1
0
mirror of https://github.com/gryf/slack-backup.git synced 2025-12-17 19:40:21 +01:00

13 Commits
v0.4.4 ... v0.7

Author SHA1 Message Date
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
6 changed files with 156 additions and 43 deletions

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.4", version="0.7",
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,6 +3,7 @@ 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
@@ -112,6 +113,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()
@@ -146,10 +148,20 @@ class Client(object):
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))
try:
user = self.q(o.User).\
filter(o.User.slackid == data['user']).one()
except KeyError:
user = self.q(o.User).\
filter(o.User.slackid == data['comment']['user']).one()
if not data['text'].strip():
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
@@ -255,7 +267,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):

View File

@@ -3,6 +3,7 @@ Create backup for certain date for specified channel in slack
""" """
import argparse import argparse
import logging import logging
import platform
from slack_backup import client from slack_backup import client
from slack_backup import config from slack_backup import config
@@ -11,6 +12,28 @@ from slack_backup import config
def setup_logger(args): def setup_logger(args):
"""Setup logger format and level""" """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 level = logging.WARNING
if args.quiet: if args.quiet:

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
@@ -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)
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,8 +95,8 @@ class Download(object):
self._hier_created = True self._hier_created = True
def prepare_filepath(self, url, filetype): def get_filepath(self, url, filetype):
"""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}
@@ -61,7 +108,8 @@ class Download(object):
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,24 @@ 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) 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 +152,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 +162,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 +175,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

@@ -4,6 +4,8 @@ Some utils functions. Jsut to not copypaste the code around
import errno import errno
import os import os
import logging import logging
import tempfile
import hashlib
def makedirs(path): def makedirs(path):
@@ -19,3 +21,24 @@ 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():
"""Return temporary file name"""
fdesc, fname = tempfile.mkstemp()
os.close(fdesc)
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()

View File

@@ -1,5 +1,5 @@
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock 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
@@ -324,7 +324,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}
@@ -362,34 +365,34 @@ class TestApiCalls(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)
@@ -405,8 +408,8 @@ class TestClient(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)
@@ -426,19 +429,22 @@ class TestMessage(TestCase):
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)