1
0
mirror of https://github.com/gryf/slack-backup.git synced 2025-12-17 11:30:25 +01:00

47 Commits

Author SHA1 Message Date
2c51568e67 Added archive note. 2021-01-09 08:27:12 +01:00
3d0961c090 Adjusted reporters to handle files attached to messages. 2019-01-05 17:43:46 +01:00
fe1e2dd230 Adjusting test data/tests for new file attachment way. 2019-01-05 17:30:57 +01:00
4a3bb067f4 Fix for missing backslash escape 2019-01-05 16:55:42 +01:00
9ab0dd0da0 File extension rework for downloaded files 2019-01-05 16:52:36 +01:00
2a2f58680b Adapt to Slack file atachement API changes
Couple of months ago, file attachments was treated differently, than
now - API provides attached files as a list, instead of single object,
and is directly coupled with a message object. This change provides
support for such cases.
2019-01-05 16:48:36 +01:00
5499ee0937 Unifying switches and config options.
There was a rule for command line switches, and for ini file options,
where in command line options words are separated with hyphen and in ini
files options are separated with underscore. This rule wasn't always
enforced, fixed it now.
2018-09-27 20:23:10 +02:00
431621edb0 Fix issue with url_file_to_attachment and generating raport. 2018-09-27 20:15:44 +02:00
40c896a01e Fix for raw format function 2018-07-17 09:06:55 +02:00
9aa79bfa89 Fix: Ignore deleted channels 2018-07-16 16:16:03 +02:00
93b0bc2dd7 Added raw-dir option
This option can be used to debug slack API responses - if set,
slack-backup will dump all of the data as a JSON in provided direcotry.
2018-07-16 16:11:27 +02:00
db8527e9af Added implementation for url_file_attachment option.
Using url_file_attachment user can specify if he like to change objects
like 'file_share' marked as external (in Slack servers point of view)
to attachments, so the only value would be remembered URL for the
"uploaded" document as an attachment. Or, treat it as is, and let the
slack-backup to produce file which contain the URLs and corresponding
local file names for such files.
2018-07-16 15:20:45 +02:00
71355b1c4a Added new option - url_file_to_attachement
Currently, if message contain shared file, slack-backup will try to
download it. If it fail, than empty file will remain, which will be at
least confusing. This will mostly happen for shares which are not
uploaded to the slack servers.

New option will be used to indicate if slack-backup should convert such
share as an attachment, or to save the list of URL and their
destination in local file system to be download manually by the user.
2018-05-22 22:26:53 +02:00
007fe04c08 Use as_uri only for absolute file paths, not random strings. 2018-05-05 16:13:17 +02:00
5e7f4740ed Use utils fromtimestamp function for creating datetime objects.
This fixes OSError exception on Windows when timestamps returned by
slack API are set to 0.
2018-05-03 18:47:14 +02:00
c33d2fad50 Moved fromtimestamp function to utils module 2018-05-03 18:44:25 +02:00
43b830c3d1 Log data on failures. 2018-05-03 11:22:12 +02:00
03972e609f Fix for skipping messages for attachements. 2018-05-03 10:41:07 +02:00
641d67065c Treat bot user equally to regular users.
Currently, if messages are generated using bot users, such messages will
crash slack_backup, since user.list API method returns only regular
users. In this commit there is detected a situation, where we have
'bot_id' in the data, and in case there is no such user in local
database, create it using data from bot.info API call.
2018-05-03 10:24:57 +02:00
a57d5085b1 Explicitly open log files with utf8 encoding 2018-05-01 12:41:02 +02:00
05799e9dfe Use pathlib for providing uri 2018-05-01 12:38:07 +02:00
c0c1e7c881 Fixed issue with fromtimestamp under Windows 2018-05-01 11:58:42 +02:00
6a261c2d21 Fixes for links handling 2018-03-11 13:30:11 +01:00
39b16e68a5 Added simple html reporter 2018-03-11 12:47:11 +01:00
ce8cd4a786 Small fixes for generating procedure, re-added emoji substitution 2018-03-11 12:46:10 +01:00
7b3a4d1f68 Moved message data generators to base class 2018-03-11 11:59:08 +01:00
dcf957fc75 Fix for selecting channels log to generate 2018-03-10 19:16:58 +01:00
3e5dfb13cf Helper Reporter methods _msg* now returns dict instead of string 2018-03-10 19:10:35 +01:00
59ae9c7046 Introducing templates for message lines 2018-03-10 18:57:19 +01:00
710142d3d7 Moved write_message to Reporter base class 2018-03-10 18:48:55 +01:00
8b133ab16a Fix tox and tests 2018-03-10 18:39:50 +01:00
37aca84605 Added NoneReporter 2018-03-10 18:39:29 +01:00
57db9b69f6 Fix a way how users and URLs are treated in report
In case of user replacement there was a bunch of regular expressions
used, which was an overkill. Substituted them with one generic regexp.

Moved method which is responsible for substitution to the base class.

Last change is a cosmetic one - uploaded files are represented as
absolute path with file:// prefix, so that it is easier to open it with
xdg-open for example.
2018-03-10 17:45:31 +01:00
0ffb9f9406 Version bump to 0.7 2018-01-25 19:02:15 +01:00
2475bb029d Merge pull request #2 from lhl/master
Uploaded cookie check for logins
2018-01-24 22:06:19 +01:00
lhl
01dfc0e8bc Uploaded cookie check for logins 2018-01-24 06:35:39 -08:00
9a3c80333d Version bump 2017-11-01 18:46:33 +01:00
b2048b03e0 Changed behavoiur for duplicates
Till now, if we download certain files (like those attached to the
conversation) and we already have the file with the same name, number in
format '%03d' was added just before extension. That way there could be
possibility, that the very same file will be downloaded and stored
multiple times, like:

file.png
file.001.png
file.002.png
...

This commit prevents that by adding comparison between files we already
have and file which is downloaded from slack. Adding another file with
additional number will only have place when stored file and downloaded
have different content.
2017-11-01 18:40:52 +01:00
a077317cb4 Added retry mechanism for getting assets 2017-11-01 18:38:31 +01:00
ce2888d441 Added colors for loglevels 2017-11-01 12:45:35 +01:00
f2a78f4a52 Add message body to log 2017-11-01 11:27:01 +01:00
64d4b09468 Fix for handling messages of different types than 'message' 2017-08-06 09:22:38 +02:00
5f9f290ba4 Fix for message comment.
If comment is sent by the user, different structure of the data is sent.
First of all, the type of this message is "message", but it contain
dictionary under 'comment' key, which can be confusing, which contain
needed data (like user id). For this kind of messages, in case of lack
of 'user' in main dict, dict['comment']['user'] will be used for getting
user identifier, while dict['text'] remains as a message text.
2017-02-13 19:57:31 +01:00
Roman Dobosz
08a0a82435 Changed absolute to relative for filepaths stored in File objects 2016-12-03 18:43:49 +01:00
Roman Dobosz
a42506dff9 Fix for new fnames in case of already existing ones 2016-12-03 18:14:28 +01:00
Roman Dobosz
0d7607cf3c Added log for updating specific channel messages 2016-12-02 17:46:27 +01:00
9ddd470b54 Move commands functions to its own module 2016-11-28 19:05:26 +01:00
15 changed files with 1321 additions and 433 deletions

View File

@@ -7,6 +7,8 @@ Slack backup
.. image:: https://img.shields.io/pypi/v/slack-backup.svg .. image:: https://img.shields.io/pypi/v/slack-backup.svg
:target: https://pypi.python.org/pypi/slack-backup :target: https://pypi.python.org/pypi/slack-backup
**This project has been archived.**
The project aim is to collect conversations from Slack using its API and The project aim is to collect conversations from Slack using its API and
optionally user account information, and provides convenient way to represent optionally user account information, and provides convenient way to represent
as a log. as a log.
@@ -14,8 +16,9 @@ as a log.
Requirements Requirements
------------ ------------
This project is written in Python 3, 3.4 to be precise, although it may work on This project is written in Python 3, 3.4 to be precise (currently it works with
earlier version of Python3. Sorry no support for Python2. version 3.6), although it may work on earlier version of Python3. Sorry no
support for Python2.
Other than that, required packages are as follows: Other than that, required packages are as follows:
@@ -90,6 +93,34 @@ where:
created, but you'll (obviously) lost all the records. Besides the db file, created, but you'll (obviously) lost all the records. Besides the db file,
assets directory might be created for downloadable items. assets directory might be created for downloadable items.
You can also specify directory, where pure response JSONs from Slack API will
be stored by using ``-r/--raw-dir`` or by providing it in config file in
``fetch`` section as ``raw_dir`` (note the underscore in config file contrary
to the swith, which have hyphen between ``raw`` and ``dir``). This might be useful for
debugging purposes.
There is one more switch to take into consideration -
``-f/--url-file-to-attachment`` which influence the way how external file
share would be treated. First of all, what is *external* file share from slack
point of view, one could ask. Slack have some sort of integration with Google
services, like Google Drive, which provide slack users to create or "upload"
files from Google Drive. "Upload", since no uploading actually takes place,
and only URL is provided for such "uploads". By default `slack-backup` will
create a file which is prefixed ``manual_download_`` which will contain URL and
destination path to the file, where user should manual download file to.
Example file contents:
.. code::
http://foo.bar.com/some/file --> assets/files/83340cbe-fee2-4d2e-bdb1-cace9c82e6d4
http://foo.bar.com/some/other/file --> assets/files/8a4c873c-1864-4f1b-b515-bbef119f33a3
http://docs/google.com/some/gdoc/file --> assets/files/ec8752bc-0bf8-4743-a8bd-9756107ab386
By setting ``--url-file-to-attachment`` flag (or making an option
``url_file_to_attachment`` set to ``true`` in config file) such "uploads" would
be internally converted into Slack "attachment", which internally is an object
to store external links, so there is no need for user interaction.
During DB creation, all available messages are stored in the database. On the During DB creation, all available messages are stored in the database. On the
next run, ``fetch`` would only take those records, which are older from next run, ``fetch`` would only take those records, which are older from
currently oldest in DB. So that it will only fetch a subset of the overall of currently oldest in DB. So that it will only fetch a subset of the overall of
@@ -136,10 +167,12 @@ For convenience, you can place all of needed options into configuration file
theme = plain theme = plain
[fetch] [fetch]
url_file_to_attachment = false
user = user =
password = password =
team = team =
token = token =
raw_dir =
Note, that you don't have to put every option. To illustrate ``fetch`` example Note, that you don't have to put every option. To illustrate ``fetch`` example
from above, here is a corresponding config file: from above, here is a corresponding config file:

View File

