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

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.
This commit is contained in:
2018-07-16 08:33:43 +02:00
parent 71355b1c4a
commit db8527e9af
8 changed files with 490 additions and 25 deletions

View File

@@ -90,6 +90,28 @@ where:
created, but you'll (obviously) lost all the records. Besides the db file,
assets directory might be created for downloadable items.
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 Goolgle
services, like Googla 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 it 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
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
@@ -136,6 +158,7 @@ For convenience, you can place all of needed options into configuration file
theme = plain
[fetch]
url_file_to_attachment = false
user =
password =
team =

View File

@@ -6,6 +6,7 @@ import json
import logging
import os
import pprint
import uuid
import slackclient
import sqlalchemy.orm.exc
@@ -46,6 +47,13 @@ class Client(object):
if 'format' in args:
self.reporter = reporters.get_reporter(args, self.q)
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):
"""
Perform an update, store data to db
@@ -54,6 +62,7 @@ class Client(object):
self.update_users()
self.update_channels()
self.update_history()
self._finalize()
def update_channels(self):
"""Fetch and update channel list with current state in db"""
@@ -210,19 +219,31 @@ class Client(object):
message.reactions.append(o.Reaction(reaction_data))
if data.get('subtype') == 'file_share':
self._file_data(message, data['file'], data['file']['is_external'])
if (self._url_file_to_attachment and
data['file'].get('is_external')):
fdata = data['file']
# change message type from file_share to default
message.type = ''
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, data['file'])
elif data.get('subtype') == 'pinned_item':
if data.get('attachments'):
self._att_data(message, data['attachments'])
elif data.get('item'):
self._file_data(message, data['item'],
data['item']['is_external'])
self._file_data(message, data['item'])
elif data.get('attachments'):
self._att_data(message, data['attachments'])
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
'item' object in case of pinned items
@@ -231,8 +252,15 @@ class Client(object):
if data.get('is_starred'):
message.is_starred = True
if is_external:
logging.debug("Found external file `%s'", data['url_private'])
if data.get('is_external'):
# Create a link and corresponding file name for manual download
fname = str(uuid.uuid4())
message.file.filepath = self.downloader.get_filepath(fname, 'file')
logging.info("Please, manually download an external file from "
"URL `%s' to `%s'", data['url_private'],
message.file.filepath)
self._dldata.append('%s --> %s\n' % (data['url_private'],
message.file.filepath))
message.file.url = data['url_private']
else:
logging.debug("Found internal file `%s'",
@@ -354,3 +382,14 @@ class Client(object):
return result['messages'], 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)

View File

@@ -94,10 +94,10 @@ def main():
help='Path to the database file.')
fetch.add_argument('-i', '--config', default=None,
help='Use specific config file.')
fetch.add_argument('-f', '--url_file_to_attachement', default=False,
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 attachement. By default there will '
'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.')

View File

@@ -13,11 +13,11 @@ class Config(object):
"""Configuration keeper"""
ints = ['verbose', 'quiet']
bools = ['url_file_to_attachement']
bools = ['url_file_to_attachment']
sections = {'common': ['channels', 'database', 'quiet', 'verbose',
'url_file_to_attachement'],
'fetch': ['user', 'password', 'team', 'token'],
sections = {'common': ['channels', 'database', 'quiet', 'verbose'],
'fetch': ['user', 'password', 'team', 'token',
'url_file_to_attachment'],
'generate': ['output', 'format', 'theme']}
def __init__(self):
@@ -38,7 +38,7 @@ class Config(object):
'output': None,
'format': None,
'theme': None,
'url_file_to_attachement': False}
'url_file_to_attachment': False}
# This message supposed to be displayed in INFO level. During the time
# of running the code where it should be displayed there is no
# complete information about logging level. Displaying message is

View File

@@ -403,7 +403,7 @@ class StaticHtmlReporter(Reporter):
'nick': msg.user.name}
link = '<a href="{url}">{title}</a>'
attachement_msg = []
attachment_msg = []
if msg.attachments:
for att in msg.attachments:
@@ -429,9 +429,9 @@ class StaticHtmlReporter(Reporter):
link.format(**match))
else:
att_text = att.fallback
attachement_msg.append(att_text)
attachment_msg.append(att_text)
data['msg'] += '<br>'.join(attachement_msg)
data['msg'] += '<br>'.join(attachment_msg)
return data

View File

@@ -24,10 +24,12 @@ def makedirs(path):
raise
def get_temp_name():
def get_temp_name(suffix='', prefix='tmp', dir=None, unlink=False):
"""Return temporary file name"""
fdesc, fname = tempfile.mkstemp()
fdesc, fname = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=dir)
os.close(fdesc)
if unlink:
os.unlink(fname)
return fname

View File

@@ -1,4 +1,5 @@
from unittest import TestCase
import copy
import unittest
from unittest import mock
from slack_backup import client
@@ -348,6 +349,234 @@ MSG3 = {"ok": True,
"has_more": 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>",
"file": {
"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,
"file": {
"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,
"file": {"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):
token = 'token_string'
@@ -356,12 +585,13 @@ class FakeArgs(object):
team = 'fake_team'
database = None
channels = None
url_file_to_attachment = False
def __contains__(self, key):
return hasattr(self, key)
class TestApiCalls(TestCase):
class TestApiCalls(unittest.TestCase):
def test_channels_list(self):
cl = client.Client(FakeArgs())
@@ -404,7 +634,7 @@ class TestApiCalls(TestCase):
self.assertIsNone(ts)
class TestClient(TestCase):
class TestClient(unittest.TestCase):
def test_update_users(self):
cl = client.Client(FakeArgs())
@@ -422,7 +652,7 @@ class TestClient(TestCase):
self.assertEqual(users[0].slackid, 'UAAAAAAAA')
class TestMessage(TestCase):
class TestMessage(unittest.TestCase):
def setUp(self):
args = FakeArgs()
@@ -453,3 +683,174 @@ class TestMessage(TestCase):
self.cl.update_history()
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['file'])
@mock.patch('slack_backup.client.Client._get_user')
def test_external_file_upload_as_attachment(self, gu):
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(SHARED, channel)
msg = cl.session.add.call_args[0][0]
self.assertEqual(len(msg.attachments), 1)
self.assertTrue('shared a file' in msg.text)
self.assertFalse(msg.is_starred)
@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['file']['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['file'])
self.assertIsNotNone(msg.file)
self.assertEqual(msg.file.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['file']['is_starred'] = True
url = data['file']['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['file'])
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']
# url = EXTERNAL_DATA['file']['url_private']
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['file'])
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

@@ -64,7 +64,7 @@ class TestConfig(unittest.TestCase):
'password': None,
'team': None,
'token': None,
'url_file_to_attachement': False})
'url_file_to_attachment': False})
args = argparse.Namespace()
args.config = self.confname
@@ -99,7 +99,7 @@ class TestConfig(unittest.TestCase):
'token': 'xxxx-1111111111-'
'222222222222-333333333333-'
'r4nd0ms7uff',
'url_file_to_attachement': False})
'url_file_to_attachment': False})
# override some conf options with commandline
args = argparse.Namespace()
@@ -127,4 +127,4 @@ class TestConfig(unittest.TestCase):
'password': 'ultricies',
'team': '',
'token': 'the token',
'url_file_to_attachement': False})
'url_file_to_attachment': False})