@@ -1,122 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Create backup for certain date for specified channel in slack Execute commands for slack-backup
""" """
import argparse from slack_backup import command
import logging
from slack_backup import client
from slack_backup import config
def setup_logger(args):
"""Setup logger format and level"""
level = logging.WARNING
if args.quiet:
level = logging.ERROR
if args.quiet > 1:
level = logging.CRITICAL
if args.verbose:
level = logging.INFO
if args.verbose > 1:
level = logging.DEBUG
logging.basicConfig(level=level,
format="%(asctime)s %(levelname)s: %(message)s")
def generate_raport(args):
"""Generate logs"""
slack = client.Client(args)
slack.generate_history()
def fetch_data(args):
"""Fetch and store data"""
slack = client.Client(args)
slack.update()
def main():
"""Main function"""
parser = argparse.ArgumentParser()
subparser = parser.add_subparsers(dest='parser')
subparser.required = True
fetch = subparser.add_parser('fetch', help='Update local db with Slack'
' data')
fetch.add_argument('-t', '--token', default=None, help='Slack token - '
'a string, which can be generated/obtained via '
'https://api.slack.com/docs/oauth-test-tokens page.')
fetch.add_argument('-u', '--user', default=None, help='Username for your '
'Slack account')
fetch.add_argument('-p', '--password', default=None, help='Password for '
'your Slack account.')
fetch.add_argument('-e', '--team', default=None, help='Team name, which '
'is part of slack url, for example: if url is '
'"https://team.slack.com" than "team" is a name of '
'the team.')
fetch.add_argument('-v', '--verbose', help='Be verbose. Adding more "v" '
'will increase verbosity', action="count",
default=None)
fetch.add_argument('-q', '--quiet', help='Be quiet. Adding more "q" will'
' decrease verbosity', action="count", default=None)
fetch.add_argument('-c', '--channels', default=None, nargs='+',
help='List of channels to perform actions on. '
'Default is all channels.')
fetch.add_argument('-d', '--database', default=None,
help='Path to the database file.')
fetch.add_argument('-i', '--config', default=None,
help='Use specific config file.')
fetch.set_defaults(func=fetch_data)
generate = subparser.add_parser('generate', help='Generate logs out of '
'data in provided database')
generate.add_argument('-o', '--output', default=None, help="Output "
"directory for store logs. All logs are organised "
"per channel. By default it's `logs' directory")
generate.add_argument('-f', '--format', default=None,
choices=('text', 'none'),
help='Output format. Default is none; only database '
'is updated by latest messages for all/selected '
'channels.')
generate.add_argument('-t', '--theme', default=None,
choices=('plain', 'unicode'),
help='Choose theme for text output. It doesn\'t '
'affect other output formats.')
generate.add_argument('-v', '--verbose', help='Be verbose. Adding more '
'"v" will increase verbosity', action="count",
default=None)
generate.add_argument('-q', '--quiet', help='Be quiet. Adding more "q" '
'will decrease verbosity', action="count",
default=None)
generate.add_argument('-c', '--channels', default=[], nargs='+',
help='List of channels to perform actions on. '
'Default is all channels.')
generate.add_argument('-d', '--database', default=None,
help='Path to the database file.')
generate.add_argument('-i', '--config', default=None,
help='Use specific config file.')
generate.set_defaults(func=generate_raport)
args = parser.parse_args()
cfg = config.Config()
msg = cfg.update(args)
setup_logger(args)
logging.info(msg)
args.func(args)
if __name__ == "__main__": if __name__ == "__main__":
main() command.main()

View File

@@ -10,7 +10,7 @@ except ImportError:
setup(name="slack-backup", setup(name="slack-backup",
packages=["slack_backup"], packages=["slack_backup"],
version="0.4.3", version="0.8",
description="Make copy of slack converstaions", description="Make copy of slack converstaions",
author="Roman Dobosz", author="Roman Dobosz",
author_email="gryf73@gmail.com", author_email="gryf73@gmail.com",
@@ -21,7 +21,7 @@ setup(name="slack-backup",
scripts=["scripts/slack-backup"], scripts=["scripts/slack-backup"],
classifiers=["Programming Language :: Python :: 3", classifiers=["Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.4",
"Development Status :: 3 - Alpha", "Development Status :: 4 - Beta",
"Environment :: Console", "Environment :: Console",
"Intended Audience :: End Users/Desktop", "Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: BSD License", "License :: OSI Approved :: BSD License",

View File

@@ -3,15 +3,20 @@ Create backup for certain date for specified channel in slack
""" """
from datetime import datetime from datetime import datetime
import getpass import getpass
import json
import logging import logging
import os import os
import pprint
import uuid
import slackclient import slackclient
import sqlalchemy.orm.exc
from slack_backup import db from slack_backup import db
from slack_backup import objects as o from slack_backup import objects as o
from slack_backup import download from slack_backup import download
from slack_backup import reporters from slack_backup import reporters
from slack_backup import utils
class Client(object): class Client(object):
@@ -19,6 +24,8 @@ class Client(object):
This class is intended to provide an interface for getting, storing and This class is intended to provide an interface for getting, storing and
querying data fetched out using Slack API. querying data fetched out using Slack API.
""" """
RAW = '%Y%m%d%H%M%S_{name}.json'
def __init__(self, args): def __init__(self, args):
if 'token' in args: if 'token' in args:
self.slack = slackclient.SlackClient(args.token) self.slack = slackclient.SlackClient(args.token)
@@ -40,9 +47,24 @@ class Client(object):
self.selected_channels = args.channels self.selected_channels = args.channels
self.q = self.session.query self.q = self.session.query
self._raw_fname = None
if 'raw_dir' in args and args.raw_dir:
if not os.path.exists(args.raw_dir):
os.mkdir(args.raw_dir)
fpath = os.path.join(args.raw_dir, self.RAW)
self._raw_fname = datetime.now().strftime(fpath)
if 'format' in args: if 'format' in args:
self.reporter = reporters.get_reporter(args, self.q) self.reporter = reporters.get_reporter(args, self.q)
if 'url_file_to_attachment' in args:
self._url_file_to_attachment = args.url_file_to_attachment
self._dlpath = utils.get_temp_name(dir=os.path.curdir,
prefix='manual_download_',
unlink=True)
self._dldata = []
def update(self): def update(self):
""" """
Perform an update, store data to db Perform an update, store data to db
@@ -51,6 +73,7 @@ class Client(object):
self.update_users() self.update_users()
self.update_channels() self.update_channels()
self.update_history() self.update_history()
self._finalize()
def update_channels(self): def update_channels(self):
"""Fetch and update channel list with current state in db""" """Fetch and update channel list with current state in db"""
@@ -60,6 +83,10 @@ class Client(object):
if not result: if not result:
return return
if self._raw_fname:
with open(self._raw_fname.format(name='channels'), 'w') as fobj:
fobj.write(json.dumps(result))
for data in result: for data in result:
channel = self.q(o.Channel).\ channel = self.q(o.Channel).\
filter(o.Channel.slackid == data['id']).one_or_none() filter(o.Channel.slackid == data['id']).one_or_none()
@@ -74,14 +101,16 @@ class Client(object):
def update_users(self): def update_users(self):
"""Fetch and update user list with current state in db""" """Fetch and update user list with current state in db"""
logging.info("Fetching and updating user information in DB") result = self._users_list()
result = self.slack.api_call("users.list", presence=0)
if not result.get("ok"): if not result:
logging.error(result['error'])
return return
for user_data in result['members']: if self._raw_fname:
with open(self._raw_fname.format(name='users'), 'w') as fobj:
fobj.write(json.dumps(result))
for user_data in result:
user = self.q(o.User).\ user = self.q(o.User).\
filter(o.User.slackid == user_data['id']).one_or_none() filter(o.User.slackid == user_data['id']).one_or_none()
@@ -112,6 +141,7 @@ class Client(object):
channels = all_channels channels = all_channels
for channel in channels: for channel in channels:
logging.info("Getting messages for channel `%s'", channel.name)
latest = self.q(o.Message).\ latest = self.q(o.Message).\
filter(o.Message.channel == channel).\ filter(o.Message.channel == channel).\
order_by(o.Message.ts.desc()).first() order_by(o.Message.ts.desc()).first()
@@ -123,9 +153,15 @@ class Client(object):
# starting from first January 1970. # starting from first January 1970.
latest = latest and latest.ts or 1 latest = latest and latest.ts or 1
result = []
while True: while True:
logging.debug("Fetching another portion of messages") logging.debug("Fetching another portion of messages")
messages, latest = self._channels_history(channel, latest) messages, latest = self._channels_history(channel, latest)
if messages is None:
# ignore deleted channels
break
result.extend(messages)
for msg in messages: for msg in messages:
self._create_message(msg, channel) self._create_message(msg, channel)
@@ -133,6 +169,11 @@ class Client(object):
if latest is None: if latest is None:
break break
if self._raw_fname:
with open(self._raw_fname.format(name='channel-' +
channel.name), 'w') as fobj:
fobj.write(json.dumps(result))
self.session.commit() self.session.commit()
def generate_history(self): def generate_history(self):
@@ -141,15 +182,64 @@ class Client(object):
""" """
self.reporter.generate() self.reporter.generate()
def _get_user(self, data):
"""
Return an User object. It can be regular one, or a bot. In case of
bot, check if it exists in db, and in case of failure - create it,
since bots are not returned by user.list API method.
"""
try:
return self.q(o.User).filter(o.User.slackid == data['user']).one()
except KeyError:
pass
try:
return self.q(o.User).filter(o.User.slackid ==
data['comment']['user']).one()
except KeyError:
pass
try:
return self.q(o.User).filter(o.User.slackid ==
data['bot_id']).one()
except KeyError:
pass
except sqlalchemy.orm.exc.NoResultFound:
result = self.slack.api_call('bots.info', bot=data['bot_id'])
if not result.get("ok"):
logging.error(result['error'])
return None
user = o.User(result['bot'])
user.real_name = result['bot']['name']
self.session.add(user)
self.session.flush()
if self._raw_fname:
with open(self._raw_fname.format(name='bot-' + user.slackid),
"w") as fobj:
fobj.write(json.dumps(result))
return user
logging.exception('Failed on data: %s', pprint.pformat(data))
raise ValueError('Cannot identify user out of given data.')
def _create_message(self, data, channel): def _create_message(self, data, channel):
""" """
Create message with corresponding possible metadata, like reactions, Create message with corresponding possible metadata, like reactions,
files etc. files etc.
""" """
user = self.q(o.User).\ if data['type'] != 'message':
filter(o.User.slackid == data['user']).one() logging.info("Skipping message of type `%s'.", data['type'])
return
if data['type'] == 'message' and not data['text'].strip(): logging.debug('Message data: %s', json.dumps(data))
user = self._get_user(data)
if not any((data.get('attachments'), data['text'].strip(),
data.get('files'))):
logging.info("Skipping message from `%s' since it's empty", logging.info("Skipping message from `%s' since it's empty",
user.name) user.name)
return return
@@ -165,37 +255,67 @@ class Client(object):
for reaction_data in data['reactions']: for reaction_data in data['reactions']:
message.reactions.append(o.Reaction(reaction_data)) message.reactions.append(o.Reaction(reaction_data))
if data.get('subtype') == 'file_share': if data.get('files'):
self._file_data(message, data['file'], data['file']['is_external']) for fdata in data['files']:
elif data.get('subtype') == 'pinned_item': if (self._url_file_to_attachment and fdata.get('is_external')):
logging.info('got external file')
message.text = (message.text.split('shared a file:')[0] +
'shared a file: ')
logging.debug("Found external file `%s'. Saving as "
"attachment.", fdata['url_private'])
self._att_data(message, [{'title': fdata['name'],
'text': fdata['url_private'],
'fallback': ''}])
else:
self._file_data(message, fdata)
# TODO(gryf): subtype pinned_item coexistsing with pinned_info message
# key :C
# pinned_info however is just a mark, which point to the channlel
# where it is pinned to, who did that and when. To be resolved.
if data.get('subtype') == 'pinned_item':
if data.get('attachments'): if data.get('attachments'):
self._att_data(message, data['attachments']) self._att_data(message, data['attachments'])
elif data.get('item'): elif data.get('item'):
self._file_data(message, data['item'], self._file_data(message, data['item'])
data['item']['is_external'])
elif data.get('attachments'): elif data.get('attachments'):
self._att_data(message, data['attachments']) self._att_data(message, data['attachments'])
self.session.add(message) self.session.add(message)
def _file_data(self, message, data, is_external=True): def _file_data(self, message, data):
""" """
Process file data. Could be either represented as 'file' object or Process file data. Could be either represented as 'file' object or
'item' object in case of pinned items 'item' object in case of pinned items
""" """
message.file = o.File(data) _file = o.File(data)
message.files.append(_file)
if data.get('mode') == 'tombstone':
_file.title = 'This file was deleted'
return
if data.get('is_starred'): if data.get('is_starred'):
message.is_starred = True message.is_starred = True
if is_external: if data.get('is_external'):
logging.debug("Found external file `%s'", data['url_private']) # Create a link and corresponding file name for manual download
message.file.url = data['url_private'] fname = str(uuid.uuid4())
_file.filepath = self.downloader.get_filepath(fname, 'file')
logging.info("Please, manually download an external file from "
"URL `%s' to `%s'", data['url_private'],
_file.filepath)
self._dldata.append('%s --> %s\n' % (data['url_private'],
_file.filepath))
_file.url = data['url_private']
else: else:
logging.debug("Found internal file `%s'", logging.debug("Found internal file `%s'",
data['url_private_download']) data['url_private_download'])
priv_url = data['url_private_download'] priv_url = data['url_private_download']
message.file.filepath = self.downloader.download(priv_url, 'file') _file.filepath = self.downloader.download(priv_url, 'file',
self.session.add(message.file) data.get('filetype'))
self.session.add(_file)
def _att_data(self, message, data): def _att_data(self, message, data):
""" """
@@ -216,11 +336,11 @@ class Client(object):
user = self.q(o.User).filter(o.User.slackid == user = self.q(o.User).filter(o.User.slackid ==
data['creator']).one_or_none() data['creator']).one_or_none()
obj = self.q(classobj).\ obj = (self.q(classobj).
filter(classobj.last_set == filter(classobj.last_set ==
datetime.fromtimestamp(data['last_set'])).\ utils.fromtimestamp(data['last_set'])).
filter(classobj.value == data['value']).\ filter(classobj.value == data['value']).
filter(classobj.creator == user).one_or_none() filter(classobj.creator == user).one_or_none())
if not obj: if not obj:
# break channel relation # break channel relation
@@ -255,7 +375,7 @@ class Client(object):
if not database: if not database:
return 'assets' return 'assets'
path = os.path.dirname(os.path.abspath(database)) path = os.path.dirname(database)
return os.path.join(path, 'assets') return os.path.join(path, 'assets')
def _channels_list(self): def _channels_list(self):
@@ -276,7 +396,8 @@ class Client(object):
Get users list using Slack API. Return list of channel data or None Get users list using Slack API. Return list of channel data or None
in case of error. in case of error.
""" """
result = self.slack.api_call("users.list", presence=0) logging.info("Fetching and updating user information in DB")
result = self.slack.api_call("users.list")
if not result.get("ok"): if not result.get("ok"):
logging.error(result['error']) logging.error(result['error'])
@@ -309,3 +430,14 @@ class Client(object):
return result['messages'], None return result['messages'], None
return [], None return [], None
def _finalize(self):
"""Create misc files if necessary - like manual donwload"""
if not self._dldata:
return
with open(self._dlpath, "a") as fobj:
fobj.write(''.join(self._dldata))
logging.warning("Manual action required! Download all the files "
"listed in `%s' and each of them save as file listed "
"right after `-->' sign", self._dlpath)

143
slack_backup/command.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Create backup for certain date for specified channel in slack
"""
import argparse
import logging
import platform
from slack_backup import client
from slack_backup import config
def setup_logger(args):
"""Setup logger format and level"""
if platform.system() != "Windows":
# hack to have colors in terminal
logging.addLevelName(logging.DEBUG,
"\033[1;30m%s\033[1;0m" %
logging.getLevelName(logging.DEBUG))
logging.addLevelName(logging.INFO,
"\033[1;32m%s\033[1;0m" %
logging.getLevelName(logging.INFO))
logging.addLevelName(logging.WARNING,
"\033[1;33m%s\033[1;0m" %
logging.getLevelName(logging.WARNING))
logging.addLevelName(logging.ERROR,
"\033[1;31m%s\033[1;0m" %
logging.getLevelName(logging.ERROR))
logging.addLevelName(logging.CRITICAL,
"\033[7;31m%s\033[1;0m" %
logging.getLevelName(logging.CRITICAL))
level = logging.WARNING
if args.quiet:
level = logging.ERROR
if args.quiet > 1:
level = logging.CRITICAL
if args.verbose:
level = logging.INFO
if args.verbose > 1:
level = logging.DEBUG
logging.basicConfig(level=level,
format="%(asctime)s %(levelname)s: %(message)s")
def generate_raport(args):
"""Generate logs"""
slack = client.Client(args)
slack.generate_history()
def fetch_data(args):
"""Fetch and store data"""
slack = client.Client(args)
slack.update()
def main():
"""Main function"""
parser = argparse.ArgumentParser()
subparser = parser.add_subparsers(dest='parser')
subparser.required = True
fetch = subparser.add_parser('fetch', help='Update local db with Slack'
' data')
fetch.add_argument('-t', '--token', default=None, help='Slack token - '
'a string, which can be generated/obtained via '
'https://api.slack.com/docs/oauth-test-tokens page.')
fetch.add_argument('-u', '--user', default=None, help='Username for your '
'Slack account')
fetch.add_argument('-p', '--password', default=None, help='Password for '
'your Slack account.')
fetch.add_argument('-e', '--team', default=None, help='Team name, which '
'is part of slack url, for example: if url is '
'"https://team.slack.com" than "team" is a name of '
'the team.')
fetch.add_argument('-v', '--verbose', help='Be verbose. Adding more "v" '
'will increase verbosity', action="count",
default=None)
fetch.add_argument('-q', '--quiet', help='Be quiet. Adding more "q" will'
' decrease verbosity', action="count", default=None)
fetch.add_argument('-c', '--channels', default=None, nargs='+',
help='List of channels to perform actions on. '
'Default is all channels.')
fetch.add_argument('-d', '--database', default=None,
help='Path to the database file.')
fetch.add_argument('-i', '--config', default=None,
help='Use specific config file.')
fetch.add_argument('-r', '--raw-dir', default=None,
help='Write raw responses to provided directory.')
fetch.add_argument('-f', '--url-file-to-attachment', default=False,
action='store_true',
help='Treat shared files (but not uploaded to the '
'Slack servers) as attachment. By default there will '
'be file created in current directory with url and '
'path to the filename under which it would be '
'registered in the DB.')
fetch.set_defaults(func=fetch_data)
generate = subparser.add_parser('generate', help='Generate logs out of '
'data in provided database')
generate.add_argument('-o', '--output', default=None, help="Output "
"directory for store logs. All logs are organised "
"per channel. By default it's `logs' directory")
generate.add_argument('-f', '--format', default=None,
choices=('text', 'html', 'none'),
help='Output format. Default is none; only database '
'is updated by latest messages for all/selected '
'channels.')
generate.add_argument('-t', '--theme', default=None,
choices=('plain', 'unicode'),
help='Choose theme for text output. It doesn\'t '
'affect other output formats.')
generate.add_argument('-v', '--verbose', help='Be verbose. Adding more '
'"v" will increase verbosity', action="count",
default=None)
generate.add_argument('-q', '--quiet', help='Be quiet. Adding more "q" '
'will decrease verbosity', action="count",
default=None)
generate.add_argument('-c', '--channels', default=[], nargs='+',
help='List of channels to perform actions on. '
'Default is all channels.')
generate.add_argument('-d', '--database', default=None,
help='Path to the database file.')
generate.add_argument('-i', '--config', default=None,
help='Use specific config file.')
generate.set_defaults(func=generate_raport)
args = parser.parse_args()
cfg = config.Config()
msg = cfg.update(args)
setup_logger(args)
logging.info(msg)
args.func(args)

View File

@@ -13,9 +13,11 @@ class Config(object):
"""Configuration keeper""" """Configuration keeper"""
ints = ['verbose', 'quiet'] ints = ['verbose', 'quiet']
bools = ['url_file_to_attachment']
sections = {'common': ['channels', 'database', 'quiet', 'verbose'], sections = {'common': ['channels', 'database', 'quiet', 'verbose'],
'fetch': ['user', 'password', 'team', 'token'], 'fetch': ['user', 'password', 'team', 'token',
'url_file_to_attachment', 'raw_dir'],
'generate': ['output', 'format', 'theme']} 'generate': ['output', 'format', 'theme']}
def __init__(self): def __init__(self):
@@ -35,7 +37,9 @@ class Config(object):
'token': None, 'token': None,
'output': None, 'output': None,
'format': None, 'format': None,
'theme': None} 'theme': None,
'url_file_to_attachment': False,
'raw_dir': None}
# This message supposed to be displayed in INFO level. During the time # This message supposed to be displayed in INFO level. During the time
# of running the code where it should be displayed there is no # of running the code where it should be displayed there is no
# complete information about logging level. Displaying message is # complete information about logging level. Displaying message is
@@ -79,6 +83,8 @@ class Config(object):
for option in self.sections[section]: for option in self.sections[section]:
if option in self.ints: if option in self.ints:
val = self.cp.getint(section, option, fallback=0) val = self.cp.getint(section, option, fallback=0)
elif option in self.bools:
val = self.cp.getboolean(section, option, fallback=False)
elif option == 'channels': elif option == 'channels':
val = self.cp.get(section, option, fallback='[]') val = self.cp.get(section, option, fallback='[]')
val = json.loads(val) val = json.loads(val)

View File

@@ -2,14 +2,45 @@
Module for download files, store them in local filesystem and convert the URLs Module for download files, store them in local filesystem and convert the URLs
to local ones, so that sophisticated writers can make a use of it to local ones, so that sophisticated writers can make a use of it
""" """
import functools
import logging import logging
import os import os
import shutil
import requests import requests
from slack_backup import utils from slack_backup import utils
def retry(count):
"""
Decorator for a case, when there is some network hiccup, or slack servers
are too busy to respond or on connection timeout. Parameter count says how
many times it should try to perform request.
"""
def wrapper(func):
@functools.wraps(func)
def inner(obj, *args, **kwargs):
counter = count
while counter:
counter -= 1
try:
return func(obj, *args, **kwargs)
except requests.exceptions.RequestException as exc:
if not counter:
logging.error('Request for %s failed. Reported '
'reason: %s', args[0], exc.__doc__)
raise
logging.warning('Request for %s failed. Reported '
'reason: %s. Retrying.', args[0],
exc.__doc__)
# Renew the session before retry
obj.authorize()
return inner
return wrapper
class NotAuthorizedError(requests.HTTPError): class NotAuthorizedError(requests.HTTPError):
pass pass
@@ -18,7 +49,7 @@ class Download(object):
"""Download class for taking care of Slack internally uploaded files""" """Download class for taking care of Slack internally uploaded files"""
def __init__(self, args, assets_dir): def __init__(self, args, assets_dir):
self.session = requests.session() self.session = None
self.team = args.team self.team = args.team
self.user = args.user self.user = args.user
self.password = args.password self.password = args.password
@@ -29,7 +60,7 @@ class Download(object):
self._hier_created = False self._hier_created = False
self.cookies = {} self.cookies = {}
def download(self, url, filetype): def download(self, url, filetype, ext=None):
""" """
Download asset, return local path to it Download asset, return local path to it
""" """
@@ -37,10 +68,26 @@ class Download(object):
if not self._hier_created: if not self._hier_created:
self._create_assets_dir() self._create_assets_dir()
fname = self.prepare_filepath(url, filetype) filepath = self.get_filepath(url, filetype, ext)
temp_file = utils.get_temp_name()
self._download(url, fname) self._download(url, temp_file)
return fname
if filepath and os.path.exists(filepath) and filetype != 'avatar':
if not utils.same_files(filepath, temp_file):
logging.warning("File `%s' already exist, renamed to `%s'",
filepath,
self.calculate_new_filename(filepath,
filetype))
filepath = self.calculate_new_filename(filepath, filetype)
shutil.move(temp_file, filepath)
else:
logging.debug("File `%s' already exist, skipping", filepath)
os.unlink(filepath)
else:
shutil.move(temp_file, filepath)
return filepath
def _create_assets_dir(self): def _create_assets_dir(self):
for path in (self._files, self._images): for path in (self._files, self._images):
@@ -48,20 +95,21 @@ class Download(object):
self._hier_created = True self._hier_created = True
def prepare_filepath(self, url, filetype): def get_filepath(self, url, filetype, ext=None):
"""Prepare directory where to download file into""" """Get full path and filename for the file"""
typemap = {'avatar': self._images, typemap = {'avatar': self._images,
'file': self._files} 'file': self._files}
if filetype == 'file' and not self._authorized: if filetype == 'file' and not self._authorized:
logging.info("There was no (valid) credentials passed, therefore " logging.warning("There was no (valid) credentials passed, "
"file `%s' cannot be downloaded", url) "therefore file `%s' cannot be downloaded", url)
return return
splitted = url.split('/') splitted = url.split('/')
if len(splitted) == 7 and 'slack.com' in splitted[2]: if len(splitted) == 7 and ('slack.com' in splitted[2] or
'slack-edge.com' in splitted[2]):
part = url.split('/')[-3] part = url.split('/')[-3]
fname = url.split('/')[-1] fname = url.split('/')[-1]
else: else:
@@ -75,24 +123,27 @@ class Download(object):
utils.makedirs(os.path.join(path, part)) utils.makedirs(os.path.join(path, part))
path = os.path.join(path, part) path = os.path.join(path, part)
path = os.path.join(path, fname) if ext and not fname.endswith(ext):
fname = fname + "." + ext
return os.path.join(path, fname)
def calculate_new_filename(self, path, filetype):
count = 1 count = 1
while filetype != 'avatar' and os.path.exists(path): while filetype != 'avatar' and os.path.exists(path):
base, ext = os.path.splitext(path) if count == 1:
path = base + "%0.3d" % count + ext base, ext = os.path.splitext(path)
path = base + ".%0.3d" % count + ext
count += 1
return path return path
@retry(3)
def _download(self, url, local): def _download(self, url, local):
"""Download file""" """Download file"""
try: res = self.session.get(url, stream=True)
res = self.session.get(url, stream=True)
except requests.exceptions.RequestException as exc:
logging.error('Request for %s failed. Reported reason: %s',
url, exc.__doc__)
raise
with open(local, 'wb') as fobj: with open(local, 'wb') as fobj:
for chunk in res.iter_content(chunk_size=5120): for chunk in res.iter_content(chunk_size=5120):
@@ -104,7 +155,9 @@ class Download(object):
""" """
Authenticate and gather session for Slack Authenticate and gather session for Slack
""" """
self.session = requests.session() # new session
res = self.session.get('https://%s.slack.com/' % self.team) res = self.session.get('https://%s.slack.com/' % self.team)
if not all((self.team, self.password, self.user)): if not all((self.team, self.password, self.user)):
logging.warning('There is neither username, password or team name' logging.warning('There is neither username, password or team name'
' provided. Downloading will not be performed.') ' provided. Downloading will not be performed.')
@@ -112,7 +165,7 @@ class Download(object):
crumb = '' crumb = ''
for line in res.text.split('\n'): for line in res.text.split('\n'):
if 'crumb' in line: if 'crumb' in line and 'value' in line:
crumb = line.split('value=')[1].split('"')[1] crumb = line.split('value=')[1].split('"')[1]
break break
else: else:
@@ -125,8 +178,7 @@ class Download(object):
'password': self.password, 'password': self.password,
'signin': 1}) 'signin': 1})
self.cookies = requests.utils.dict_from_cookiejar(self.session.cookies) self.cookies = requests.utils.dict_from_cookiejar(self.session.cookies)
if not ('a' in self.cookies and 'b' in self.cookies and if not ('d' in self.cookies and 'd-s' in self.cookies):
('a-' + self.cookies['a']) in self.cookies):
logging.error('Failed to login into Slack app') logging.error('Failed to login into Slack app')
else: else:
self._authorized = True self._authorized = True

View File

@@ -53,7 +53,7 @@ EMOJI = {'plain': {":bowtie:": ':)8',
":scream:": 'X-O', ":scream:": 'X-O',
":tired_face:": 'D-X', ":tired_face:": 'D-X',
":angry:": "(`##')", ":angry:": "(`##')",
":rage:": "\(`###')>", ":rage:": "\\(`###')>",
":triumph:": '(-A-)', ":triumph:": '(-A-)',
":sleepy:": '(-_-) zZ', ":sleepy:": '(-_-) zZ',
":sunglasses:": 'B-)', ":sunglasses:": 'B-)',

View File

@@ -1,13 +1,12 @@
""" """
Convinient object mapping from slack API reponses Convinient object mapping from slack API reponses
""" """
from datetime import datetime
from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey
from sqlalchemy import DateTime from sqlalchemy import DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from slack_backup.db import Base from slack_backup.db import Base
from slack_backup import utils
class Purpose(Base): class Purpose(Base):
@@ -28,7 +27,7 @@ class Purpose(Base):
def update(self, data_dict): def update(self, data_dict):
data_dict = data_dict or {} data_dict = data_dict or {}
self.last_set = datetime.fromtimestamp(data_dict.get('last_set', 0)) self.last_set = utils.fromtimestamp(data_dict.get('last_set'))
self.value = data_dict.get('value') self.value = data_dict.get('value')
def __repr__(self): def __repr__(self):
@@ -56,7 +55,7 @@ class Topic(Base):
def update(self, data_dict): def update(self, data_dict):
data_dict = data_dict or {} data_dict = data_dict or {}
self.last_set = datetime.fromtimestamp(data_dict.get('last_set', 0)) self.last_set = utils.fromtimestamp(data_dict.get('last_set'))
self.value = data_dict.get('value') self.value = data_dict.get('value')
def __repr__(self): def __repr__(self):
@@ -91,7 +90,7 @@ class Channel(Base):
self.slackid = data_dict.get('id', '') self.slackid = data_dict.get('id', '')
self.name = data_dict.get('name', '') self.name = data_dict.get('name', '')
self.created = datetime.fromtimestamp(data_dict.get('created', 0)) self.created = utils.fromtimestamp(data_dict.get('created'))
self.is_archived = data_dict.get('is_archived', False) self.is_archived = data_dict.get('is_archived', False)
def __repr__(self): def __repr__(self):
@@ -207,7 +206,7 @@ class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
# NOTE(gryf): timestamp from messages are coming as text. It might be # NOTE(gryf): timestamps from messages are coming as text. It might be
# tempting to store them as Decimal or Integer, but it doesn't really # tempting to store them as Decimal or Integer, but it doesn't really
# matters since in case of Decimal sqlite doesn't support it, and Integer # matters since in case of Decimal sqlite doesn't support it, and Integer
# require additional conversion. It might be critical for messages to have # require additional conversion. It might be critical for messages to have
@@ -229,14 +228,14 @@ class Message(Base):
channel = relationship("Channel", back_populates="messages") channel = relationship("Channel", back_populates="messages")
reactions = relationship("Reaction", back_populates="message") reactions = relationship("Reaction", back_populates="message")
file = relationship("File", uselist=False, back_populates="message") files = relationship("File", back_populates="message")
attachments = relationship("Attachment", back_populates="message") attachments = relationship("Attachment", back_populates="message")
def __init__(self, data_dict=None): def __init__(self, data_dict=None):
self.update(data_dict) self.update(data_dict)
def datetime(self): def datetime(self):
return datetime.fromtimestamp(float(self.ts)) return utils.fromtimestamp(float(self.ts))
def update(self, data_dict): def update(self, data_dict):
data_dict = data_dict or {} data_dict = data_dict or {}
@@ -256,7 +255,7 @@ class File(Base):
filepath = Column(Text) filepath = Column(Text)
message_id = Column(Integer, ForeignKey('messages.id')) message_id = Column(Integer, ForeignKey('messages.id'))
message = relationship('Message', back_populates='file') message = relationship('Message', back_populates='files')
def __init__(self, data_dict=None): def __init__(self, data_dict=None):
self.update(data_dict) self.update(data_dict)

View File

@@ -8,6 +8,7 @@ import os
import errno import errno
import html.parser import html.parser
import logging import logging
import pathlib
import re import re
from slack_backup import objects as o from slack_backup import objects as o
@@ -18,6 +19,24 @@ from slack_backup import emoji
class Reporter(object): class Reporter(object):
"""Base reporter class""" """Base reporter class"""
ext = '' ext = ''
symbols = {'plain': {'join': '->',
'leave': '<-',
'me': '*',
'file': '-',
'topic': '+',
'separator': '|'},
'unicode': {'join': '',
'leave': '',
'me': '🟊',
'file': '📂',
'topic': '🟅',
'separator': ''}}
literal_url_pat = re.compile(r'(?P<replace>(?P<url>https?[^\s\|]+))')
url_pat = re.compile(r'(?P<replace><(?P<url>http[^\|>]+)'
r'(\|(?P<title>[^>]+))?>)')
url2_pat = re.compile(r'<(?P<url>https?[^\s\|]+)>')
slackid_pat = re.compile(r'(?P<replace><@'
r'(?P<slackid>U[A-Z,0-9]+)(\|[^>]+)?[^>]*>)')
def __init__(self, args, query): def __init__(self, args, query):
self.out = args.output self.out = args.output
@@ -26,44 +45,47 @@ class Reporter(object):
self.types = {"channel_join": self._msg_join, self.types = {"channel_join": self._msg_join,
"channel_leave": self._msg_leave, "channel_leave": self._msg_leave,
"channel_topic": self._msg_topic, "channel_topic": self._msg_topic,
"file_share": self._msg_file, # "file_share": self._msg_file,
"me_message": self._msg_me} "me_message": self._msg_me}
self.symbols = {'plain': {'join': '->',
'leave': '<-',
'me': '*',
'file': '-',
'topic': '+',
'separator': '|'},
'unicode': {'join': '',
'leave': '',
'me': '🟊',
'file': '📂',
'topic': '🟅',
'separator': ''}}
self.emoji = emoji.EMOJI.get(args.theme, {}) self.emoji = emoji.EMOJI.get(args.theme, {})
self.channels = self._get_channels(args.channels) self.channels = self._get_channels(args.channels)
self.users = self.q(o.User).all() self.users = self.q(o.User).all()
self._slackid_pat = [re.compile(r'^(?P<replace>'
r'<@(?P<slackid>U[A-Z,0-9]+)\|.+>)'),
re.compile('^(?P<replace>'
'<@(?P<slackid>U[A-Z,0-9]+)>)'),
re.compile(r'.*(?P<replace>'
r'<@(?P<slackid>U[A-Z,0-9]+)\|.+>)'),
re.compile('.*(?P<replace><@(?P<slackid>'
'U[A-Z,0-9]+)>)')]
def generate(self): def generate(self):
"""Generate raport it's a dummmy one - for use with none reporter""" """Generate raport for each channel"""
return for channel in self.channels:
messages = []
log_path = self.get_log_path(channel.name)
try:
os.unlink(log_path)
except IOError as err:
if err.errno != errno.ENOENT:
raise
for message in self.q(o.Message).\
filter(o.Message.channel == channel).\
order_by(o.Message.ts).all():
messages.append(message)
self.write_msg(messages, log_path, channel)
def get_log_path(self, name): def get_log_path(self, name):
"""Return relative log file name """ """Return relative log file name """
return os.path.join(self.out, name + self.ext) return os.path.join(self.out, name + self.ext)
def write_msg(self, message, log): def write_msg(self, messages, log, channel):
"""Write message to file""" """Write message to file"""
raise NotImplementedError() with open(log, "a", encoding='utf8') as fobj:
for message in messages:
data = self._process_message(message)
fobj.write(data['tpl'].format(**data))
if message.files:
for _file in message.files:
data = self._msg_file(message, _file)
fobj.write(data['tpl'].format(**data))
# else:
# data = self._process_message(message)
# fobj.write(data['tpl'].format(**data))
def _get_symbol(self, item): def _get_symbol(self, item):
"""Return appropriate item depending on the selected theme""" """Return appropriate item depending on the selected theme"""
@@ -82,46 +104,92 @@ class Reporter(object):
for channel in all_channels: for channel in all_channels:
if channel.name in selected_channels: if channel.name in selected_channels:
result.append(channel) result.append(channel)
return result
def _msg_join(self, msg, text): def _process_message(self, msg):
"""return formatter for join""" """
return Make changes to the text (replace slack ids, replace representation of
urls, substitute images etc) and return dict with data suitable to
display.
"""
processor = self.types.get(msg.type, self._msg)
data = processor(msg)
data.update({'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'tpl': "{date} {nick} {msg}"})
def _msg_leave(self, msg, text): for emoticon in self.emoji:
"""return formatter for leave""" data['msg'] = data['msg'].replace(emoticon, self.emoji[emoticon])
return
def _msg_topic(self, msg, text): return data
"""return formatter for set topic"""
return
def _msg_me(self, msg, text): def _msg_join(self, msg):
"""return formatter for /me""" """return data for join"""
return return {'msg': msg.text,
'nick': self._get_symbol('join')}
def _msg_file(self, msg, text): def _msg_leave(self, msg):
"""return formatter for /me""" """return data for leave"""
return return {'msg': msg.text,
'nick': self._get_symbol('leave')}
def _msg_topic(self, msg):
"""return data for set topic"""
return {'msg': msg.text,
'nick': self._get_symbol('topic')}
def _msg_me(self, msg):
"""return data for /me"""
return {'msg': msg.user.name + ' ' + msg.text,
'nick': self._get_symbol('me')}
def _msg_file(self, msg, _file):
"""return data for file"""
return {'msg': msg.text,
'nick': self._get_symbol('file')}
def _msg(self, msg):
"""return data for all other message types"""
return {'msg': msg.text,
'nick': msg.user.name}
def _filter_slackid(self, text): def _filter_slackid(self, text):
"""filter out all of the id from slack""" """filter out all of the id from slack"""
match = True
while match:
match = self.slackid_pat.search(text)
if not match:
return text
match = match.groupdict()
user = self.q(o.User).filter(o.User.slackid ==
match['slackid']).one()
text = text.replace(match['replace'], user.name)
return text
class NoneReporter(Reporter):
"""Dummy reporter used for fallback"""
def generate(self):
"""Generate raport it's a dummmy one - for use with none reporter"""
return return
class TextReporter(Reporter): class TextReporter(Reporter):
"""Text aka IRC reporter""" """Text aka IRC reporter"""
ext = '.log' ext = '.log'
tpl = '{date} {nick:>{max_len}} {separator} {msg}\n'
def __init__(self, args, query): def __init__(self, args, query):
super(TextReporter, self).__init__(args, query) super(TextReporter, self).__init__(args, query)
utils.makedirs(self.out) utils.makedirs(self.out)
self._max_len = 0 self._max_len = 0
return
def generate(self): def generate(self):
"""Generate raport""" """Generate raport"""
for channel in self.channels: for channel in self.channels:
messages = []
log_path = self.get_log_path(channel.name) log_path = self.get_log_path(channel.name)
self._set_max_len(channel) self._set_max_len(channel)
try: try:
@@ -132,12 +200,9 @@ class TextReporter(Reporter):
for message in self.q(o.Message).\ for message in self.q(o.Message).\
filter(o.Message.channel == channel).\ filter(o.Message.channel == channel).\
order_by(o.Message.ts).all(): order_by(o.Message.ts).all():
self.write_msg(message, log_path) messages.append(message)
def write_msg(self, message, log): self.write_msg(messages, log_path, channel)
"""Write message to file"""
with open(log, "a") as fobj:
fobj.write(self._format_message(message))
def _set_max_len(self, channel): def _set_max_len(self, channel):
"""calculate max_len for sepcified channel""" """calculate max_len for sepcified channel"""
@@ -149,132 +214,61 @@ class TextReporter(Reporter):
if len(user_name) > self._max_len: if len(user_name) > self._max_len:
self._max_len = len(user_name) self._max_len = len(user_name)
def _format_message(self, msg): def _process_message(self, msg):
""" """
Check what kind of message we are dealing with and do appropriate Check what kind of message we are dealing with and do appropriate
formatting formatting
""" """
msg_txt = self._filter_slackid(msg.text) data = super(TextReporter, self)._process_message(msg)
msg_txt = self._fix_newlines(msg_txt) data['msg'] = self._filter_slackid(data['msg'])
for emoticon in self.emoji: data['msg'] = self._fix_newlines(data['msg'])
msg_txt = msg_txt.replace(emoticon, self.emoji[emoticon]) data['msg'] = self._remove_entities(data['msg'])
formatter = self.types.get(msg.type, self._msg) data.update({'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'max_len': self._max_len,
'separator': self._get_symbol('separator'),
'tpl': self.tpl})
return data
return formatter(msg, msg_txt) def _msg_file(self, message, _file):
"""return data for file"""
def _msg_join(self, msg, text): if _file.filepath:
"""return formatter for join""" fpath = os.path.abspath(_file.filepath)
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"), fpath = pathlib.PurePath(fpath).as_uri()
'msg': text,
'max_len': self._max_len,
'separator': self._get_symbol('separator'),
'nick': self._get_symbol('join')}
return '{date} {nick:>{max_len}} {separator} {msg}\n'.format(**data)
def _msg_leave(self, msg, text):
"""return formatter for leave"""
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'msg': text,
'max_len': self._max_len,
'separator': self._get_symbol('separator'),
'nick': self._get_symbol('leave')}
return '{date} {nick:>{max_len}} {separator} {msg}\n'.format(**data)
def _msg_topic(self, msg, text):
"""return formatter for set topic"""
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'msg': text,
'max_len': self._max_len,
'separator': self._get_symbol('separator'),
'char': self._get_symbol('topic')}
return '{date} {char:>{max_len}} {separator} {msg}\n'.format(**data)
def _msg_me(self, msg, text):
"""return formatter for /me"""
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'msg': text,
'max_len': self._max_len,
'nick': msg.user.name,
'separator': self._get_symbol('separator'),
'char': self._get_symbol('me')}
return '{date} {char:>{max_len}} {separator} {nick} {msg}\n'.\
format(**data)
def _msg_file(self, msg, text):
"""return formatter for file"""
groups = self._slackid_pat[0].match(msg.text).groupdict()
text = msg.text.replace(groups['replace'], '')
filename = msg.file.filepath
if filename:
filename = os.path.relpath(msg.file.filepath, start=self.out)
else: else:
filename = msg.file.url fpath = 'does_not_exists'
if not filename: return {'msg': _file.title + ' ' + fpath,
logging.warning("There is a file object, but without filename." 'nick': self._get_symbol('file'),
"Name of the file object is `%s'", msg.file.name) 'date': message.datetime().strftime("%Y-%m-%d %H:%M:%S"),
filename = msg.file.name
text = self._filter_slackid(text)
text = self._remove_entities(text)
text = self._fix_newlines(text)
for emoticon in self.emoji:
text = text.replace(emoticon, self.emoji[emoticon])
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'msg': text,
'max_len': self._max_len, 'max_len': self._max_len,
'separator': self._get_symbol('separator'), 'separator': self._get_symbol('separator'),
'filename': filename, 'tpl': self.tpl}
'nick': msg.user.name,
'char': self._get_symbol('file')}
return ('{date} {char:>{max_len}} {separator} {nick} '
'shared file "{filename}"{msg}\n'.format(**data))
def _msg(self, msg, text): def _msg(self, msg):
"""return formatter for all other message types""" """return data for all other message types"""
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"), data = super(TextReporter, self)._msg(msg)
'msg': text, result = ''
'max_len': self._max_len,
'separator': self._get_symbol('separator'),
'nick': msg.user.name}
result = '{date} {nick:>{max_len}} {separator} {msg}\n'.format(**data)
if msg.attachments: if msg.attachments:
for att in msg.attachments: for att in msg.attachments:
if att.title: if att.title:
att_text = "\n" + att.title + '\n' att_text = att.title + '\n'
else: else:
att_text = "\n" + self._fix_newlines(att.fallback) + '\n' att_text = self._fix_newlines(att.fallback) + '\n'
if att.text: if att.text:
att_text += att.text att_text += att.text
att_text = self._fix_newlines(att_text)
# remove first newline
att_text = att_text[1:]
result += att_text + '\n' result += att_text + '\n'
return result data['msg'] += result.strip()
return data
def _remove_entities(self, text): def _remove_entities(self, text):
"""replace html entites into appropriate chars""" """replace html entites into appropriate chars"""
return html.parser.HTMLParser().unescape(text) return html.parser.HTMLParser().unescape(text)
def _filter_slackid(self, text):
"""filter out all of the id from slack"""
for pat in self._slackid_pat:
while pat.search(text):
groups = pat.search(text).groupdict('slackid')
user = [u for u in self.users
if u.slackid == groups['slackid']][0]
text = text.replace(groups['replace'], user.name)
return text
def _fix_newlines(self, text): def _fix_newlines(self, text):
"""Shift text with new lines to the right with separator""" """Shift text with new lines to the right with separator"""
shift = 19 # length of the date shift = 19 # length of the date
@@ -285,11 +279,206 @@ class TextReporter(Reporter):
self._get_symbol('separator') + ' ') self._get_symbol('separator') + ' ')
class StaticHtmlReporter(Reporter):
"""Text-like, but with browsable, clickable links"""
ext = '.html'
index_templ = """<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
<title>%(title)s</title>
</head>
<body>
<div id="container">
%(msgs)s
</div>
</body>
</html>
"""
index_list = """
<ul>
%s
</ul>
"""
msg_head = """<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
<title>%(title)s</title>
"""
msg_style = """
<style>
* {
font-family: sans-serif;
}
.log {
width: 100%;
}
.log tr:nth-child(even) {
background-color: #efefef;
}
.nick {
text-align: right;
white-space: nowrap;
}
.date {
white-space: nowrap;
}
td {
padding: 2px;
}
img {
max-height: 300px;
max-widht: 500px;
}
</style>
</head>
<body>
<div id="container">
<table class="log">
"""
msg_foot = """
</table>
</div>
</body>
</html>
"""
msg_line = """
<tr>
<td class="date">{date}</td>
<td class="nick">{nick}</td>
<td>{msg}</td>
</tr>
"""
def __init__(self, args, query):
super(StaticHtmlReporter, self).__init__(args, query)
utils.makedirs(self.out)
self._max_len = 0
def generate(self):
"""Generate raport"""
super(StaticHtmlReporter, self).generate()
with open(os.path.join(self.out, "index.html"), "w",
encoding='utf8') as fobj:
content = {'title': 'index',
'msgs': self.index_list % self._get_index_list()}
fobj.write(self.index_templ % content)
def write_msg(self, messages, log, channel):
"""Write message to file"""
with open(log, "w", encoding='utf8') as fobj:
title = channel.name
if channel.topic:
title = channel.name + ' ' + channel.topic.value
fobj.write(self.msg_head % {'title': title})
fobj.write(self.msg_style)
super(StaticHtmlReporter, self).write_msg(messages, log, channel)
with open(log, "a", encoding='utf8') as fobj:
fobj.write(self.msg_foot)
def _get_index_list(self):
_list = []
for channel in sorted([c.name for c in self.channels]):
_list.append('<li><a href="%s">%s</a></li>' % (channel + '.html',
channel))
return '\n'.join(_list)
def _process_message(self, msg):
"""
Check what kind of message we are dealing with and do appropriate
formatting
"""
data = super(StaticHtmlReporter, self)._process_message(msg)
data['msg'] = self._filter_slackid(data['msg'])
data.update({'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'tpl': self.msg_line})
return data
def _msg_file(self, msg, _file):
"""return data for file"""
if _file.filepath:
fpath = os.path.abspath(_file.filepath)
fpath = pathlib.PurePath(fpath).as_uri()
else:
fpath = 'does_not_exists'
_, ext = os.path.splitext(fpath)
if ext.lower() in ('.png', '.jpg', '.jpeg', '.gif'):
url = ('<img src="' + fpath + '" alt="' +
_file.title + '">')
else:
url = ('<a href="' + fpath + '">' + _file.title + '</a>')
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'msg': self._filter_slackid(url + _file.title),
'tpl': self.msg_line,
'nick': self._get_symbol('file')}
for emoticon in self.emoji:
data['msg'] = data['msg'].replace(emoticon, self.emoji[emoticon])
return data
def _msg(self, msg):
"""return processor for all other message types"""
match = self.url2_pat.match(msg.text)
text = msg.text
if match:
text = ''
for part in self.url2_pat.split(msg.text):
if 'http' in part:
text += '<a href="' + part + '">' + part + '</a>'
else:
text += part
data = {'date': msg.datetime().strftime("%Y-%m-%d %H:%M:%S"),
'msg': text,
'nick': msg.user.name}
link = '<a href="{url}">{title}</a>'
attachment_msg = []
if msg.attachments:
for att in msg.attachments:
if 'http' in att.fallback:
match = self.url_pat.search(att.fallback)
if not match:
match = self.literal_url_pat.search(att.fallback)
match = match.groupdict()
if 'title' not in match:
match['title'] = match['url']
if att.title:
match['title'] = att.title
att_text = att.fallback.replace(match['replace'],
link.format(**match))
else:
match = self.url_pat.search(msg.text)
if match:
match = match.groupdict()
match['title'] = att.fallback
att_text = msg.text.replace(match['replace'],
link.format(**match))
else:
att_text = att.fallback
attachment_msg.append(att_text)
data['msg'] += '<br>'.join(attachment_msg)
return data
def get_reporter(args, query): def get_reporter(args, query):
"""Return object of right reporter class""" """Return object of right reporter class"""
reporters = {'text': TextReporter} reporters = {'text': TextReporter,
'html': StaticHtmlReporter}
klass = reporters.get(args.format, Reporter) klass = reporters.get(args.format, NoneReporter)
if klass.__name__ == 'Reporter': if klass.__name__ == 'Reporter':
logging.warning('None, or wrong (%s) formatter selected, falling to' logging.warning('None, or wrong (%s) formatter selected, falling to'
' None Reporter', args.format) ' None Reporter', args.format)

View File

@@ -1,9 +1,12 @@
""" """
Some utils functions. Jsut to not copypaste the code around Some utils functions. Jsut to not copypaste the code around
""" """
from datetime import datetime
import errno import errno
import os import os
import logging import logging
import tempfile
import hashlib
def makedirs(path): def makedirs(path):
@@ -19,3 +22,36 @@ def makedirs(path):
logging.error("Cannot create `%s'. There is some file on the " logging.error("Cannot create `%s'. There is some file on the "
"way; cannot proceed.", path) "way; cannot proceed.", path)
raise raise
def get_temp_name(suffix='', prefix='tmp', dir=None, unlink=False):
"""Return temporary file name"""
fdesc, fname = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=dir)
os.close(fdesc)
if unlink:
os.unlink(fname)
return fname
def same_files(file1, file2):
"""
Compare files by calculating hash for each of them. Return True if hash is
identical, False otherwise
"""
with open(file1, 'rb') as fobj:
hash1 = hashlib.sha256(fobj.read())
with open(file2, 'rb') as fobj:
hash2 = hashlib.sha256(fobj.read())
return hash1.hexdigest() == hash2.hexdigest()
def fromtimestamp(timestamp):
"""
Return datetime object from provided timestamp. If timestamp argument is
falsy, datetime object placed in January 1970 will be retuned.
"""
if not timestamp:
return datetime.utcfromtimestamp(0)
return datetime.fromtimestamp(timestamp)

View File

@@ -1,5 +1,6 @@
from unittest import TestCase import copy
from unittest.mock import MagicMock import unittest
from unittest import mock
from slack_backup import client from slack_backup import client
from slack_backup import objects as o from slack_backup import objects as o
@@ -257,66 +258,67 @@ MSGS = {'messages': [{"type": "message",
{"display_as_bot": False, {"display_as_bot": False,
"subtype": "file_share", "subtype": "file_share",
"username": "<@UCCCCCCCC|name2>", "username": "<@UCCCCCCCC|name2>",
"file": {"thumb_960": "https://files.slack.com/files-tmb" "files": [{"thumb_960": "https://files.slack.com/files-"
"/hash/screenshot_960.png", "tmb/hash/screenshot_960.png",
"user": "UCCCCCCCC", "user": "UCCCCCCCC",
"size": 77222, "size": 77222,
"thumb_1024_h": 754, "thumb_1024_h": 754,
"timestamp": 1479407569, "timestamp": 1479407569,
"url_private_download": "https://files.slack.co" "url_private_download": "https://files.slack."
"m/files-pri/hsh/downlo" "com/files-pri/hsh/do"
"ad/screenshot.png", "wnload/screenshot.pn"
"thumb_360": "https://files.slack.com/files-tmb" "g",
"/hash/screenshot_360.png", "thumb_360": "https://files.slack.com/files-"
"username": "", "tmb/hash/screenshot_360.png",
"external_type": "", "username": "",
"thumb_64": "https://files.slack.com/files-tmb/" "external_type": "",
"hash/screenshot_64.png", "thumb_64": "https://files.slack.com/files-"
"created": 1479407569, "tmb/hash/screenshot_64.png",
"ims": [], "created": 1479407569,
"groups": [], "ims": [],
"filetype": "png", "groups": [],
"thumb_1024": "https://files.slack.com/files-" "filetype": "png",
"tmb/hash/screenshot_1024.png", "thumb_1024": "https://files.slack.com/files-"
"original_w": 1193, "tmb/hash/screenshot_1024.png",
"name": "Screenshot.png", "original_w": 1193,
"thumb_360_h": 265, "name": "Screenshot.png",
"is_public": True, "thumb_360_h": 265,
"thumb_960_h": 707, "is_public": True,
"original_h": 878, "thumb_960_h": 707,
"mimetype": "image/png", "original_h": 878,
"id": "F3405RRB5", "mimetype": "image/png",
"pretty_type": "PNG", "id": "F3405RRB5",
"editable": False, "pretty_type": "PNG",
"thumb_960_w": 960, "editable": False,
"thumb_80": "https://files.slack.com/files-tmb/" "thumb_960_w": 960,
"hash/screenshot_80.png", "thumb_80": "https://files.slack.com/files-"
"comments_count": 0, "tmb/hash/screenshot_80.png",
"image_exif_rotation": 1, "comments_count": 0,
"thumb_160": "https://files.slack.com/files-tmb" "image_exif_rotation": 1,
"/hash/screenshot_160.png", "thumb_160": "https://files.slack.com/files-"
"thumb_480_w": 480, "tmb/hash/screenshot_160.png",
"is_external": False, "thumb_480_w": 480,
"display_as_bot": False, "is_external": False,
"thumb_720_h": 530, "display_as_bot": False,
"channels": ["C00000001"], "thumb_720_h": 530,
"title": "Screenshot.png", "channels": ["C00000001"],
"thumb_480": "https://files.slack.com/files-tmb" "title": "Screenshot.png",
"/hash/screenshot_480.png", "thumb_480": "https://files.slack.com/files-"
"url_private": "https://files.slack.com/files-" "tmb/hash/screenshot_480.png",
"pri/hsh/screenshot.png", "url_private": "https://files.slack.com/files"
"mode": "hosted", "-pri/hsh/screenshot.png",
"thumb_1024_w": 1024, "mode": "hosted",
"permalink": "https://esm64.slack.com/files/" "thumb_1024_w": 1024,
"name2/F3405RRB5/screenshot.png", "permalink": "https://esm64.slack.com/files/"
"thumb_480_h": 353, "name2/F3405RRB5/screenshot.png",
"public_url_shared": False, "thumb_480_h": 353,
"thumb_720": "https://files.slack.com/files-tmb" "public_url_shared": False,
"/hash/screenshot_720.png", "thumb_720": "https://files.slack.com/files-"
"thumb_360_w": 360, "tmb/hash/screenshot_720.png",
"permalink_public": "https://slack-files.com/" "thumb_360_w": 360,
"hsh-7dbb96b758", "permalink_public": "https://slack-files.com/"
"thumb_720_w": 720}, "hsh-7dbb96b758",
"thumb_720_w": 720}],
"type": "message", "type": "message",
"user": "UCCCCCCCC", "user": "UCCCCCCCC",
"bot_id": None, "bot_id": None,
@@ -324,7 +326,10 @@ MSGS = {'messages': [{"type": "message",
"<https://esm64.slack.com/files/name2/F3405RRB5/" "<https://esm64.slack.com/files/name2/F3405RRB5/"
"screenshot.png|Screenshot.png>", "screenshot.png|Screenshot.png>",
"ts": "1478107371.000052", "ts": "1478107371.000052",
"upload": True}], "upload": True},
{'type': 'something else',
'ts': '1502003415232.000001',
"wibblr": True}],
"ok": True, "ok": True,
"latest": "1479501075.000020", "latest": "1479501075.000020",
"has_more": True} "has_more": True}
@@ -345,6 +350,234 @@ MSG3 = {"ok": True,
"has_more": False, "has_more": False,
"is_limited": False} "is_limited": False}
STARRED = {"ok": True,
"oldest": "1479505026.000003",
"messages": [],
"has_more": False,
"is_limited": False}
SHARED = {"type": "message",
"subtype": "file_share",
"text": "<@UAAAAAAAA> shared a file: <https://bla."
"slack.com/files/name%20lastname/F7ARMB4JU/17|"
"some_spreadsheet>",
"files": [{
"id": "F6ABMB0Ja",
"created": 1479147929,
"timestamp": 1479147929,
"name": "att name",
"title": "some_spreadsheet",
"mimetype": "application/vnd.google-apps."
"spreadsheet",
"filetype": "gsheet",
"pretty_type": "GDocs Spreadsheet",
"user": 'UAAAAAAAA',
"editable": False,
"size": 666,
"mode": "external",
"is_external": True,
"external_type": "gdrive",
"is_public": True,
"public_url_shared": False,
"display_as_bot": False,
"username": "",
"url_private": "https://docs.google.com/"
"spreadsheets/d/name%20lastname/"
"edit?usp=drivesdk",
# removed useless thumb_* definition
"image_exif_rotation": 1,
"original_w": 1024,
"original_h": 1449,
"permalink": "https://bla.slack.com/files/"
"name%20lastname/F7ARMB4JU/17",
"channels": ["C00000001"],
"groups": [],
"ims": [],
"comments_count": 0,
"has_rich_preview": True
}],
"user": 'UAAAAAAAA',
"upload": False,
"display_as_bot": False,
"username": "name lastname",
"bot_id": None,
"ts": "1479147929.000043"}
PINNED = {'attachments': [{'fallback': 'blah',
'id': 1,
'image_bytes': 5,
'image_height': 2,
'image_url': 'http://fake.com/i.png',
'image_width': 1,
'original_url': 'http://fake.com/fake',
'service_icon': 'http://fake.com/favicon.ico',
'service_name': 'fake service',
'text': 'the text',
'title': 'Fake service title',
'title_link': 'http://fake.com/fake'}],
'item_type': 'C',
'subtype': 'pinned_item',
'text': '<@UAAAAAAAA> pinned a message to this channel.',
'ts': '1479147929.000043',
'type': 'message',
'user': 'UAAAAAAAA'}
EXTERNAL_DATA = {"bot_id": None,
"display_as_bot": False,
"files": [{
"channels": [
"xxx"
],
"id": "F7ARMB4JU",
"created": 1506819447,
"timestamp": 1506819447,
"name": "17",
"title": "PT Card Count 9/30/17",
"mimetype": "application/"
"vnd.google-apps.spreadsheet",
"filetype": "gsheet",
"pretty_type": "GDocs Spreadsheet",
"user": "xxx",
"editable": False,
"size": 37583,
"mode": "external",
"is_external": True,
"external_type": "gdrive",
"is_public": True,
"public_url_shared": False,
"display_as_bot": False,
"username": "",
"url_private": "https://docs.google.com/"
"spreadsheets/d/xxx/edit?"
"usp=drivesdk",
"thumb_64": "https://files.slack.com/files-tmb/"
"xxx-F7ARMB4JU-xxx/17_64.png",
"thumb_80": "https://files.slack.com/files-tmb/"
"xxx-F7ARMB4JU-xxx/17_80.png",
"thumb_360": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_360.png",
"thumb_360_w": 254,
"thumb_360_h": 360,
"thumb_480": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_480.png",
"thumb_480_w": 339,
"thumb_480_h": 480,
"thumb_160": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_160.png",
"thumb_720": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_720.png",
"thumb_720_w": 509,
"thumb_720_h": 720,
"thumb_800": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_800.png",
"thumb_800_w": 800,
"thumb_800_h": 1132,
"thumb_960": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_960.png",
"thumb_960_w": 678,
"thumb_960_h": 960,
"thumb_1024": "https://files.slack.com/files-tmb"
"/xxx-F7ARMB4JU-xxx/17_1024.png",
"thumb_1024_w": 724,
"thumb_1024_h": 1024,
"image_exif_rotation": 1,
"original_w": 1024,
"original_h": 1449,
"permalink": "https://xxx.slack.com/files/xxx/"
"F7ARMB4JU/17",
"groups": [],
"ims": [],
"comments_count": 0,
"has_rich_preview": True}],
"user": "xxx",
"upload": False,
"username": "xxx"}
INTERNAL_DATA = {"bot_id": None,
"display_as_bot": False,
"files": [{"channels": ["zzz"],
"comments_count": 1,
"created": 1524724681,
"display_as_bot": False,
"editable": False,
"external_type": "",
"filetype": "jpg",
"groups": [],
"id": "id",
"image_exif_rotation": 1,
"ims": [],
"initial_comment": {"comment": "bla",
"created": 1524724681,
"id": "yyy",
"is_intro": True,
"timestamp": 1524724681,
"user": "UAAAAAAAA"},
"is_external": False,
"is_public": True,
"mimetype": "image/jpeg",
"mode": "hosted",
"name": "img.jpg",
"original_h": 768,
"original_w": 1080,
"permalink": "https://fake.slack.com/files/"
"UAAAAAAAA/id/img.jpg",
"permalink_public": "https://slack-files.com/"
"TXXXXXX-id-3de9b969d2",
"pretty_type": "JPEG",
"public_url_shared": False,
"reactions": [{"count": 1,
"name": "thinking_face",
"users": ["U6P9KEALW"]}],
"size": 491335,
"thumb_1024": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_1024.jpg",
"thumb_1024_h": 728,
"thumb_1024_w": 1024,
"thumb_160": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_160.jpg",
"thumb_360": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_360.jpg",
"thumb_360_h": 256,
"thumb_360_w": 360,
"thumb_480": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_480.jpg",
"thumb_480_h": 341,
"thumb_480_w": 480,
"thumb_64": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_64.jpg",
"thumb_720": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_720.jpg",
"thumb_720_h": 512,
"thumb_720_w": 720,
"thumb_80": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_80.jpg",
"thumb_800": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_800.jpg",
"thumb_800_h": 569,
"thumb_800_w": 800,
"thumb_960": "https://files.slack.com/files-tmb/"
"TXXXXXX-id-123/img_960.jpg",
"thumb_960_h": 683,
"thumb_960_w": 960,
"timestamp": 1524724681,
"title": "img.jpg",
"url_private": "https://files.slack.com/files-pri/"
"TXXXXXX-id/img.jpg",
"url_private_download": "https://files.slack.com/"
"files-pri/TXXXXXX-id/"
"download/img.jpg",
"user": "UAAAAAAAA",
"username": ""}],
"subtype": "file_share",
"text": "<@UAAAAAAAA> uploaded a file: <https://fake.slack."
"com/files/UAAAAAAAA/id/img.jpg|img.jpg> and "
"commented: bla",
"ts": "1524724685.000201",
"type": "message",
"upload": True,
"user": "UAAAAAAAA",
"username": "bob"}
class FakeArgs(object): class FakeArgs(object):
token = 'token_string' token = 'token_string'
@@ -353,43 +586,44 @@ class FakeArgs(object):
team = 'fake_team' team = 'fake_team'
database = None database = None
channels = None channels = None
url_file_to_attachment = False
def __contains__(self, key): def __contains__(self, key):
return hasattr(self, key) return hasattr(self, key)
class TestApiCalls(TestCase): class TestApiCalls(unittest.TestCase):
def test_channels_list(self): def test_channels_list(self):
cl = client.Client(FakeArgs()) cl = client.Client(FakeArgs())
cl.slack.api_call = MagicMock(return_value=CHANNELS) cl.slack.api_call = mock.MagicMock(return_value=CHANNELS)
channels = cl._channels_list() channels = cl._channels_list()
self.assertListEqual(CHANNELS['channels'], channels) self.assertListEqual(CHANNELS['channels'], channels)
def test_users_list(self): def test_users_list(self):
cl = client.Client(FakeArgs()) cl = client.Client(FakeArgs())
cl.slack.api_call = MagicMock(return_value=USERS) cl.slack.api_call = mock.MagicMock(return_value=USERS)
users = cl._users_list() users = cl._users_list()
self.assertListEqual(USERS['members'], users) self.assertListEqual(USERS['members'], users)
def test_channels_history(self): def test_channels_history(self):
cl = client.Client(FakeArgs()) cl = client.Client(FakeArgs())
cl.slack.api_call = MagicMock(return_value=USERS) cl.slack.api_call = mock.MagicMock(return_value=USERS)
cl.downloader._download = MagicMock(return_value=None) cl.downloader._download = mock.MagicMock(return_value=None)
cl.update_users() cl.update_users()
cl.slack.api_call = MagicMock(return_value=CHANNELS) cl.slack.api_call = mock.MagicMock(return_value=CHANNELS)
cl.update_channels() cl.update_channels()
cl.slack.api_call = MagicMock() cl.slack.api_call = mock.MagicMock()
cl.slack.api_call.side_effect = [MSGS, MSG2, MSG3] cl.slack.api_call.side_effect = [MSGS, MSG2, MSG3]
channel = cl.q(o.Channel).filter(o.Channel.slackid == channel = cl.q(o.Channel).filter(o.Channel.slackid ==
"C00000001").one() "C00000001").one()
msg, ts = cl._channels_history(channel, 0) msg, ts = cl._channels_history(channel, 0)
self.assertEqual(len(msg), 5) self.assertEqual(len(msg), 6)
self.assertEqual(ts, '1479501074.000032') self.assertEqual(ts, '1479501074.000032')
msg, ts = cl._channels_history(channel, ts) msg, ts = cl._channels_history(channel, ts)
@@ -401,12 +635,12 @@ class TestApiCalls(TestCase):
self.assertIsNone(ts) self.assertIsNone(ts)
class TestClient(TestCase): class TestClient(unittest.TestCase):
def test_update_users(self): def test_update_users(self):
cl = client.Client(FakeArgs()) cl = client.Client(FakeArgs())
cl.slack.api_call = MagicMock(return_value=USERS) cl.slack.api_call = mock.MagicMock(return_value=USERS)
cl.downloader._download = MagicMock(return_value=None) cl.downloader._download = mock.MagicMock(return_value=None)
cl.update_users() cl.update_users()
users = cl.session.query(o.User).all() users = cl.session.query(o.User).all()
self.assertEqual(len(users), 4) self.assertEqual(len(users), 4)
@@ -419,26 +653,29 @@ class TestClient(TestCase):
self.assertEqual(users[0].slackid, 'UAAAAAAAA') self.assertEqual(users[0].slackid, 'UAAAAAAAA')
class TestMessage(TestCase): class TestMessage(unittest.TestCase):
def setUp(self): def setUp(self):
args = FakeArgs() args = FakeArgs()
args.channels = ['general'] args.channels = ['general']
self.cl = client.Client(args) self.cl = client.Client(args)
self.cl.downloader.authorize = MagicMock() self.cl.downloader.authorize = mock.MagicMock()
self.cl.slack.api_call = MagicMock(return_value=USERS) self.cl.slack.api_call = mock.MagicMock(return_value=USERS)
self.cl.downloader._download = MagicMock(return_value=None) self.cl.downloader._download = mock.MagicMock(return_value=None)
self.cl.update_users() self.cl.update_users()
self.cl.slack.api_call = MagicMock(return_value=CHANNELS) self.cl.slack.api_call = mock.MagicMock(return_value=CHANNELS)
self.cl.update_channels() self.cl.update_channels()
self.cl.slack.api_call = MagicMock() self.cl.slack.api_call = mock.MagicMock()
def test_update_history(self): @mock.patch('slack_backup.download.Download.download')
def test_update_history(self, download):
self.cl.downloader._download = MagicMock(return_value=None) download.return_value = 'foo'
self.cl.downloader._download = mock.MagicMock(return_value=None)
self.cl.slack.api_call.side_effect = [MSGS, MSG3] self.cl.slack.api_call.side_effect = [MSGS, MSG3]
self.cl.update_history() self.cl.update_history()
self.assertEqual(len(self.cl.q(o.Message).all()), 5) self.assertEqual(len(self.cl.q(o.Message).all()), 5)
@@ -447,3 +684,157 @@ class TestMessage(TestCase):
self.cl.update_history() self.cl.update_history()
self.assertEqual(len(self.cl.q(o.Message).all()), 6) self.assertEqual(len(self.cl.q(o.Message).all()), 6)
class TestCreateMessage(unittest.TestCase):
@mock.patch('slack_backup.client.Client._file_data')
@mock.patch('slack_backup.client.Client._get_user')
def test_empty_message(self, gu, fd):
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value='aa')
cl.session = mock.MagicMock()
channel = o.Channel({'name': 'test', 'id': 'C00000001'})
cl._create_message({'type': 'message', 'text': ''}, channel)
cl.session.add.assert_not_called()
@mock.patch('slack_backup.client.Client._file_data')
@mock.patch('slack_backup.client.Client._get_user')
def test_message_with_reaction(self, gu, fd):
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value='aa')
cl.session = mock.MagicMock()
channel = o.Channel({'name': 'test', 'id': 'C00000001'})
cl._create_message(MSGS['messages'][1], channel)
msg = cl.session.add.call_args[0][0]
self.assertEqual(len(msg.attachments), 1)
self.assertEqual(len(msg.reactions), 1)
self.assertEqual(msg.reactions[0].name, '+1')
self.assertFalse(msg.is_starred)
@mock.patch('slack_backup.client.Client._get_user')
def test_starred_item(self, gu):
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value='aa')
cl.session = mock.MagicMock()
channel = o.Channel({'name': 'test', 'id': 'C00000001'})
data = {"type": "message",
"user": "UAAAAAAAA",
"text": "test",
"ts": "1479501074.000032",
"is_starred": True}
cl._create_message(data, channel)
msg = cl.session.add.call_args[0][0]
self.assertEqual(len(msg.attachments), 0)
self.assertEqual(msg.text, 'test')
self.assertEqual(msg.type, '')
self.assertTrue(msg.is_starred)
@mock.patch('slack_backup.client.Client._file_data')
@mock.patch('slack_backup.client.Client._get_user')
def test_external_file_upload(self, gu, fd):
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value='aa')
cl.session = mock.MagicMock()
channel = o.Channel({'name': 'test', 'id': 'C00000001'})
cl._create_message(SHARED, channel)
msg = cl.session.add.call_args[0][0]
self.assertEqual(len(msg.attachments), 0)
self.assertTrue('shared a file' in msg.text)
self.assertFalse(msg.is_starred)
self.assertEqual(msg.type, 'file_share')
fd.assert_called_once_with(msg, SHARED['files'][0])
@mock.patch('slack_backup.client.Client._file_data')
@mock.patch('slack_backup.client.Client._get_user')
def test_pinned_message_with_attachments(self, gu, fd):
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value='aa')
cl.session = mock.MagicMock()
cl._url_file_to_attachment = True
channel = o.Channel({'name': 'test', 'id': 'C00000001'})
cl._create_message(PINNED, channel)
msg = cl.session.add.call_args[0][0]
self.assertEqual(len(msg.attachments), 1)
self.assertEqual(msg.text, '<@UAAAAAAAA> pinned a message to this '
'channel.')
self.assertEqual(msg.type, 'pinned_item')
self.assertEqual(msg.attachments[0].text, 'the text')
self.assertEqual(msg.attachments[0].title, 'Fake service title')
class TestFileShare(unittest.TestCase):
@mock.patch('slack_backup.download.Download.download')
@mock.patch('slack_backup.utils.makedirs')
def test_file_data(self, md, dl):
dl.side_effect = ['some_path']
url = INTERNAL_DATA['files'][0]['url_private_download']
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value=url)
cl.session = mock.MagicMock()
msg = o.Message()
cl._file_data(msg, INTERNAL_DATA['files'][0])
self.assertIsNotNone(msg.files)
self.assertEqual(msg.files[0].filepath, 'some_path')
@mock.patch('slack_backup.download.Download.download')
@mock.patch('slack_backup.utils.makedirs')
def test_starred_file_data(self, md, dl):
dl.side_effect = ['some_path']
data = copy.deepcopy(INTERNAL_DATA)
data['files'][0]['is_starred'] = True
url = data['files'][0]['url_private_download']
cl = client.Client(FakeArgs())
cl.downloader._download = mock.MagicMock(return_value=url)
cl.session = mock.MagicMock()
msg = o.Message()
cl._file_data(msg, data['files'][0])
self.assertTrue(msg.is_starred)
@mock.patch('uuid.uuid4')
@mock.patch('slack_backup.download.Download.download')
@mock.patch('slack_backup.utils.makedirs')
def test_external_file_data(self, md, dl, uuid):
uuid.side_effect = ['aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee']
dl.side_effect = ['some_path']
cl = client.Client(FakeArgs())
cl.session = mock.MagicMock()
# pretend, that we are authorized
cl.downloader._authorized = True
msg = o.Message()
expexted_line = ('https://docs.google.com/spreadsheets/d/xxx/edit?'
'usp=drivesdk --> '
'assets/files/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\n')
cl._file_data(msg, EXTERNAL_DATA['files'][0])
file_ = cl.session.add.call_args[0][0]
self.assertEqual(cl._dldata, [expexted_line])
self.assertEqual(file_.filepath,
'assets/files/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
dl.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -63,7 +63,9 @@ class TestConfig(unittest.TestCase):
'user': None, 'user': None,
'password': None, 'password': None,
'team': None, 'team': None,
'token': None}) 'token': None,
'url_file_to_attachment': False,
'raw_dir': None})
args = argparse.Namespace() args = argparse.Namespace()
args.config = self.confname args.config = self.confname
@@ -97,7 +99,9 @@ class TestConfig(unittest.TestCase):
'team': 'myteam', 'team': 'myteam',
'token': 'xxxx-1111111111-' 'token': 'xxxx-1111111111-'
'222222222222-333333333333-' '222222222222-333333333333-'
'r4nd0ms7uff'}) 'r4nd0ms7uff',
'url_file_to_attachment': False,
'raw_dir': None})
# override some conf options with commandline # override some conf options with commandline
args = argparse.Namespace() args = argparse.Namespace()
@@ -124,4 +128,6 @@ class TestConfig(unittest.TestCase):
'user': 'joe', 'user': 'joe',
'password': 'ultricies', 'password': 'ultricies',
'team': '', 'team': '',
'token': 'the token'}) 'token': 'the token',
'url_file_to_attachment': False,
'raw_dir': None})

View File

@@ -1,7 +1,7 @@
from unittest import TestCase import unittest
from unittest.mock import MagicMock from unittest import mock
from slack_backup import reporters as r from slack_backup import reporters
class FakeUser(object): class FakeUser(object):
@@ -10,33 +10,46 @@ class FakeUser(object):
self.name = name self.name = name
class TestReporter(TestCase): class TestReporter(unittest.TestCase):
def setUp(self): def setUp(self):
users = [FakeUser('U111AAAAA', 'user1'), args = mock.MagicMock()
FakeUser('U111BBBBB', 'user2'),
FakeUser('U111CCCCC', 'funky_username1'),
FakeUser('U111DDDDD', 'user-name°')]
args = MagicMock()
args.output = 'logs' args.output = 'logs'
query1 = MagicMock()
query1.all = MagicMock(return_value=users)
query = MagicMock(return_value=query1)
self.reporter = r.TextReporter(args, query) self.one = mock.MagicMock()
query2 = mock.MagicMock()
query2.one = self.one
query1 = mock.MagicMock()
query1.filter = mock.MagicMock(return_value=query2)
query = mock.MagicMock(return_value=query1)
def test_regexp(self): self.reporter = reporters.TextReporter(args, query)
def test_regexp1(self):
self.one.return_value = FakeUser('U111AAAAA', 'user1')
text = 'Cras vestibulum <@U111AAAAA|user1> erat ultrices neque.' text = 'Cras vestibulum <@U111AAAAA|user1> erat ultrices neque.'
self.assertEqual(self.reporter._filter_slackid(text), self.assertEqual(self.reporter._filter_slackid(text),
'Cras vestibulum user1 erat ultrices neque.') 'Cras vestibulum user1 erat ultrices neque.')
def test_regexp2(self):
self.one.side_effect = [FakeUser('U111AAAAA', 'user1'),
FakeUser('U111AAAAA', 'user1')]
text = ('Cras vestibulum <@U111AAAAA|user1> erat ultrices ' text = ('Cras vestibulum <@U111AAAAA|user1> erat ultrices '
'<@U111AAAAA|user1> neque.') '<@U111AAAAA|user1> neque.')
self.assertEqual(self.reporter._filter_slackid(text), self.assertEqual(self.reporter._filter_slackid(text),
'Cras vestibulum user1 erat ultrices user1 neque.') 'Cras vestibulum user1 erat ultrices user1 neque.')
text = ('<@U111BBBBB|user2>Praesent vel enim sed eros luctus ' def test_regexp3(self):
self.one.side_effect = [FakeUser('U111BBBBB', 'user2'),
FakeUser('U111DDDDD', 'user-name°'),
FakeUser('U111CCCCC', 'funky_username1')]
text = ('<@U111BBBBB|user2> Praesent vel enim sed eros luctus '
'imperdiet.\nMauris neque ante, <@U111DDDDD> placerat at, ' 'imperdiet.\nMauris neque ante, <@U111DDDDD> placerat at, '
'mollis vitae, faucibus quis, <@U111CCCCC>leo. Ut feugiat.') 'mollis vitae, faucibus quis, <@U111CCCCC> leo. Ut feugiat.')
self.assertEqual(self.reporter._filter_slackid(text),
'user2 Praesent vel enim sed eros luctus '
'imperdiet.\nMauris neque ante, user-name° placerat '
'at, mollis vitae, faucibus quis, funky_username1 '
'leo. Ut feugiat.')

View File

@@ -1,5 +1,5 @@
[tox] [tox]
envlist = py34,py34-flake8 envlist = py3,py3-flake8
usedevelop = True usedevelop = True
@@ -10,7 +10,7 @@ commands = py.test --cov=slack_backup --cov-report=term-missing
deps = -r{toxinidir}/requirements.txt deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt -r{toxinidir}/test-requirements.txt
[testenv:py34-flake8] [testenv:py3-flake8]
basepython = python3.4 basepython = python3
deps = flake8 deps = flake8
commands = flake8 {posargs} commands = flake8 {posargs}