├── tests ├── __init__.py ├── test_no_sockets.py ├── run_fpdf_ext.py ├── test_locales.py ├── test_helpers.py ├── helpers.py ├── test_settings.py ├── test_slack_services.py ├── test_cli.py ├── test_channel_exporter.py └── slack_data.json ├── slackchannel2pdf ├── fonts │ ├── NotoSans-Bold.ttf │ ├── NotoSans-Italic.ttf │ ├── NotoSans-Regular.ttf │ ├── NotoSansMono-Bold.ttf │ ├── NotoSans-BoldItalic.ttf │ ├── NotoSansMono-Regular.ttf │ └── LICENSE ├── __init__.py ├── fpdf_mod │ ├── __init__.py │ ├── php.py │ ├── py3k.py │ ├── template.py │ └── html.py ├── helpers.py ├── slackchannel2pdf.ini ├── locales.py ├── settings.py ├── message_transformer.py ├── cli.py ├── fpdf_extension.py └── slack_service.py ├── .gitignore ├── .coveragerc ├── Makefile ├── tox.ini ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── release.yml │ └── main.yml ├── LICENSE ├── pyproject.toml ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/NotoSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikKalkoken/slackchannel2pdf/HEAD/slackchannel2pdf/fonts/NotoSans-Bold.ttf -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/NotoSans-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikKalkoken/slackchannel2pdf/HEAD/slackchannel2pdf/fonts/NotoSans-Italic.ttf -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikKalkoken/slackchannel2pdf/HEAD/slackchannel2pdf/fonts/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/NotoSansMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikKalkoken/slackchannel2pdf/HEAD/slackchannel2pdf/fonts/NotoSansMono-Bold.ttf -------------------------------------------------------------------------------- /slackchannel2pdf/__init__.py: -------------------------------------------------------------------------------- 1 | """Command line tool for exporting the text contents of any Slack channel to a PDF file.""" 2 | 3 | __version__ = "1.5.2" 4 | -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/NotoSans-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikKalkoken/slackchannel2pdf/HEAD/slackchannel2pdf/fonts/NotoSans-BoldItalic.ttf -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/NotoSansMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikKalkoken/slackchannel2pdf/HEAD/slackchannel2pdf/fonts/NotoSansMono-Regular.ttf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.tmp 3 | *.bak 4 | *.old 5 | __pycache__/ 6 | .vscode/ 7 | *.pdf 8 | /*.json 9 | slackchannel2pdf/build/ 10 | slackchannel2pdf/dist/ 11 | playground/ 12 | dist/ 13 | build/ 14 | devonly/ 15 | venv/ 16 | .tox/ 17 | .coverage 18 | MANIFEST 19 | *.egg-info/ 20 | *.pyc 21 | htmlcov/ 22 | */temp/ 23 | temp/ 24 | -------------------------------------------------------------------------------- /tests/test_no_sockets.py: -------------------------------------------------------------------------------- 1 | import urllib.request 2 | 3 | from .helpers import NoSocketsTestCase, SocketAccessError 4 | 5 | 6 | class TestNoSocketsTestCase(NoSocketsTestCase): 7 | def test_raises_exception_on_attempted_network_access(self): 8 | with self.assertRaises(SocketAccessError): 9 | urllib.request.urlopen("https://www.google.com") 10 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = slackchannel2pdf 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | ignore_errors = True 12 | omit = 13 | setup.py 14 | slackchannel2pdf/tests.py 15 | slackchannel2pdf/tests/* 16 | slackchannel2pdf/migrations/* 17 | slackchannel2pdf/fpdf_mod/* 18 | doc/* 19 | -------------------------------------------------------------------------------- /slackchannel2pdf/fpdf_mod/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | "FPDF for python" 5 | 6 | __license__ = "LGPL 3.0" 7 | __version__ = "1.7.2" 8 | 9 | from .fpdf import ( 10 | FPDF, 11 | FPDF_CACHE_DIR, 12 | FPDF_CACHE_MODE, 13 | FPDF_FONT_DIR, 14 | FPDF_VERSION, 15 | SYSTEM_TTFONTS, 16 | set_global, 17 | ) 18 | 19 | try: 20 | from .html import HTMLMixin 21 | except ImportError: 22 | import warnings 23 | 24 | warnings.warn("web2py gluon package not installed, required for html2pdf") 25 | 26 | from .template import Template 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | appname = slackchannel2pdf 2 | package = slackchannel2pdf 3 | 4 | help: 5 | @echo "Makefile for $(appname)" 6 | 7 | coverage: 8 | coverage run -m unittest discover && coverage html && coverage report 9 | 10 | test: 11 | coverage run -m unittest -v tests.test_channel_exporter.TestSlackChannelExporter.test_should_handle_team_name_with_invalid_characters 12 | 13 | pylint: 14 | pylint $(package) 15 | 16 | check_complexity: 17 | flake8 $(package) --max-complexity=10 18 | 19 | flake8: 20 | flake8 $(package) --count 21 | 22 | deploy: 23 | rm -f dist/* 24 | python setup.py sdist 25 | twine upload dist/* 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git, *migrations*, .tox, dist, htmlcov, *fpdf_mod* 3 | max-line-length = 88 4 | select = C,E,F,W,B,B950 5 | ignore = E203, E231, E501, W503, W291, W293 6 | 7 | [tox] 8 | envlist = py38, py39, py310, py311 9 | 10 | 11 | [gh-actions] 12 | python = 13 | 3.8: py38 14 | 3.9: py39 15 | 3.10: py310 16 | 3.11: py311 17 | 18 | [testenv] 19 | deps= 20 | PyPDF2 21 | coverage 22 | 23 | commands= 24 | coverage run -m unittest discover -v 25 | coverage report 26 | 27 | [testenv:pylint] 28 | deps= 29 | pylint 30 | 31 | commands= 32 | pylint slackchannel2pdf 33 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude: \.(min\.css|min\.js|po|mo|txt|svg)$|(staticfiles/vendor\/) 7 | - id: end-of-file-fixer 8 | exclude: \.(min\.css|min\.js|po|mo|txt|svg)$|(staticfiles/vendor\/) 9 | - repo: https://github.com/psf/black 10 | rev: 23.7.0 11 | hooks: 12 | - id: black 13 | - repo: https://github.com/pre-commit/mirrors-isort 14 | rev: "v5.10.1" 15 | hooks: 16 | - id: isort 17 | - repo: https://github.com/pycqa/flake8 18 | rev: "6.1.0" 19 | hooks: 20 | - id: flake8 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release on PyPI 2 | 3 | on: 4 | release: 5 | # https://docs.github.com/en/actions/reference/events-that-trigger-workflows#release 6 | types: [released, prereleased] 7 | 8 | jobs: 9 | release: 10 | name: Release on Pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@main 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install Tools 23 | run: | 24 | python -m pip install -U build twine 25 | 26 | - name: Package and Upload 27 | env: 28 | STACKMANAGER_VERSION: ${{ github.event.release.tag_name }} 29 | TWINE_USERNAME: __token__ 30 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 31 | run: | 32 | python -m build 33 | python -m twine upload dist/* 34 | -------------------------------------------------------------------------------- /tests/run_fpdf_ext.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from slackchannel2pdf.fpdf_extension import FPDFext 4 | 5 | 6 | def main(): 7 | html = """You can now easily print text mixing different styles: bold, italic, underlined, or all at once!

You can also insert links on text, such as www.fpdf.org, or this some custom text - yeah!""" # noqa 8 | 9 | document = FPDFext() 10 | document.add_page() 11 | document.set_font("Arial", size=12) 12 | document.cell(w=0, txt="hello world") 13 | document.write_html(5, html) 14 | 15 | document.ln() 16 | document.ln() 17 | 18 | html = ( 19 | "This is some normal text
This is more text
" 20 | "And this is the final text" 21 | ) 22 | document.write_html(5, html) 23 | 24 | path = Path(__file__) / "test_fpdf_ext.pdf" 25 | document.output(str(path)) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Erik Kalkoken 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | pre-commit: 9 | name: Pre Commit Checks 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@main 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.10' 20 | 21 | - name: Run Pre Commit Checks 22 | uses: pre-commit/action@v3.0.0 23 | 24 | pylint: 25 | name: Pylint check 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@main 31 | 32 | - name: Set up Python 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: '3.10' 36 | 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install tox 40 | - name: Test with tox 41 | run: tox -e pylint 42 | 43 | test: 44 | runs-on: ubuntu-latest 45 | strategy: 46 | matrix: 47 | python-version: ['3.8', '3.9', '3.10', '3.11'] 48 | 49 | steps: 50 | - uses: actions/checkout@v3 51 | - name: Set up Python ${{ matrix.python-version }} 52 | uses: actions/setup-python@v4 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | - name: Install dependencies 56 | run: | 57 | python -m pip install tox tox-gh-actions 58 | - name: Test with tox 59 | run: tox 60 | - name: Upload coverage reports to Codecov 61 | uses: codecov/codecov-action@v3 62 | -------------------------------------------------------------------------------- /slackchannel2pdf/helpers.py: -------------------------------------------------------------------------------- 1 | """Helpers for slackchannel2pdf.""" 2 | 3 | import html 4 | import json 5 | import logging 6 | from pathlib import Path 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def transform_encoding(text: str) -> str: 12 | """adjust encoding to latin-1 and transform HTML entities""" 13 | text2 = html.unescape(text) 14 | text2 = text2.encode("utf-8", "replace").decode("utf-8") 15 | text2 = text2.replace("\t", " ") 16 | return text2 17 | 18 | 19 | def read_array_from_json_file(filepath: Path, quiet=False) -> list: 20 | """reads a json file and returns its contents as array""" 21 | my_file = filepath.parent / (filepath.name + ".json") 22 | if not my_file.is_file: 23 | if quiet is False: 24 | logger.warning("file does not exist: %s", filepath) 25 | arr = [] 26 | else: 27 | try: 28 | with my_file.open("r", encoding="utf-8") as file: 29 | arr = json.load(file) 30 | except IOError: 31 | if quiet is False: 32 | logger.warning("failed to read from %s: ", my_file, exc_info=True) 33 | arr = [] 34 | 35 | return arr 36 | 37 | 38 | def write_array_to_json_file(arr, filepath: Path) -> None: 39 | """writes array to a json file""" 40 | my_file = filepath.parent / (filepath.name + ".json") 41 | logger.info("Writing file: %s", filepath) 42 | try: 43 | with my_file.open("w", encoding="utf-8") as file: 44 | json.dump(arr, file, sort_keys=True, indent=4, ensure_ascii=False) 45 | except IOError: 46 | logger.error("failed to write to %s", my_file, exc_info=True) 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "slackchannel2pdf" 7 | dynamic = ["version", "description"] 8 | readme = "README.md" 9 | license = { file = "LICENSE" } 10 | requires-python = ">=3.8" 11 | authors = [{ name = "Erik Kalkoken", email = "kalkoken87@gmail.com" }] 12 | classifiers = [ 13 | "Environment :: Console", 14 | "Intended Audience :: End Users/Desktop", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | ] 23 | dependencies = [ 24 | "Babel>=2.9.1", 25 | "python-dateutil>=2.8.0", 26 | "pytz>=2019.1", 27 | "slack_sdk>=3.15.2", 28 | "tzlocal>=2.0.0", 29 | ] 30 | 31 | [project.scripts] 32 | slackchannel2pdf = "slackchannel2pdf.cli:main" 33 | 34 | [project.urls] 35 | Homepage = "https://github.com/ErikKalkoken/slackchannel2pdf" 36 | 37 | [tool.flit.module] 38 | name = "slackchannel2pdf" 39 | 40 | [tool.isort] 41 | profile = "black" 42 | multi_line_output = 3 43 | 44 | [tool.pylint.'MASTER'] 45 | ignore-patterns = ["__init__.py"] 46 | ignore-paths = ["^.*/tests/.*$", "^.*/fpdf_mod/.*$"] 47 | 48 | [tool.pylint.'BASIC'] 49 | good-names = ["i", "j", "k", "ex", "x1", "x2", "x3", "x4", "y1", "y2"] 50 | 51 | [tool.pylint.'FORMAT'] 52 | max-line-length = 120 53 | 54 | 55 | [tool.pylint.'MESSAGES CONTROL'] 56 | disable = [ 57 | "too-many-arguments", 58 | "too-few-public-methods", 59 | "too-many-instance-attributes", 60 | ] 61 | -------------------------------------------------------------------------------- /slackchannel2pdf/fpdf_mod/php.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: latin-1 -*- 3 | 4 | from .py3k import PY3K, basestring, unicode 5 | 6 | # fpdf php helpers: 7 | 8 | 9 | def substr(s, start, length=-1): 10 | if length < 0: 11 | length = len(s) - start 12 | return s[start : start + length] 13 | 14 | 15 | def sprintf(fmt, *args): 16 | return fmt % args 17 | 18 | 19 | def print_r(array): 20 | if not isinstance(array, dict): 21 | array = dict([(k, k) for k in array]) 22 | for k, v in array.items(): 23 | print("[%s] => %s " % (k, v)) 24 | 25 | 26 | def UTF8ToUTF16BE(instr, setbom=True): 27 | "Converts UTF-8 strings to UTF16-BE." 28 | outstr = "".encode() 29 | if setbom: 30 | outstr += "\xFE\xFF".encode("latin1") 31 | if not isinstance(instr, unicode): 32 | instr = instr.decode("UTF-8") 33 | outstr += instr.encode("UTF-16BE") 34 | # convert bytes back to fake unicode string until PEP461-like is implemented 35 | if PY3K: 36 | outstr = outstr.decode("latin1") 37 | return outstr 38 | 39 | 40 | def UTF8StringToArray(instr): 41 | "Converts UTF-8 strings to codepoints array" 42 | return [ord(c) for c in instr] 43 | 44 | 45 | # ttfints php helpers: 46 | 47 | 48 | def die(msg): 49 | raise RuntimeError(msg) 50 | 51 | 52 | def str_repeat(s, count): 53 | return s * count 54 | 55 | 56 | def str_pad(s, pad_length=0, pad_char=" ", pad_type=+1): 57 | if pad_type < 0: # pad left 58 | return s.rjust(pad_length, pad_char) 59 | elif pad_type > 0: # pad right 60 | return s.ljust(pad_length, pad_char) 61 | else: # pad both 62 | return s.center(pad_length, pad_char) 63 | 64 | 65 | strlen = count = lambda s: len(s) 66 | -------------------------------------------------------------------------------- /slackchannel2pdf/slackchannel2pdf.ini: -------------------------------------------------------------------------------- 1 | ;============================================================================== 2 | ; slackchannel2pdf.ini 3 | ;============================================================================== 4 | ; Master configuration file for slackchannel2pdf 5 | ; 6 | ; All setttings in this files are used as defaults unless they are overwritten 7 | ; by a similar configuration file in home or the current working directory 8 | ;============================================================================== 9 | 10 | [pdf] 11 | ; page_orientation can be "portrait" or "landscape" 12 | page_orientation = "portrait" 13 | ; page_format can be "a3", "a4", "a5", "letter" or "legal" 14 | page_format = "a4" 15 | font_size_normal = 12 16 | font_size_large = 14 17 | font_size_small = 10 18 | line_height_default = 6 19 | line_height_small = 2 20 | margin_left = 10 21 | tab_width = 4 22 | 23 | [locale] 24 | ; fallback_locale can be any legal language code 25 | ; will only be used if the app can not determine any default locale from the system 26 | fallback_locale = "en-US" 27 | 28 | [slack] 29 | ; posts from the same user that appear within given minutes will be shown under the 30 | ; header (with user & time) of the first post (same as Slack client) 31 | ; set to 0 to turn off 32 | minutes_until_username_repeats = 10 33 | ; maximum number of messages retrieved from a channel 34 | max_messages_per_channel = 10000 35 | ; max number of items returned from the Slack API per request when paging 36 | ; slack_page_limit must by <= 1000 37 | slack_page_limit = 200 38 | 39 | [logging] 40 | ; log level can be "INFO", "WARN", "ERROR", "CRITICAL" 41 | console_log_level = "WARN" 42 | log_file_enabled = False 43 | ; log file file be written in the current working directory, unless path is defined 44 | # log_file_path = "/path/to" 45 | file_log_level = "INFO" 46 | -------------------------------------------------------------------------------- /slackchannel2pdf/fpdf_mod/py3k.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | "Special module to handle differences between Python 2 and 3 versions" 5 | 6 | import sys 7 | 8 | PY3K = sys.version_info >= (3, 0) 9 | 10 | try: 11 | import cPickle as pickle 12 | except ImportError: 13 | import pickle 14 | 15 | try: 16 | from urllib import urlopen 17 | except ImportError: 18 | from urllib.request import urlopen 19 | 20 | try: 21 | from io import BytesIO 22 | except ImportError: 23 | try: 24 | from cStringIO import StringIO as BytesIO 25 | except ImportError: 26 | from StringIO import StringIO as BytesIO 27 | 28 | try: 29 | from hashlib import md5 30 | except ImportError: 31 | try: 32 | from md5 import md5 33 | except ImportError: 34 | md5 = None 35 | 36 | 37 | def hashpath(fn): 38 | h = md5() 39 | if PY3K: 40 | h.update(fn.encode("UTF-8")) 41 | else: 42 | h.update(fn) 43 | return h.hexdigest() 44 | 45 | 46 | # Check if PIL is available (tries importing both pypi version and corrected or manually installed versions). 47 | # Necessary for JPEG and GIF support. 48 | # TODO: Pillow support 49 | try: 50 | from PIL import Image 51 | except ImportError: 52 | try: 53 | import Image 54 | except ImportError: 55 | Image = None 56 | 57 | try: 58 | from HTMLParser import HTMLParser 59 | except ImportError: 60 | from html.parser import HTMLParser 61 | 62 | if PY3K: 63 | basestring = str 64 | unicode = str 65 | ord = lambda x: x 66 | else: 67 | basestring = basestring 68 | unicode = unicode 69 | ord = ord 70 | 71 | 72 | # shortcut to bytes conversion (b prefix) 73 | def b(s): 74 | if isinstance(s, basestring): 75 | return s.encode("latin1") 76 | elif isinstance(s, int): 77 | if PY3K: 78 | return bytes([s]) # http://bugs.python.org/issue4588 79 | else: 80 | return chr(s) 81 | 82 | 83 | def exception(): 84 | "Return the current the exception instance currently being handled" 85 | # this is needed to support Python 2.5 that lacks "as" syntax 86 | return sys.exc_info()[1] 87 | -------------------------------------------------------------------------------- /tests/test_locales.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch 3 | 4 | from babel import Locale 5 | 6 | from slackchannel2pdf.locales import LocaleHelper 7 | 8 | MODULE_PATH = "slackchannel2pdf.locales" 9 | 10 | 11 | class TestDetermineLocale(TestCase): 12 | def test_should_use_locale_when_provided_1(self): 13 | # given 14 | locale = Locale("en", "US") 15 | # when 16 | result = LocaleHelper._determine_locale(locale) 17 | # then 18 | self.assertEqual(result, locale) 19 | 20 | def test_should_use_locale_when_provided_2(self): 21 | # given 22 | locale = Locale("en", "US") 23 | author_info = {"locale": "de-DE"} 24 | # when 25 | result = LocaleHelper._determine_locale(locale, author_info) 26 | # then 27 | self.assertEqual(result, locale) 28 | 29 | def test_should_use_auth_info(self): 30 | # given 31 | author_info = {"locale": "de-DE"} 32 | # when 33 | result = LocaleHelper._determine_locale(None, author_info) 34 | # then 35 | self.assertEqual(result, Locale("de", "DE")) 36 | 37 | def test_should_return_default_when_nothing_provided(self): 38 | # when 39 | with patch(MODULE_PATH + ".Locale", wraps=Locale) as spy: 40 | LocaleHelper._determine_locale() 41 | # then 42 | self.assertTrue(spy.default.called) 43 | 44 | def test_should_return_default_when_parsing_fails(self): 45 | # given 46 | author_info = {"locale": "xx-yy"} 47 | # when 48 | with patch(MODULE_PATH + ".Locale", wraps=Locale) as spy: 49 | LocaleHelper._determine_locale(None, author_info) 50 | # then 51 | self.assertTrue(spy.default.called) 52 | 53 | def test_should_return_fallback_when_default_fails(self): 54 | # when 55 | with patch(MODULE_PATH + ".settings") as mock_settings: 56 | mock_settings.FALLBACK_LOCALE = "de-DE" 57 | with patch(MODULE_PATH + ".Locale.default") as mock: 58 | mock.side_effect = RuntimeError 59 | result = LocaleHelper._determine_locale() 60 | # then 61 | self.assertEqual(result, Locale("de", "DE")) 62 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | import babel 6 | import pytz 7 | from tzlocal import get_localzone 8 | 9 | from slackchannel2pdf import helpers 10 | from slackchannel2pdf.locales import LocaleHelper 11 | 12 | 13 | class TestTransformEncoding(unittest.TestCase): 14 | def test_should_transform_special_chars(self): 15 | self.assertEqual(helpers.transform_encoding("special char ✓"), "special char ✓") 16 | self.assertEqual(helpers.transform_encoding("<"), "<") 17 | self.assertEqual(helpers.transform_encoding("<"), "<") 18 | 19 | 20 | class TestLocaleHelper(unittest.TestCase): 21 | def test_should_init_with_defaults(self): 22 | # when 23 | locale_helper = LocaleHelper() 24 | # then 25 | self.assertEqual(locale_helper.locale, babel.Locale.default()) 26 | self.assertEqual(locale_helper.timezone, get_localzone()) 27 | 28 | def test_should_use_given_locale_and_timezone(self): 29 | # given 30 | my_locale = babel.Locale.parse("es-MX", sep="-") 31 | my_tz = pytz.timezone("Asia/Bangkok") 32 | # when 33 | locale_helper = LocaleHelper(my_locale=my_locale, my_tz=my_tz) 34 | # then 35 | self.assertEqual(locale_helper.locale, my_locale) 36 | self.assertEqual(locale_helper.timezone, my_tz) 37 | 38 | def test_should_use_locale_and_timezone_from_slack(self): 39 | # given 40 | author_info = {"locale": "es-MX", "tz": "Asia/Bangkok"} 41 | # when 42 | locale_helper = LocaleHelper(author_info=author_info) 43 | # then 44 | self.assertEqual(locale_helper.locale, babel.Locale.parse("es-MX", sep="-")) 45 | self.assertEqual(locale_helper.timezone, pytz.timezone("Asia/Bangkok")) 46 | 47 | def test_should_use_fallback_timezone_if_none_can_be_determined(self): 48 | # when 49 | with patch("slackchannel2pdf.locales.get_localzone") as mock_get_localzone: 50 | mock_get_localzone.return_value = None 51 | locale_helper = LocaleHelper() 52 | # then 53 | self.assertEqual(locale_helper.timezone, pytz.UTC) 54 | 55 | def test_should_convert_epoch_to_datetime(self): 56 | # given 57 | locale_helper = LocaleHelper() 58 | ts = 1006300923 59 | # when 60 | my_datetime = locale_helper.get_datetime_from_ts(ts) 61 | # then 62 | self.assertEqual(my_datetime.timestamp(), ts) 63 | 64 | def test_should_format_datetime(self): 65 | # given 66 | locale_helper = LocaleHelper(my_locale=babel.Locale.parse("de-DE", sep="-")) 67 | # when 68 | my_datetime = dt.datetime(2021, 2, 3, 18, 10) 69 | result = locale_helper.format_datetime_str(my_datetime) 70 | # then 71 | self.assertEqual(result, "03.02.21, 18:10") 72 | 73 | def test_should_format_epoch(self): 74 | # given 75 | locale_helper = LocaleHelper(my_locale=babel.Locale.parse("de-DE", sep="-")) 76 | # when 77 | my_datetime = dt.datetime(2021, 2, 3, 18, 10) 78 | result = locale_helper.get_datetime_formatted_str(my_datetime.timestamp()) 79 | # then 80 | self.assertEqual(result, "03.02.21, 18:10") 81 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## [Unreleased] - yyyy-mm-dd 9 | 10 | ## [1.5.2] - 2023-09-06 11 | 12 | ### Fixed 13 | 14 | - ModuleNotFoundError: No module named 'fpdf_mod' (#25) 15 | 16 | ## [1.5.1] - 2023-09-03 17 | 18 | ### Fixed 19 | 20 | - Locale.default() breaks with exception on Windows (#20) 21 | - Typo in latin-1 encoding (#18) 22 | - KeyError: 'team' (#17) 23 | 24 | ## [1.5.0] - 2023-09-03 25 | 26 | ### Added 27 | 28 | - Added support for Python 3.11 29 | - Add GH actions for test and release 30 | - Add pylint checks to CI pipeline 31 | 32 | ### Changed 33 | 34 | - Consolidated tests.helper package into module 35 | - Migrate build process to PEP 621 36 | - Removed support for Python 3.7 37 | - Remove TEST mode 38 | 39 | ## [1.4.0] - 2022-04-04 40 | 41 | ### Changed 42 | 43 | - Upgraded to new Slack library (slack_sdk) 44 | - Removed Python 3.7 from CI pipeline 45 | - Added Python 3.10 to CI pipeline 46 | - Rate limits are now handled by the slack_sdk library (#7) 47 | - Include tests in distribution package on PyPI 48 | 49 | ## [1.3.1] - 2021-02-21 50 | 51 | ### Fixed 52 | 53 | - Security issue with outdated Babel version 54 | 55 | ## [1.3.0] - 2021-02-21 56 | 57 | ### Added 58 | 59 | - Configuration files now allow users to configure defaults (e.g. page format) 60 | - New command line argument "quiet" to turn off console output 61 | - A log file will now be written in the current working directory (can be disabled by through configuration) 62 | - Now officially supports Python 3.8 & Python 3.9 63 | 64 | ### Changed 65 | 66 | - Console will no longer show detailed infos by default (but can be re-enabled by setting console log level to INFO) 67 | - Console output streamlined to better work with console logger 68 | - Program now exists with a non zero value if an error occurred 69 | - API errors returned from Slack now shown in a more user friendly format 70 | - Now logs an error instead of aborting when failing to generate a message due to nested S-tags 71 | 72 | ## [1.2.4] - 2021-02-04 73 | 74 | ### Fixed 75 | 76 | - Shows date & time on thread replies 77 | 78 | ## [1.2.3] - 2021-02-04 79 | 80 | ### Added 81 | 82 | - Ability to add a logfile 83 | 84 | ### Changed 85 | 86 | - Replaced print statements with logger 87 | - No uses `Pathlib` for better cross platform compatibility 88 | 89 | ### Fixed 90 | 91 | - 'NoneType' object has no attribute 'decimal_formats' 92 | 93 | ## [1.2.2] - 2021-02-03 94 | 95 | ### Fixed 96 | 97 | - 'NoneType' object has no attribute 'get_display_name' 98 | 99 | ## [1.2.1] - 2021-02-03 100 | 101 | ### Fixed 102 | 103 | - Missing timestamp for posts in thread [#5](https://github.com/ErikKalkoken/slackchannel2pdf/issues/5) 104 | 105 | ## [1.2.0] - 2021-02-02 106 | 107 | ### Changed 108 | 109 | - Major refactoring 110 | - Now also uses paging to fetch list of users 111 | 112 | ### Fixed 113 | 114 | - Works with general and random but not other channels [#4](https://github.com/ErikKalkoken/slackchannel2pdf/issues/4) 115 | 116 | ## [1.1.4] - 2020-12-07 117 | 118 | ### Changed 119 | 120 | - Improved test coverage 121 | 122 | ### Fixed 123 | 124 | - Error 'pretty type' - cant' export slack channel to pdf [#3](https://github.com/ErikKalkoken/slackchannel2pdf/issues/3) 125 | 126 | ## [1.1.3] - 2020-11-27 127 | 128 | ### Changed 129 | 130 | - Now using Black for code styling 131 | 132 | ### Fixed 133 | 134 | - Can not create PDF file on windows if team name contains characters not valid for file names, i.e. `<>:"/\|?*` 135 | 136 | ## [1.1.2] - 2020-04-01 137 | 138 | ### Fixed 139 | 140 | - Installation from PyPI does not work probably 141 | 142 | ## [1.1.1] - 2020-04-01 143 | 144 | ### Added 145 | 146 | - Technical improvements (flake8 compliance) 147 | 148 | ## [1.1.0] - 2020-04-01 149 | 150 | ### Added 151 | 152 | - You can now run the tool from shell after pip install 153 | - Added description of installation options 154 | 155 | ## [1.0.0] - 2019-08-05 156 | 157 | ### Added 158 | 159 | - Initial release 160 | -------------------------------------------------------------------------------- /slackchannel2pdf/locales.py: -------------------------------------------------------------------------------- 1 | """Locales for slackchannel2pdf.""" 2 | 3 | import datetime as dt 4 | import logging 5 | from typing import Optional 6 | 7 | import pytz 8 | from babel import Locale, UnknownLocaleError 9 | from babel.dates import format_date, format_datetime, format_time 10 | from tzlocal import get_localzone 11 | 12 | from . import settings 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class LocaleHelper: 18 | """Helpers for converting date & time according to current locale and timezone""" 19 | 20 | def __init__( 21 | self, 22 | my_locale: Optional[Locale] = None, 23 | my_tz: Optional[pytz.BaseTzInfo] = None, 24 | author_info: Optional[dict] = None, 25 | ) -> None: 26 | """ 27 | Args: 28 | - my_locale: Primary locale to use 29 | - my_tz: Primary timezone to use 30 | - author_info: locale and timezone to use from this Slack response 31 | if my_locale and/or my_tz are not given 32 | """ 33 | self._locale = self._determine_locale(my_locale, author_info) 34 | self._timezone = self._determine_timezone(my_tz, author_info) 35 | 36 | @staticmethod 37 | def _determine_locale( 38 | my_locale: Optional[Locale] = None, author_info: Optional[dict] = None 39 | ) -> Locale: 40 | if my_locale: 41 | if not isinstance(my_locale, Locale): 42 | raise TypeError("my_locale must be a babel Locale object") 43 | return my_locale 44 | 45 | if author_info: 46 | try: 47 | return Locale.parse(author_info["locale"], sep="-") 48 | except UnknownLocaleError: 49 | logger.warning("Could not use locale info from Slack.") 50 | my_locale = None 51 | 52 | try: 53 | return Locale.default() 54 | except Exception: # pylint: disable = broad-exception-caught 55 | return Locale.parse(settings.FALLBACK_LOCALE, sep="-") 56 | 57 | @staticmethod 58 | def _determine_timezone( 59 | my_tz: Optional[pytz.BaseTzInfo] = None, author_info: Optional[dict] = None 60 | ) -> pytz.BaseTzInfo: 61 | if my_tz: 62 | if not isinstance(my_tz, pytz.BaseTzInfo): 63 | raise TypeError("my_tz must be of type pytz") 64 | else: 65 | if author_info: 66 | try: 67 | my_tz = pytz.timezone(author_info["tz"]) 68 | except pytz.exceptions.UnknownTimeZoneError: 69 | logger.warning("Could not use timezone info from Slack") 70 | my_tz = get_localzone() 71 | else: 72 | my_tz = get_localzone() 73 | if not my_tz: 74 | my_tz = pytz.UTC 75 | return my_tz 76 | 77 | @property 78 | def locale(self) -> Locale: 79 | """Return locale.""" 80 | return self._locale 81 | 82 | @property 83 | def timezone(self) -> pytz.BaseTzInfo: 84 | """Return timezone.""" 85 | return self._timezone 86 | 87 | def format_date_full_str(self, my_datetime: dt.datetime) -> str: 88 | """Return all full formatted date.""" 89 | return format_date(my_datetime, format="full", locale=self.locale) 90 | 91 | def format_datetime_str(self, my_datetime: dt.datetime) -> str: 92 | """Return formatted datetime string for given dt using locale.""" 93 | return format_datetime(my_datetime, format="short", locale=self.locale) 94 | 95 | def get_datetime_formatted_str(self, timestamp: float) -> str: 96 | """Return given timestamp as formatted datetime string using locale.""" 97 | my_datetime = self.get_datetime_from_ts(timestamp) 98 | return format_datetime(my_datetime, format="short", locale=self.locale) 99 | 100 | def get_time_formatted_str(self, timestamp: float) -> str: 101 | """Return given timestamp as formatted datetime string using locale.""" 102 | my_datetime = self.get_datetime_from_ts(timestamp) 103 | return format_time(my_datetime, format="short", locale=self.locale) 104 | 105 | def get_datetime_from_ts(self, timestamp: float) -> dt.datetime: 106 | """Return datetime object of a unix timestamp with local timezone.""" 107 | my_datetime = dt.datetime.fromtimestamp(float(timestamp), pytz.UTC) 108 | return my_datetime.astimezone(self.timezone) 109 | -------------------------------------------------------------------------------- /slackchannel2pdf/settings.py: -------------------------------------------------------------------------------- 1 | """Global settings incl. from configuration files for slackchannel2pdf.""" 2 | 3 | # pylint: disable = no-member 4 | 5 | import configparser 6 | from ast import literal_eval 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | _FILE_NAME_BASE = "slackchannel2pdf" 11 | _CONF_FILE_NAME = f"{_FILE_NAME_BASE}.ini" 12 | _LOG_FILE_NAME = f"{_FILE_NAME_BASE}.log" 13 | 14 | _DEFAULTS_PATH = Path(__file__).parent 15 | 16 | 17 | def _configparser_convert_str(value): 18 | result = literal_eval(value) 19 | if not isinstance(result, str): 20 | raise configparser.ParsingError(f"Needs to be a string type: {value}") 21 | return result 22 | 23 | 24 | def config_parser( 25 | defaults_path: Path, 26 | home_path: Optional[Path] = None, 27 | cwd_path: Optional[Path] = None, 28 | ) -> configparser.ConfigParser: 29 | """Load and parse config from file and return it.""" 30 | parser = configparser.ConfigParser(converters={"str": _configparser_convert_str}) 31 | config_file_paths = [defaults_path / _CONF_FILE_NAME] 32 | if home_path: 33 | config_file_paths.append(home_path / _CONF_FILE_NAME) 34 | if cwd_path: 35 | config_file_paths.append(cwd_path / _CONF_FILE_NAME) 36 | found = parser.read(config_file_paths) 37 | if not found: 38 | raise RuntimeError("Can not find a configuration file anywhere") 39 | return parser 40 | 41 | 42 | _my_config = config_parser( 43 | defaults_path=_DEFAULTS_PATH, home_path=Path.home(), cwd_path=Path.cwd() 44 | ) 45 | 46 | # style and layout settings for PDF 47 | PAGE_UNITS_DEFAULT = "mm" 48 | FONT_FAMILY_DEFAULT = "NotoSans" 49 | FONT_FAMILY_MONO_DEFAULT = "NotoSansMono" 50 | 51 | PAGE_ORIENTATION_DEFAULT = _my_config.getstr("pdf", "page_orientation") # type: ignore 52 | PAGE_FORMAT_DEFAULT = _my_config.getstr("pdf", "page_format") # type: ignore 53 | FONT_SIZE_NORMAL = _my_config.getint("pdf", "font_size_normal") 54 | FONT_SIZE_LARGE = _my_config.getint("pdf", "font_size_large") 55 | FONT_SIZE_SMALL = _my_config.getint("pdf", "font_size_small") 56 | LINE_HEIGHT_DEFAULT = _my_config.getint("pdf", "line_height_default") 57 | LINE_HEIGHT_SMALL = _my_config.getint("pdf", "line_height_small") 58 | MARGIN_LEFT = _my_config.getint("pdf", "margin_left") 59 | TAB_WIDTH = _my_config.getint("pdf", "tab_width") 60 | 61 | # locale 62 | FALLBACK_LOCALE = _my_config.getstr("locale", "fallback_locale") # type: ignore 63 | 64 | # slack 65 | MINUTES_UNTIL_USERNAME_REPEATS = _my_config.getint( 66 | "slack", "minutes_until_username_repeats" 67 | ) 68 | MAX_MESSAGES_PER_CHANNEL = _my_config.getint("slack", "max_messages_per_channel") 69 | SLACK_PAGE_LIMIT = _my_config.getint("slack", "slack_page_limit") 70 | 71 | 72 | def _setup_logging(config: configparser.ConfigParser) -> dict: 73 | config_logging = { 74 | "version": 1, 75 | "disable_existing_loggers": False, 76 | "formatters": { 77 | "console": {"format": "[%(levelname)s] %(message)s"}, 78 | "file": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, 79 | }, 80 | "handlers": { 81 | "console": { 82 | "level": config.getstr("logging", "console_log_level"), # type: ignore 83 | "formatter": "console", 84 | "class": "logging.StreamHandler", 85 | "stream": "ext://sys.stdout", # Default is stderr 86 | } 87 | }, 88 | "loggers": { 89 | "": { # root logger 90 | "handlers": ["console"], 91 | "level": "DEBUG", 92 | "propagate": False, 93 | }, 94 | }, 95 | } 96 | 97 | # add log file if configured 98 | log_file_enabled = config.getboolean("logging", "log_file_enabled", fallback=False) 99 | if log_file_enabled: 100 | file_log_path_full = config.getstr("logging", "log_file_path", fallback=None) # type: ignore 101 | filename = ( 102 | Path(file_log_path_full) / _LOG_FILE_NAME 103 | if file_log_path_full 104 | else _LOG_FILE_NAME 105 | ) 106 | config_logging["handlers"]["file"] = { 107 | "level": config.getstr("logging", "file_log_level"), # type: ignore 108 | "formatter": "file", 109 | "class": "logging.FileHandler", 110 | "filename": filename, 111 | "mode": "a", 112 | } 113 | config_logging["loggers"][""]["handlers"].append("file") 114 | return config_logging 115 | 116 | 117 | DEFAULT_LOGGING = _setup_logging(_my_config) 118 | -------------------------------------------------------------------------------- /slackchannel2pdf/fonts/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) 2 | 3 | This Font Software is licensed under the SIL Open Font License, 4 | Version 1.1. 5 | 6 | This license is copied below, and is also available with a FAQ at: 7 | http://scripts.sil.org/OFL 8 | 9 | ----------------------------------------------------------- 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 11 | ----------------------------------------------------------- 12 | 13 | PREAMBLE 14 | The goals of the Open Font License (OFL) are to stimulate worldwide 15 | development of collaborative font projects, to support the font 16 | creation efforts of academic and linguistic communities, and to 17 | provide a free and open framework in which fonts may be shared and 18 | improved in partnership with others. 19 | 20 | The OFL allows the licensed fonts to be used, studied, modified and 21 | redistributed freely as long as they are not sold by themselves. The 22 | fonts, including any derivative works, can be bundled, embedded, 23 | redistributed and/or sold with any software provided that any reserved 24 | names are not used by derivative works. The fonts and derivatives, 25 | however, cannot be released under any other type of license. The 26 | requirement for fonts to remain under this license does not apply to 27 | any document created using the fonts or their derivatives. 28 | 29 | DEFINITIONS 30 | "Font Software" refers to the set of files released by the Copyright 31 | Holder(s) under this license and clearly marked as such. This may 32 | include source files, build scripts and documentation. 33 | 34 | "Reserved Font Name" refers to any names specified as such after the 35 | copyright statement(s). 36 | 37 | "Original Version" refers to the collection of Font Software 38 | components as distributed by the Copyright Holder(s). 39 | 40 | "Modified Version" refers to any derivative made by adding to, 41 | deleting, or substituting -- in part or in whole -- any of the 42 | components of the Original Version, by changing formats or by porting 43 | the Font Software to a new environment. 44 | 45 | "Author" refers to any designer, engineer, programmer, technical 46 | writer or other person who contributed to the Font Software. 47 | 48 | PERMISSION & CONDITIONS 49 | Permission is hereby granted, free of charge, to any person obtaining 50 | a copy of the Font Software, to use, study, copy, merge, embed, 51 | modify, redistribute, and sell modified and unmodified copies of the 52 | Font Software, subject to the following conditions: 53 | 54 | 1) Neither the Font Software nor any of its individual components, in 55 | Original or Modified Versions, may be sold by itself. 56 | 57 | 2) Original or Modified Versions of the Font Software may be bundled, 58 | redistributed and/or sold with any software, provided that each copy 59 | contains the above copyright notice and this license. These can be 60 | included either as stand-alone text files, human-readable headers or 61 | in the appropriate machine-readable metadata fields within text or 62 | binary files as long as those fields can be easily viewed by the user. 63 | 64 | 3) No Modified Version of the Font Software may use the Reserved Font 65 | Name(s) unless explicit written permission is granted by the 66 | corresponding Copyright Holder. This restriction only applies to the 67 | primary font name as presented to the users. 68 | 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 70 | Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except to acknowledge the contribution(s) of the 72 | Copyright Holder(s) and the Author(s) or with their explicit written 73 | permission. 74 | 75 | 5) The Font Software, modified or unmodified, in part or in whole, 76 | must be distributed entirely under this license, and must not be 77 | distributed under any other license. The requirement for fonts to 78 | remain under this license does not apply to any document created using 79 | the Font Software. 80 | 81 | TERMINATION 82 | This license becomes null and void if any of the above conditions are 83 | not met. 84 | 85 | DISCLAIMER 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 94 | OTHER DEALINGS IN THE FONT SOFTWARE. 95 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | from pathlib import Path 4 | from unittest import TestCase 5 | 6 | 7 | class SocketAccessError(Exception): 8 | pass 9 | 10 | 11 | class NoSocketsTestCase(TestCase): 12 | """Enhancement of TestCase class that prevents any use of sockets 13 | 14 | Will throw the exception SocketAccessError when any code tries to 15 | access network sockets 16 | """ 17 | 18 | @classmethod 19 | def setUpClass(cls): 20 | cls.socket_original = socket.socket 21 | socket.socket = cls.guard 22 | return super().setUpClass() 23 | 24 | @classmethod 25 | def tearDownClass(cls): 26 | socket.socket = cls.socket_original 27 | return super().tearDownClass() 28 | 29 | @staticmethod 30 | def guard(*args, **kwargs): 31 | raise SocketAccessError("Attempted to access network") 32 | 33 | 34 | def chunks(lst, size): 35 | """Yield successive sized chunks from lst.""" 36 | for i in range(0, len(lst), size): 37 | yield lst[i : i + size] 38 | 39 | 40 | def slack_response(data: dict, ok: bool = True, error: str = None) -> str: 41 | if not ok: 42 | return {"ok": False, **{"error": error}} 43 | else: 44 | return {"ok": True, **data} 45 | 46 | 47 | class SlackResponseStub: 48 | def __init__(self, data, ok=True) -> None: 49 | self.data = slack_response(data, ok) 50 | 51 | 52 | class SlackClientStub: 53 | def __init__(self, team: str, page_size: int = None) -> None: 54 | self._team = str(team) 55 | self._page_size = int(page_size) if page_size else None 56 | self._page_counts = {"conversations_list": 0} 57 | path = Path(__file__).parent / "slack_data.json" 58 | with path.open("r", encoding="utf-8") as f: 59 | self._slack_data = json.load(f) 60 | 61 | def _paging(self, data, key, cursor=None) -> str: 62 | if not self._page_size: 63 | return slack_response({key: data}) 64 | else: 65 | data_chunks = list(chunks(data, self._page_size)) 66 | if cursor is None: 67 | cursor = 0 68 | 69 | response = {key: data_chunks[cursor]} 70 | if len(data_chunks) > cursor + 1: 71 | response["response_metadata"] = {"next_cursor": cursor + 1} 72 | 73 | return slack_response(response) 74 | 75 | def auth_test(self) -> str: 76 | return SlackResponseStub(self._slack_data[self._team]["auth_test"]) 77 | 78 | def bots_info(self, bot) -> str: 79 | return slack_response({}, ok=False) 80 | 81 | def conversations_replies( 82 | self, channel, ts, limit=None, oldest=None, latest=None, cursor=None 83 | ) -> str: 84 | if ( 85 | channel in self._slack_data[self._team]["conversations_replies"] 86 | and ts in self._slack_data[self._team]["conversations_replies"][channel] 87 | ): 88 | messages = self._slack_data[self._team]["conversations_replies"][channel][ 89 | ts 90 | ] 91 | return slack_response(self._messages_to_response(messages)) 92 | else: 93 | return slack_response(None, ok=False, error="Thread not found") 94 | 95 | def conversations_history( 96 | self, channel, limit=None, oldest=None, latest=None, cursor=None 97 | ) -> str: 98 | if channel in self._slack_data[self._team]["conversations_history"]: 99 | messages = self._slack_data[self._team]["conversations_history"][channel] 100 | return self._paging(messages, "messages", cursor) 101 | else: 102 | return slack_response(None, ok=False, error="Channel not found") 103 | 104 | @staticmethod 105 | def _messages_to_response(messages: list) -> dict: 106 | return {"messages": messages, "has_more": False} 107 | 108 | def conversations_list(self, types, limit=None, cursor=None) -> str: 109 | return self._paging( 110 | self._slack_data[self._team]["conversations_list"]["channels"], 111 | "channels", 112 | cursor, 113 | ) 114 | 115 | def users_info(self, user, include_locale=None) -> str: 116 | users = { 117 | obj["id"]: obj 118 | for obj in self._slack_data[self._team]["users_list"]["members"] 119 | } 120 | if user in users: 121 | return slack_response({"user": users[user]}) 122 | else: 123 | return slack_response(None, ok=False, error="User not found") 124 | 125 | def users_list(self, limit=None) -> str: 126 | return slack_response(self._slack_data[self._team]["users_list"]) 127 | 128 | def usergroups_list(self) -> str: 129 | return slack_response(self._slack_data[self._team]["usergroups_list"]) 130 | -------------------------------------------------------------------------------- /slackchannel2pdf/message_transformer.py: -------------------------------------------------------------------------------- 1 | """Slack message transformers for slackchannel2pdf.""" 2 | 3 | import re 4 | 5 | from .helpers import transform_encoding 6 | from .locales import LocaleHelper 7 | from .slack_service import SlackService 8 | 9 | 10 | class MessageTransformer: 11 | """A class for parsing and transforming Slack messages.""" 12 | 13 | def __init__( 14 | self, 15 | slack_service: SlackService, 16 | locale_helper: LocaleHelper, 17 | font_family_mono_default: str, 18 | ) -> None: 19 | self._slack_service = slack_service 20 | self._locale_helper = locale_helper 21 | self._font_family_mono_default = font_family_mono_default 22 | 23 | def transform_text(self, text: str, use_mrkdwn: bool = False) -> str: 24 | """Transform mrkdwn text into HTML text for PDF output. 25 | 26 | Main method to resolve all mrkdwn, e.g. , , *bold* 27 | Will resolve channel and user IDs to their names if possible 28 | Returns string with rudimentary HTML for formatting and links 29 | 30 | Attr: 31 | text: text string to be transformed 32 | use_mrkdwn: will transform mrkdwn if set to true 33 | 34 | Returns: 35 | transformed text string with HTML formatting 36 | """ 37 | 38 | # pass 1 - adjust encoding to latin-1 and transform HTML entities 39 | result = transform_encoding(text) 40 | 41 | # if requested try to transform mrkdwn in text 42 | if use_mrkdwn: 43 | # pass 2 - transform mrkdwns with brackets 44 | result = re.sub(r"<(.*?)>", self._replace_mrkdwn_in_text, result) 45 | 46 | # pass 3 - transform formatting mrkdwns 47 | 48 | # bold 49 | result = re.sub(r"\*(.+)\*", r"\1", result) 50 | # italic 51 | result = re.sub(r"\b_(.+)_\b", r"\1", result) 52 | # code 53 | result = re.sub( 54 | r"`(.*)`", 55 | r'\1', 56 | result, 57 | ) 58 | # indents 59 | result = re.sub( 60 | r"^>(.+)", r"
\1
", result, 0, re.MULTILINE 61 | ) 62 | result = result.replace("
", "") 63 | # EOF 64 | result = result.replace("\n", "
") 65 | 66 | return result 67 | 68 | def _replace_mrkdwn_in_text(self, match_obj: re.Match) -> str: 69 | """inline function returns replacement string for re.sub 70 | 71 | This function does the actual resolving of IDs and mrkdwn key words 72 | """ 73 | match = match_obj.group(1) 74 | 75 | id_chars = match[0:2] 76 | id_raw = match[1 : len(match)] 77 | parts = id_raw.split("|", 1) 78 | obj_id = parts[0] 79 | 80 | make_bold = True 81 | if id_chars in {"@U", "@W"}: 82 | result = self._process_user_id(obj_id) 83 | 84 | elif id_chars == "#C": 85 | result = self._process_channel_id(obj_id) 86 | 87 | elif match[0:9] == "!subteam^": 88 | result = self._process_user_group_id(match) 89 | 90 | elif match[0:1] == "!": 91 | make_bold, result = self._process_special_mention(match, obj_id) 92 | 93 | else: 94 | make_bold, result = self._process_url(match) 95 | 96 | if make_bold: 97 | result = f"{result}" 98 | 99 | return result 100 | 101 | def _process_user_id(self, obj_id): 102 | if obj_id in self._slack_service.user_names(): 103 | return "@" + self._slack_service.user_names()[obj_id] 104 | 105 | return f"@user_{obj_id}" 106 | 107 | def _process_channel_id(self, obj_id): 108 | if obj_id in self._slack_service.channel_names(): 109 | return "#" + self._slack_service.channel_names()[obj_id] 110 | 111 | return f"#channel_{obj_id}" 112 | 113 | def _process_user_group_id(self, match): 114 | match2 = re.match(r"!subteam\^(S[A-Z0-9]+)", match) 115 | if match2 is not None and len(match2.groups()) == 1: 116 | usergroup_id = match2.group(1) 117 | if usergroup_id in self._slack_service.usergroup_names(): 118 | usergroup_name = self._slack_service.usergroup_names()[usergroup_id] 119 | else: 120 | usergroup_name = f"usergroup_{usergroup_id}" 121 | else: 122 | usergroup_name = "usergroup_unknown" 123 | return "@" + usergroup_name 124 | 125 | def _process_special_mention(self, match, obj_id): 126 | make_bold = True 127 | if obj_id == "here": 128 | result = "@here" 129 | 130 | elif obj_id == "channel": 131 | result = "@channel" 132 | 133 | elif obj_id == "everyone": 134 | result = "@everyone" 135 | 136 | elif match[0:5] == "!date": 137 | make_bold = False 138 | date_parts = match.split("^") 139 | if len(date_parts) > 1: 140 | result = self._locale_helper.get_datetime_formatted_str(date_parts[1]) 141 | else: 142 | result = "(failed to parse date)" 143 | 144 | else: 145 | result = f"@special_{obj_id}" 146 | 147 | return make_bold, result 148 | 149 | def _process_url(self, match): 150 | link_parts = match.split("|") 151 | if len(link_parts) == 2: 152 | url = link_parts[0] 153 | text = link_parts[1] 154 | else: 155 | url = match 156 | text = match 157 | 158 | make_bold = False 159 | result = f'{text}' 160 | return make_bold, result 161 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import pickle 3 | import tempfile 4 | import unittest 5 | from pathlib import Path 6 | 7 | from slackchannel2pdf import settings 8 | 9 | 10 | def deepcopy(config: configparser.ConfigParser) -> configparser.ConfigParser: 11 | """deep copy config""" 12 | rep = pickle.dumps(config) 13 | new_config = pickle.loads(rep) 14 | return new_config 15 | 16 | 17 | class TestConvertStr(unittest.TestCase): 18 | def test_should_convert_string(self): 19 | self.assertEqual(settings._configparser_convert_str('"abc"'), "abc") 20 | 21 | def test_should_raise_exception_when_not_str(self): 22 | with self.assertRaises(configparser.ParsingError): 23 | settings._configparser_convert_str("42") 24 | 25 | 26 | def fetch_default_config() -> configparser.ConfigParser: 27 | default_config = configparser.ConfigParser( 28 | converters={"str": settings._configparser_convert_str} 29 | ) 30 | default_config.read(settings._DEFAULTS_PATH / settings._CONF_FILE_NAME) 31 | return default_config 32 | 33 | 34 | class TestConfigParser(unittest.TestCase): 35 | TEST_SECTION = "pdf" 36 | TEST_OPTION = "font_size_normal" 37 | 38 | @classmethod 39 | def setUpClass(cls) -> None: 40 | super().setUpClass() 41 | cls.default_config = fetch_default_config() 42 | cls.default_config.set(cls.TEST_SECTION, cls.TEST_OPTION, "12") 43 | 44 | def test_should_return_default_configuration(self): 45 | # given 46 | defaults_path = Path(tempfile.mkdtemp()) 47 | default_file_path = defaults_path / settings._CONF_FILE_NAME 48 | with default_file_path.open("w", encoding=("utf-8")) as fp: 49 | self.default_config.write(fp) 50 | # when 51 | new_parser = settings.config_parser(defaults_path) 52 | # then 53 | self.assertEqual(new_parser.getint(self.TEST_SECTION, self.TEST_OPTION), 12) 54 | 55 | def test_should_return_home_configuration(self): 56 | # given 57 | defaults_path = Path(tempfile.mkdtemp()) 58 | file_path = defaults_path / settings._CONF_FILE_NAME 59 | with file_path.open("w", encoding=("utf-8")) as fp: 60 | self.default_config.write(fp) 61 | home_path = Path(tempfile.mkdtemp()) 62 | home_config = deepcopy(self.default_config) 63 | home_config.set(self.TEST_SECTION, self.TEST_OPTION, "10") 64 | file_path = home_path / settings._CONF_FILE_NAME 65 | with file_path.open("w", encoding=("utf-8")) as fp: 66 | home_config.write(fp) 67 | # when 68 | new_parser = settings.config_parser( 69 | defaults_path=defaults_path, home_path=home_path 70 | ) 71 | # then 72 | self.assertEqual(new_parser.getint(self.TEST_SECTION, self.TEST_OPTION), 10) 73 | 74 | def test_should_return_cwd_configuration(self): 75 | # given 76 | defaults_path = Path(tempfile.mkdtemp()) 77 | file_path = defaults_path / settings._CONF_FILE_NAME 78 | with file_path.open("w", encoding=("utf-8")) as fp: 79 | self.default_config.write(fp) 80 | home_path = Path(tempfile.mkdtemp()) 81 | home_config = deepcopy(self.default_config) 82 | home_config.set(self.TEST_SECTION, self.TEST_OPTION, "10") 83 | file_path = home_path / settings._CONF_FILE_NAME 84 | with file_path.open("w", encoding=("utf-8")) as fp: 85 | home_config.write(fp) 86 | cwd_path = Path(tempfile.mkdtemp()) 87 | cwd_config = deepcopy(self.default_config) 88 | cwd_config.set(self.TEST_SECTION, self.TEST_OPTION, "8") 89 | file_path = cwd_path / settings._CONF_FILE_NAME 90 | with file_path.open("w", encoding=("utf-8")) as fp: 91 | cwd_config.write(fp) 92 | # when 93 | new_parser = settings.config_parser( 94 | defaults_path=defaults_path, home_path=home_path, cwd_path=cwd_path 95 | ) 96 | # then 97 | self.assertEqual(new_parser.getint(self.TEST_SECTION, self.TEST_OPTION), 8) 98 | 99 | 100 | class TestSettings(unittest.TestCase): 101 | def test_should_set_console_log_level(self): 102 | # given 103 | config = fetch_default_config() 104 | config.set("logging", "console_log_level", '"DEBUG"') 105 | # when 106 | dict_config = settings._setup_logging(config) 107 | # then 108 | self.assertEqual(dict_config["handlers"]["console"]["level"], "DEBUG") 109 | 110 | def test_should_set_file_log_level(self): 111 | # given 112 | config = fetch_default_config() 113 | config.set("logging", "log_file_enabled", "True") 114 | config.set("logging", "file_log_level", '"DEBUG"') 115 | # when 116 | dict_config = settings._setup_logging(config) 117 | # then 118 | self.assertEqual(dict_config["handlers"]["file"]["level"], "DEBUG") 119 | 120 | def test_should_enable_file_logger(self): 121 | # given 122 | config = fetch_default_config() 123 | config.set("logging", "log_file_enabled", "True") 124 | # when 125 | dict_config = settings._setup_logging(config) 126 | # then 127 | self.assertIn("file", dict_config["handlers"]) 128 | self.assertIn("file", dict_config["loggers"][""]["handlers"]) 129 | 130 | def test_should_disable_file_logger(self): 131 | # given 132 | config = fetch_default_config() 133 | config.set("logging", "log_file_enabled", "False") 134 | # when 135 | dict_config = settings._setup_logging(config) 136 | # then 137 | self.assertNotIn("file", dict_config["handlers"]) 138 | self.assertNotIn("file", dict_config["loggers"][""]["handlers"]) 139 | 140 | def test_should_set_log_file_path(self): 141 | # given 142 | config = fetch_default_config() 143 | config.set("logging", "log_file_enabled", "True") 144 | my_path = Path(tempfile.mkdtemp()) 145 | config.set("logging", "log_file_path", f"'{str(my_path)}'") 146 | # when 147 | dict_config = settings._setup_logging(config) 148 | # then 149 | self.assertEqual( 150 | Path(dict_config["handlers"]["file"]["filename"]).parent, my_path 151 | ) 152 | -------------------------------------------------------------------------------- /tests/test_slack_services.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from slackchannel2pdf.slack_service import SlackService 4 | 5 | from .helpers import NoSocketsTestCase, SlackClientStub 6 | 7 | MODULE_NAME = "slackchannel2pdf.slack_service" 8 | 9 | 10 | class TestReduceToDict(NoSocketsTestCase): 11 | def setUp(self): 12 | self.a = [ 13 | {"id": "1", "name_1": "Naoko Kobayashi", "name_2": "naoko.kobayashi"}, 14 | {"id": "2", "name_1": "Janet Hakuli", "name_2": "janet.hakuli"}, 15 | {"id": "3", "name_2": "rosie.dunbar"}, 16 | {"id": "4"}, 17 | {"name_1": "John Doe", "name_2": "john.doe"}, 18 | ] 19 | 20 | def test_1(self): 21 | expected = {"1": "Naoko Kobayashi", "2": "Janet Hakuli"} 22 | result = SlackService._reduce_to_dict(self.a, "id", "name_1") 23 | self.assertEqual(result, expected) 24 | 25 | def test_2(self): 26 | expected = {"1": "Naoko Kobayashi", "2": "Janet Hakuli", "3": "rosie.dunbar"} 27 | result = SlackService._reduce_to_dict(self.a, "id", "name_1", "name_2") 28 | self.assertEqual(result, expected) 29 | 30 | def test_3(self): 31 | expected = {"1": "naoko.kobayashi", "2": "janet.hakuli", "3": "rosie.dunbar"} 32 | result = SlackService._reduce_to_dict(self.a, "id", "invalid_col", "name_2") 33 | self.assertEqual(result, expected) 34 | 35 | 36 | @patch(MODULE_NAME + ".slack_sdk") 37 | class TestSlackService(NoSocketsTestCase): 38 | def test_should_return_all_user_names_1(self, mock_slack): 39 | # given 40 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 41 | slack_service = SlackService("TEST") 42 | # when 43 | result = slack_service.fetch_user_names() 44 | # then 45 | self.assertDictEqual( 46 | { 47 | "U12345678": "Naoko Kobayashi", 48 | "U62345678": "Janet Hakuli", 49 | "U72345678": "Yuna Kobayashi", 50 | "U92345678": "Rosie Dunbar", 51 | "U9234567X": "Erik Kalkoken", 52 | }, 53 | result, 54 | ) 55 | 56 | def test_should_return_all_user_names_2(self, mock_slack): 57 | # given 58 | mock_slack.WebClient.return_value = SlackClientStub( 59 | team="T12345678", page_size=2 60 | ) 61 | slack_service = SlackService("TOKEN_DUMMY") 62 | # when 63 | result = slack_service.fetch_user_names() 64 | # then 65 | self.assertDictEqual( 66 | { 67 | "U12345678": "Naoko Kobayashi", 68 | "U62345678": "Janet Hakuli", 69 | "U72345678": "Yuna Kobayashi", 70 | "U92345678": "Rosie Dunbar", 71 | "U9234567X": "Erik Kalkoken", 72 | }, 73 | result, 74 | ) 75 | 76 | def test_should_return_all_conversations_1(self, mock_slack): 77 | # given 78 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 79 | slack_service = SlackService("TEST") 80 | # when 81 | result = slack_service._fetch_channel_names() 82 | # then 83 | self.assertDictEqual( 84 | { 85 | "C12345678": "berlin", 86 | "C42345678": "oslo", 87 | "C72345678": "london", 88 | "C92345678": "moscow", 89 | "G1234567X": "bangkok", 90 | "G2234567X": "tokyo", 91 | }, 92 | result, 93 | ) 94 | 95 | def test_should_return_all_conversations_2(self, mock_slack): 96 | # given 97 | mock_slack.WebClient.return_value = SlackClientStub( 98 | team="T12345678", page_size=2 99 | ) 100 | slack_service = SlackService("TEST") 101 | # when 102 | result = slack_service._fetch_channel_names() 103 | # then 104 | self.assertDictEqual( 105 | { 106 | "C12345678": "berlin", 107 | "C42345678": "oslo", 108 | "C72345678": "london", 109 | "C92345678": "moscow", 110 | "G1234567X": "bangkok", 111 | "G2234567X": "tokyo", 112 | }, 113 | result, 114 | ) 115 | 116 | def test_should_return_all_messages_from_conversation_1(self, mock_slack): 117 | # given 118 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 119 | slack_service = SlackService("TEST") 120 | slack_service._channel_names = {"C72345678": "dummy"} 121 | # when 122 | result = slack_service.fetch_messages_from_channel("C72345678", 200) 123 | # then 124 | ids = {message["ts"] for message in result} 125 | self.assertSetEqual( 126 | ids, 127 | { 128 | "1562274541.000800", 129 | "1562274542.000800", 130 | "1562274543.000800", 131 | "1562274544.000800", 132 | "1562274545.000800", 133 | }, 134 | ) 135 | 136 | def test_should_return_all_messages_from_conversation_2(self, mock_slack): 137 | # given 138 | mock_slack.WebClient.return_value = SlackClientStub( 139 | team="T12345678", page_size=2 140 | ) 141 | slack_service = SlackService("TEST") 142 | slack_service._channel_names = {"C72345678": "dummy"} 143 | # when 144 | result = slack_service.fetch_messages_from_channel("C72345678", 200) 145 | # then 146 | ids = {message["ts"] for message in result} 147 | self.assertSetEqual( 148 | ids, 149 | { 150 | "1562274541.000800", 151 | "1562274542.000800", 152 | "1562274543.000800", 153 | "1562274544.000800", 154 | "1562274545.000800", 155 | }, 156 | ) 157 | 158 | def test_should_return_all_threads_from_messages(self, mock_slack): 159 | # given 160 | slack_stub = SlackClientStub(team="T12345678", page_size=2) 161 | mock_slack.WebClient.return_value = slack_stub 162 | slack_service = SlackService("TEST") 163 | slack_service._channel_names = {"G1234567X": "dummy"} 164 | messages = slack_stub._slack_data["T12345678"]["conversations_history"][ 165 | "G1234567X" 166 | ] 167 | # when 168 | result = slack_service.fetch_threads_from_messages("G1234567X", messages, 200) 169 | # then 170 | self.assertIn("1561764011.015500", result) 171 | ids = {message["ts"] for message in result["1561764011.015500"]} 172 | self.assertSetEqual( 173 | ids, 174 | { 175 | "1561764011.015500", 176 | "1562171321.000100", 177 | "1562171322.000100", 178 | "1562171323.000100", 179 | "1562171324.000100", 180 | }, 181 | ) 182 | -------------------------------------------------------------------------------- /slackchannel2pdf/cli.py: -------------------------------------------------------------------------------- 1 | """Command line interface.""" 2 | 3 | import argparse 4 | import os 5 | import sys 6 | from pathlib import Path 7 | 8 | import pytz 9 | from babel import Locale, UnknownLocaleError 10 | from dateutil import parser 11 | from slack_sdk.errors import SlackApiError 12 | 13 | from . import __version__, settings 14 | from .channel_exporter import SlackChannelExporter 15 | 16 | 17 | def main(): 18 | """Implements the arg parser and starts the channel exporter with its input""" 19 | 20 | args = _parse_args(sys.argv[1:]) 21 | slack_token = _parse_slack_token(args) 22 | my_tz = _parse_local_timezone(args) 23 | my_locale = _parse_locale(args) 24 | oldest = _parse_oldest(args) 25 | latest = _parse_latest(args) 26 | 27 | if not args.quiet: 28 | channel_postfix = "s" if args.channel and len(args.channel) > 1 else "" 29 | print(f"Exporting channel{channel_postfix} from Slack...") 30 | try: 31 | exporter = SlackChannelExporter( 32 | slack_token=slack_token, 33 | my_tz=my_tz, 34 | my_locale=my_locale, 35 | add_debug_info=args.add_debug_info, 36 | ) 37 | except SlackApiError as ex: 38 | print(f"ERROR: {ex}") 39 | sys.exit(1) 40 | 41 | result = exporter.run( 42 | channel_inputs=args.channel, 43 | dest_path=Path(args.destination) if args.destination else None, 44 | oldest=oldest, 45 | latest=latest, 46 | page_orientation=args.page_orientation, 47 | page_format=args.page_format, 48 | max_messages=args.max_messages, 49 | write_raw_data=(args.write_raw_data is True), 50 | ) 51 | for channel in result["channels"].values(): 52 | if not args.quiet: 53 | print( 54 | f"{'written' if channel['ok'] else 'failed'}: {channel['filename_pdf']}" 55 | ) 56 | 57 | 58 | def _parse_args(args: list) -> argparse.Namespace: 59 | """defines the argument parser and returns parsed result from given argument""" 60 | my_arg_parser = argparse.ArgumentParser( 61 | description="This program exports the text of a Slack channel to a PDF file", 62 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 63 | ) 64 | 65 | # main arguments 66 | my_arg_parser.add_argument( 67 | "channel", help="One or several: name or ID of channel to export.", nargs="+" 68 | ) 69 | my_arg_parser.add_argument("--token", help="Slack OAuth token") 70 | my_arg_parser.add_argument("--oldest", help="don't load messages older than a date") 71 | my_arg_parser.add_argument("--latest", help="don't load messages newer then a date") 72 | 73 | # PDF file 74 | my_arg_parser.add_argument( 75 | "-d", 76 | "--destination", 77 | help="Specify a destination path to store the PDF file. (TBD)", 78 | default=".", 79 | ) 80 | 81 | # formatting 82 | my_arg_parser.add_argument( 83 | "--page-orientation", 84 | help="Orientation of PDF pages", 85 | choices=["portrait", "landscape"], 86 | default=settings.PAGE_ORIENTATION_DEFAULT, 87 | ) 88 | my_arg_parser.add_argument( 89 | "--page-format", 90 | help="Format of PDF pages", 91 | choices=["a3", "a4", "a5", "letter", "legal"], 92 | default=settings.PAGE_FORMAT_DEFAULT, 93 | ) 94 | my_arg_parser.add_argument( 95 | "--timezone", 96 | help=( 97 | "Manually set the timezone to be used e.g. 'Europe/Berlin' " 98 | "Use a timezone name as defined here: " 99 | "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" 100 | ), 101 | ) 102 | my_arg_parser.add_argument( 103 | "--locale", 104 | help=( 105 | "Manually set the locale to be used with a IETF language tag, " 106 | "e.g. ' de-DE' for Germany. " 107 | "See this page for a list of valid tags: " 108 | "https://en.wikipedia.org/wiki/IETF_language_tag" 109 | ), 110 | ) 111 | 112 | # standards 113 | my_arg_parser.add_argument( 114 | "--version", 115 | help="show the program version and sys.exit", 116 | action="version", 117 | version=__version__, 118 | ) 119 | 120 | # exporter config 121 | my_arg_parser.add_argument( 122 | "--max-messages", 123 | help="max number of messages to export", 124 | type=int, 125 | default=settings.MAX_MESSAGES_PER_CHANNEL, 126 | ) 127 | 128 | # Developer needs 129 | my_arg_parser.add_argument( 130 | "--write-raw-data", 131 | help=( 132 | "will also write all raw data returned from the API to files," 133 | " e.g. messages.json with all messages" 134 | ), 135 | action="store_const", 136 | const=True, 137 | ) 138 | my_arg_parser.add_argument( 139 | "--add-debug-info", 140 | help="wether to add debug info to PDF", 141 | action="store_const", 142 | const=True, 143 | default=False, 144 | ) 145 | my_arg_parser.add_argument( 146 | "--quiet", 147 | action="store_const", 148 | const=True, 149 | default=False, 150 | help=( 151 | "When provided will not generate normal console output, " 152 | "but still show errors " 153 | "(console logging not affected and needs to be configured through " 154 | "log levels instead)" 155 | ), 156 | ) 157 | return my_arg_parser.parse_args(args) 158 | 159 | 160 | def _parse_slack_token(args): 161 | """Try to take slack token from optional argument or environment variable.""" 162 | if args.token is None: 163 | if "SLACK_TOKEN" in os.environ: 164 | slack_token = os.environ["SLACK_TOKEN"] 165 | else: 166 | print("ERROR: No slack token provided") 167 | sys.exit(1) 168 | else: 169 | slack_token = args.token 170 | return slack_token 171 | 172 | 173 | def _parse_local_timezone(args): 174 | if args.timezone is not None: 175 | try: 176 | my_tz = pytz.timezone(args.timezone) 177 | except pytz.UnknownTimeZoneError: 178 | print("ERROR: Unknown timezone") 179 | sys.exit(1) 180 | else: 181 | my_tz = None 182 | return my_tz 183 | 184 | 185 | def _parse_locale(args): 186 | if args.locale is not None: 187 | try: 188 | my_locale = Locale.parse(args.locale, sep="-") 189 | except UnknownLocaleError: 190 | print("ERROR: provided locale string is not valid") 191 | sys.exit(1) 192 | else: 193 | my_locale = None 194 | return my_locale 195 | 196 | 197 | def _parse_oldest(args): 198 | if args.oldest is not None: 199 | try: 200 | oldest = parser.parse(args.oldest) 201 | except ValueError: 202 | print("ERROR: Invalid date input for --oldest") 203 | sys.exit(1) 204 | else: 205 | oldest = None 206 | return oldest 207 | 208 | 209 | def _parse_latest(args): 210 | if args.latest is not None: 211 | try: 212 | latest = parser.parse(args.latest) 213 | except ValueError: 214 | print("ERROR: Invalid date input for --latest") 215 | sys.exit(1) 216 | else: 217 | latest = None 218 | return latest 219 | 220 | 221 | if __name__ == "__main__": 222 | main() 223 | -------------------------------------------------------------------------------- /slackchannel2pdf/fpdf_extension.py: -------------------------------------------------------------------------------- 1 | """This module contains an extended FPDF class with rudimentary HTML support.""" 2 | 3 | import logging 4 | import os 5 | import re 6 | 7 | from . import fpdf_mod, settings 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | fpdf_mod.set_global("FPDF_CACHE_MODE", 1) 12 | fpdf_mod.set_global("SYSTEM_TTFONTS", os.path.join(os.path.dirname(__file__), "fonts")) 13 | 14 | 15 | class HtmlConversionError(Exception): 16 | """A HTML conversion error.""" 17 | 18 | 19 | class FPDFext(fpdf_mod.FPDF): 20 | """This class extends FDPF to enable formatting with rudimentary HTML 21 | 22 | This package extends the FPDF class with the functionality to use 23 | rudimentary HTML for defining text formatting with the new 24 | method write_html() 25 | 26 | The class is based on the example in Tutorial 6 from 27 | the official FPDF documentation (http://www.fpdf.org/) 28 | but has extended functionality 29 | 30 | It's build upon the pyfpdf variant from this github: 31 | https://github.com/alexanderankin/pyfpdf 32 | 33 | Currently supports: , , , ,
,
34 | 35 | is a custom tag for setting the font for part of a text. Example: 36 | 37 | Attributes can be omitted and wil then not be set. 38 | 39 | Unsupported tags are ignored and removed from the text 40 | """ 41 | 42 | _TAB_WIDTH = 4 43 | _TAGS_SUPPORTED = ["b", "i", "u", "a", "br", "blockquote", "s"] 44 | 45 | def __init__(self, orientation="P", unit="mm", page_format="A4"): 46 | super().__init__(orientation=orientation, unit=unit, format=page_format) 47 | self._tags = {} 48 | self._tags["B"] = 0 49 | self._tags["I"] = 0 50 | self._tags["U"] = 0 51 | self._tags["BLOCKQUOTE"] = 0 52 | self._href = "" 53 | self._last_font = None 54 | 55 | def write_html(self, height, html): 56 | """write() with support for rudimentary formatting with HTML tags""" 57 | html = html.replace("\n", " ") 58 | 59 | # split html into parts to identify all HTML tags 60 | # even numbered parts will contain text 61 | # odd numbered parts will contain tags 62 | parts = re.split(r"<([^>]*)>", html) 63 | 64 | # run through all parts one by one 65 | try: 66 | for i, part in dict(enumerate(parts)).items(): 67 | if i % 2 == 0: 68 | self._process_text(height, part) 69 | 70 | else: 71 | self._process_tag(part) 72 | 73 | except HtmlConversionError: 74 | logger.error("Failed to convert HTML to PDF: %s", html) 75 | 76 | def _process_text(self, height, part): 77 | if len(self._href) > 0: 78 | self._put_link(self._href, height, part) 79 | else: 80 | self.write(height, part) 81 | 82 | def _process_tag(self, part): 83 | if part[0] == "/": 84 | self._close_tag(part[1 : len(part)].upper()) 85 | else: 86 | # extract all attributes from the current tag if any 87 | tag_parts = part.split(" ") 88 | tag = tag_parts.pop(0).upper() 89 | attributes = {} 90 | for tag_part in tag_parts: 91 | match_obj = re.search(r'([^=]*)=["\']?([^"\']*)', tag_part) 92 | if match_obj is not None and len(match_obj.groups()) == 2: 93 | attributes[match_obj.group(1).upper()] = match_obj.group(2) 94 | 95 | self._open_tag(tag, attributes) 96 | 97 | def _open_tag(self, tag, attributes): 98 | """set style for opening tags and singular tags""" 99 | 100 | if tag in ["B", "I", "U"]: 101 | self._set_style(tag, True) 102 | 103 | if tag == "BLOCKQUOTE": 104 | self._set_ident_plus() 105 | 106 | if tag == "A": 107 | self._href = attributes["HREF"] 108 | 109 | if tag == "BR": 110 | self.ln(5) 111 | 112 | if tag == "S": 113 | if self._last_font is not None: 114 | raise HtmlConversionError(" tags can not be nested") 115 | 116 | self._last_font = { 117 | "font_family": self.font_family, 118 | "size": self.font_size_pt, 119 | "style": self.font_style, 120 | } 121 | 122 | if "FONTFAMILY" in attributes: 123 | font_family = attributes["FONTFAMILY"] 124 | else: 125 | font_family = self.font_family 126 | 127 | if "SIZE" in attributes: 128 | size = int(attributes["SIZE"]) 129 | else: 130 | size = self.font_size_pt 131 | 132 | if "STYLE" in attributes: 133 | style = attributes["STYLE"] 134 | else: 135 | style = self.font_style 136 | 137 | self.set_font(font_family, size=size, style=style) 138 | 139 | def _close_tag(self, tag): 140 | """set style for closing tags""" 141 | 142 | if tag in ["B", "I", "U"]: 143 | self._set_style(tag, False) 144 | 145 | if tag == "BLOCKQUOTE": 146 | self._set_ident_minus() 147 | 148 | if tag == "A": 149 | self._href = "" 150 | 151 | if tag == "S": 152 | if self._last_font is not None: 153 | self.set_font( 154 | self._last_font["font_family"], 155 | size=self._last_font["size"], 156 | style=self._last_font["style"], 157 | ) 158 | self._last_font = None 159 | 160 | def _set_style(self, tag, enable): 161 | """set the actual font style based on input""" 162 | self._tags[tag] += 1 if enable else -1 163 | style = "" 164 | for my_style in ["B", "I", "U"]: 165 | if self._tags[my_style] > 0: 166 | style += my_style 167 | 168 | self.set_font(self.font_family, size=self.font_size_pt, style=style) 169 | 170 | def _set_ident_plus(self): 171 | """moves current left margin and position forward by tab width""" 172 | self.set_left_margin(self.l_margin + self._TAB_WIDTH) 173 | self.set_x(self.get_x() + self._TAB_WIDTH) 174 | self.ln() 175 | 176 | def _set_ident_minus(self): 177 | """reduces current left margin and position forward by tab width""" 178 | left_margin = self.l_margin 179 | my_x = self.get_x() 180 | if left_margin > self._TAB_WIDTH and my_x > self._TAB_WIDTH: 181 | self.set_left_margin(left_margin - self._TAB_WIDTH) 182 | self.set_x(my_x - self._TAB_WIDTH) 183 | self.ln() 184 | 185 | def _put_link(self, url, height, txt): 186 | """set style and write text to create a link""" 187 | self.set_text_color(0, 0, 255) 188 | self._set_style("U", True) 189 | self.write(height, txt, url) 190 | self._set_style("U", False) 191 | self.set_text_color(0) 192 | 193 | 194 | class MyFPDF(FPDFext): 195 | """Inheritance of FPDF class to add header and footers and set PDF settings""" 196 | 197 | def __init__(self, orientation="P", unit="mm", page_format="A4"): 198 | super().__init__(orientation=orientation, unit=unit, page_format=page_format) 199 | self._page_title = "" 200 | 201 | @property 202 | def page_title(self): 203 | """text shown as title on every page""" 204 | return self._page_title 205 | 206 | @page_title.setter 207 | def page_title(self, text): 208 | """set text to appear as title on every page""" 209 | self._page_title = str(text) 210 | 211 | def header(self): 212 | """definition of custom header""" 213 | self.set_font( 214 | settings.FONT_FAMILY_DEFAULT, size=settings.FONT_SIZE_NORMAL, style="B" 215 | ) 216 | self.cell(0, 0, self._page_title, 0, 1, "C") 217 | self.ln(settings.LINE_HEIGHT_DEFAULT) 218 | 219 | def footer(self): 220 | """definition of custom footer""" 221 | self.set_y(-15) 222 | self.cell(0, 10, "Page " + str(self.page_no()) + " / {nb}", 0, 0, "C") 223 | 224 | def write_info_table(self, table_def): 225 | """write info table defined by dict""" 226 | cell_height = 10 227 | for key, value in table_def.items(): 228 | self.set_font(self.font_family, style="B") 229 | self.cell(50, cell_height, str(key), 1) 230 | self.set_font(self.font_family) 231 | self.cell(0, cell_height, str(value), 1) 232 | self.ln() 233 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slackchannel2pdf 2 | 3 | **slackchannel2pdf** is a command line tool for exporting the text contents of any Slack channel to a PDF file. 4 | 5 | [![release](https://img.shields.io/pypi/v/slackchannel2pdf?label=release)](https://pypi.org/project/slackchannel2pdf/) 6 | [![python](https://img.shields.io/pypi/pyversions/slackchannel2pdf)](https://pypi.org/project/slackchannel2pdf/) 7 | [![license](https://img.shields.io/github/license/ErikKalkoken/slackchannel2pdf)](https://github.com/ErikKalkoken/slackchannel2pdf/blob/master/LICENSE) 8 | [![Tests](https://github.com/ErikKalkoken/slackchannel2pdf/actions/workflows/main.yml/badge.svg)](https://github.com/ErikKalkoken/slackchannel2pdf/actions/workflows/main.yml) 9 | [![codecov](https://codecov.io/gh/ErikKalkoken/slackchannel2pdf/branch/master/graph/badge.svg?token=omhTxW8ALq)](https://codecov.io/gh/ErikKalkoken/slackchannel2pdf) 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 | 12 | ## Contents 13 | 14 | - [Overview](#overview) 15 | - [Features](#features) 16 | - [Installation](#installation) 17 | - [Token](#token) 18 | - [Usage](#usage) 19 | - [Arguments](#arguments) 20 | - [Configuration](#configuration) 21 | - [Limitations](#limitations) 22 | 23 | ## Overview 24 | 25 | This tool is aimed at end users that want to make backups of Slack conversations or be able to share them outside Slack. It will create a PDF file for every exported channel and will work both for public and private channels. 26 | 27 | **slackchannel2pdf** is an open source project and offered free of charge and under the MIT license. Please check attached licence file for details. 28 | 29 | ## Features 30 | 31 | Here is a short summary of the key features of **slackchannel2pdf**: 32 | 33 | - Export of any public and private Slack channel to a PDF file (text only) 34 | - Automatic detection of timezone and locale based from Slack. Can also be set manually if needed. 35 | - Exporting support for all Slack features incl. threads and layout blocks 36 | - Ability to export only the portion of a channel for a specific time period 37 | - Ability to configure page layout of PDF file (e.g. Portrait vs. Landscape) 38 | - Many defaults and behaviors can be configured with configuration files 39 | 40 | ## Installation 41 | 42 | ### Python 43 | 44 | You can install the tool from PyPI with `pip install`. This wil require you to have Python reinstalled in your machine and it will work with any OS supported by Python. We recommend installing it into a virtual environment like venv. 45 | 46 | ```bash 47 | pip install slackchannel2pdf 48 | ``` 49 | 50 | You can then run the tool with the command `slackchannel2pdf` as explained in detail under [Usage](#usage). 51 | 52 | ### Windows 53 | 54 | For windows users we also provide a Windows EXE that does not require you to install Python. You find the EXE file under [releases](https://github.com/ErikKalkoken/slackchannel2pdf/releases). 55 | 56 | ## Token 57 | 58 | To run **slackchannel2pdf** your need to have a token for your Slack workspace with the following permissions: 59 | 60 | - `channels:history` 61 | - `channels:read` 62 | - `groups:history` 63 | - `groups:read` 64 | - `users:read` 65 | - `usergroups:read` 66 | 67 | To get a working token you need to create a Slack app in your workspace with a user token. Here is one way on how to do that: 68 | 69 | 1. Create a new Slack app in your workspace (you can give it any name). 70 | 1. Under Oauth & Permissions / User Token Scopes add all the required scopes as documented above. 71 | 1. Install the app into your workspace 72 | 73 | After successful installation the token for your app will then shown under Basic Information / App Credentials. 74 | 75 | ## Usage 76 | 77 | In order to use **slackchannel2pdf** you need: 78 | 79 | 1. have it installed on your system (see [Installation](#installation)) 80 | 2. have a Slack token for the respective Slack workspace with the required permissions (see [Token](#token)). 81 | 82 | Here are some examples on how to use **slackchannel2pdf**: 83 | 84 | To export the Slack channel "general": 85 | 86 | ```bash 87 | slackchannel2pdf --token MY_TOKEN general 88 | ``` 89 | 90 | To export the Slack channels "general", "random" and "test": 91 | 92 | ```bash 93 | slackchannel2pdf --token MY_TOKEN general random test 94 | ``` 95 | 96 | To export all message from channel "general" starting from July 5th, 2019 at 11:00. 97 | 98 | ```bash 99 | slackchannel2pdf --token MY_TOKEN --oldest "2019-JUL-05 11:00" general 100 | ``` 101 | 102 | > Tip: You can provide the Slack token either as command line argument `--token` or by setting the environment variable `SLACK-TOKEN`. 103 | 104 | ## Arguments 105 | 106 | ```text 107 | usage: slackchannel2pdf [-h] [--token TOKEN] [--oldest OLDEST] 108 | [--latest LATEST] [-d DESTINATION] 109 | [--page-orientation {portrait,landscape}] 110 | [--page-format {a3,a4,a5,letter,legal}] 111 | [--timezone TIMEZONE] [--locale LOCALE] [--version] 112 | [--max-messages MAX_MESSAGES] [--write-raw-data] 113 | [--add-debug-info] [--quiet] 114 | channel [channel ...] 115 | 116 | This program exports the text of a Slack channel to a PDF file 117 | 118 | positional arguments: 119 | channel One or several: name or ID of channel to export. 120 | 121 | optional arguments: 122 | -h, --help show this help message and exit 123 | --token TOKEN Slack OAuth token (default: None) 124 | --oldest OLDEST don't load messages older than a date (default: None) 125 | --latest LATEST don't load messages newer then a date (default: None) 126 | -d DESTINATION, --destination DESTINATION 127 | Specify a destination path to store the PDF file. 128 | (TBD) (default: .) 129 | --page-orientation {portrait,landscape} 130 | Orientation of PDF pages (default: portrait) 131 | --page-format {a3,a4,a5,letter,legal} 132 | Format of PDF pages (default: a4) 133 | --timezone TIMEZONE Manually set the timezone to be used e.g. 134 | 'Europe/Berlin' Use a timezone name as defined here: h 135 | ttps://en.wikipedia.org/wiki/List_of_tz_database_time_ 136 | zones (default: None) 137 | --locale LOCALE Manually set the locale to be used with a IETF 138 | language tag, e.g. ' de-DE' for Germany. See this page 139 | for a list of valid tags: 140 | https://en.wikipedia.org/wiki/IETF_language_tag 141 | (default: None) 142 | --version show the program version and exit 143 | --max-messages MAX_MESSAGES 144 | max number of messages to export (default: 10000) 145 | --write-raw-data will also write all raw data returned from the API to 146 | files, e.g. messages.json with all messages (default: 147 | None) 148 | --add-debug-info wether to add debug info to PDF (default: False) 149 | --quiet When provided will not generate normal console output, 150 | but still show errors (console logging not affected 151 | and needs to be configured through log levels instead) 152 | (default: False) 153 | ``` 154 | 155 | ## Configuration 156 | 157 | You can configure many defaults and behaviors via configuration files. Configuration files must have the name `slackchannel2pdf.ini` and can be placed in two locations: 158 | 159 | - home directory (home) 160 | - current working directory (cwd) 161 | 162 | You can also have a configuration file in both. Settings in cwd will overwrite the same settings in home. And calling this app with command line arguments will overwrite the corresponding configuration setting. 163 | 164 | Please see the master configuration file for a list of all available configuration sections, options and the current defaults. The master configuration file is `slackchannel2pdf/slackchannel2pdf.ini` in this repo. 165 | 166 | ## Limitations 167 | 168 | - Text only: **slackchannel2pdf** will export only text from a channel, but not images or icons. This is by design. 169 | - No Emojis: the tools is currently not able to write emojis as icons will will use their text representation instead (e.g. `:laughing:` instead of :laughing:). 170 | - DMs, Group DM: Currently not supported 171 | - Limited blocks support:Some non-text features of layout blocks not yet supported 172 | - Limited script support: This tool is rendering all text with the [Google Noto Sans](https://www.google.com/get/noto/#sans-lgc) font and will therefore support all 500+ languages that are support by that font. It does however not support many Asian languages / scripts like Chinese, Japanese, Korean, Thai and others 173 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from argparse import Namespace 3 | from unittest import TestCase 4 | from unittest.mock import patch 5 | 6 | import babel 7 | import pytz 8 | 9 | from slackchannel2pdf.cli import main 10 | 11 | 12 | @patch("slackchannel2pdf.cli.SlackChannelExporter") 13 | @patch("slackchannel2pdf.cli._parse_args") 14 | class TestCli(TestCase): 15 | def test_should_start_export_for_channel(self, mock_parse_args, MockExporter): 16 | # given 17 | mock_parse_args.return_value = Namespace( 18 | add_debug_info=False, 19 | channel="channel", 20 | destination=None, 21 | latest=None, 22 | locale=None, 23 | max_messages=None, 24 | oldest=None, 25 | page_format=None, 26 | page_orientation=None, 27 | token="DUMMY_TOKEN", 28 | timezone=None, 29 | write_raw_data=None, 30 | quiet=False, 31 | ) 32 | # when 33 | main() 34 | # then 35 | self.assertTrue(MockExporter.called) 36 | kwargs = MockExporter.call_args[1] 37 | self.assertEqual(kwargs["slack_token"], "DUMMY_TOKEN") 38 | self.assertTrue(MockExporter.return_value.run.called) 39 | kwargs = MockExporter.return_value.run.call_args[1] 40 | self.assertEqual(kwargs["channel_inputs"], "channel") 41 | 42 | def test_should_use_token_from_environment_var(self, mock_parse_args, MockExporter): 43 | # given 44 | mock_parse_args.return_value = Namespace( 45 | add_debug_info=False, 46 | channel="channel", 47 | destination=None, 48 | latest=None, 49 | locale=None, 50 | max_messages=None, 51 | oldest=None, 52 | page_format=None, 53 | page_orientation=None, 54 | token=None, 55 | timezone=None, 56 | write_raw_data=None, 57 | quiet=False, 58 | ) 59 | # when 60 | with patch("slackchannel2pdf.cli.os") as mock_os: 61 | mock_os.environ = {"SLACK_TOKEN": "DUMMY_TOKEN"} 62 | main() 63 | # then 64 | self.assertTrue(MockExporter.called) 65 | kwargs = MockExporter.call_args[1] 66 | self.assertEqual(kwargs["slack_token"], "DUMMY_TOKEN") 67 | 68 | def test_should_show_version_and_abort(self, mock_parse_args, MockExporter): 69 | # given 70 | mock_parse_args.return_value = Namespace( 71 | add_debug_info=False, 72 | channel=None, 73 | destination=None, 74 | latest=None, 75 | locale=None, 76 | max_messages=None, 77 | oldest=None, 78 | page_format=None, 79 | page_orientation=None, 80 | token=None, 81 | timezone=None, 82 | version=True, 83 | write_raw_data=None, 84 | ) 85 | # when 86 | with self.assertRaises(SystemExit): 87 | main() 88 | 89 | def test_should_abort_when_no_token_given(self, mock_parse_args, MockExporter): 90 | # given 91 | mock_parse_args.return_value = Namespace( 92 | add_debug_info=False, 93 | channel=None, 94 | destination=None, 95 | latest=None, 96 | locale=None, 97 | max_messages=None, 98 | oldest=None, 99 | page_format=None, 100 | page_orientation=None, 101 | token=None, 102 | timezone=None, 103 | write_raw_data=None, 104 | ) 105 | # when 106 | with self.assertRaises(SystemExit): 107 | main() 108 | 109 | def test_should_use_given_timezone(self, mock_parse_args, MockExporter): 110 | # given 111 | mock_parse_args.return_value = Namespace( 112 | add_debug_info=False, 113 | channel=None, 114 | destination=None, 115 | latest=None, 116 | locale=None, 117 | max_messages=None, 118 | oldest=None, 119 | page_format=None, 120 | page_orientation=None, 121 | token="DUMMY_TOKEN", 122 | timezone="Asia/Bangkok", 123 | write_raw_data=None, 124 | quiet=False, 125 | ) 126 | # when 127 | main() 128 | # then 129 | self.assertTrue(MockExporter.called) 130 | kwargs = MockExporter.call_args[1] 131 | self.assertEqual(kwargs["my_tz"], pytz.timezone("Asia/Bangkok")) 132 | 133 | def test_should_use_given_locale(self, mock_parse_args, MockExporter): 134 | # given 135 | mock_parse_args.return_value = Namespace( 136 | add_debug_info=False, 137 | channel=None, 138 | destination=None, 139 | latest=None, 140 | locale="es-MX", 141 | max_messages=None, 142 | oldest=None, 143 | page_format=None, 144 | page_orientation=None, 145 | token="DUMMY_TOKEN", 146 | timezone=None, 147 | write_raw_data=None, 148 | quiet=False, 149 | ) 150 | # when 151 | main() 152 | # then 153 | self.assertTrue(MockExporter.called) 154 | kwargs = MockExporter.call_args[1] 155 | self.assertEqual(kwargs["my_locale"], babel.Locale.parse("es-MX", sep="-")) 156 | 157 | def test_should_use_given_oldest_and_latest(self, mock_parse_args, MockExporter): 158 | # given 159 | latest = "2020-03-03 22:00" 160 | oldest = "2020-02-02 20:00" 161 | mock_parse_args.return_value = Namespace( 162 | add_debug_info=False, 163 | channel=None, 164 | destination=None, 165 | latest=latest, 166 | locale=None, 167 | max_messages=None, 168 | oldest=oldest, 169 | page_format=None, 170 | page_orientation=None, 171 | token="DUMMY_TOKEN", 172 | timezone=None, 173 | write_raw_data=None, 174 | quiet=False, 175 | ) 176 | # when 177 | main() 178 | # then 179 | self.assertTrue(MockExporter.called) 180 | mock_run = MockExporter.return_value.run 181 | self.assertTrue(mock_run.called) 182 | kwargs = mock_run.call_args[1] 183 | self.assertEqual(kwargs["oldest"], dt.datetime(2020, 2, 2, 20, 0)) 184 | self.assertEqual(kwargs["latest"], dt.datetime(2020, 3, 3, 22, 0)) 185 | 186 | def test_should_abort_if_locale_is_invalid(self, mock_parse_args, MockExporter): 187 | # given 188 | mock_parse_args.return_value = Namespace( 189 | add_debug_info=False, 190 | channel=None, 191 | destination=None, 192 | latest=None, 193 | locale="xx", 194 | max_messages=None, 195 | oldest=None, 196 | page_format=None, 197 | page_orientation=None, 198 | token="DUMMY_TOKEN", 199 | timezone=None, 200 | write_raw_data=None, 201 | ) 202 | # when 203 | with self.assertRaises(SystemExit): 204 | main() 205 | 206 | def test_should_abort_if_timezone_is_invalid(self, mock_parse_args, MockExporter): 207 | # given 208 | mock_parse_args.return_value = Namespace( 209 | add_debug_info=False, 210 | channel=None, 211 | destination=None, 212 | latest=None, 213 | locale=None, 214 | max_messages=None, 215 | oldest=None, 216 | page_format=None, 217 | page_orientation=None, 218 | token="DUMMY_TOKEN", 219 | timezone="xx", 220 | write_raw_data=None, 221 | ) 222 | # when 223 | with self.assertRaises(SystemExit): 224 | main() 225 | 226 | def test_should_abort_if_oldest_is_invalid(self, mock_parse_args, MockExporter): 227 | # given 228 | mock_parse_args.return_value = Namespace( 229 | add_debug_info=False, 230 | channel=None, 231 | destination=None, 232 | latest=None, 233 | locale=None, 234 | max_messages=None, 235 | oldest="xx", 236 | page_format=None, 237 | page_orientation=None, 238 | token="DUMMY_TOKEN", 239 | timezone=None, 240 | write_raw_data=None, 241 | ) 242 | # when 243 | with self.assertRaises(SystemExit): 244 | main() 245 | 246 | def test_should_abort_if_latest_is_invalid(self, mock_parse_args, MockExporter): 247 | # given 248 | mock_parse_args.return_value = Namespace( 249 | add_debug_info=False, 250 | channel=None, 251 | destination=None, 252 | latest="xx", 253 | locale=None, 254 | max_messages=None, 255 | oldest=None, 256 | page_format=None, 257 | page_orientation=None, 258 | token="DUMMY_TOKEN", 259 | timezone=None, 260 | write_raw_data=None, 261 | ) 262 | # when 263 | with self.assertRaises(SystemExit): 264 | main() 265 | -------------------------------------------------------------------------------- /slackchannel2pdf/fpdf_mod/template.py: -------------------------------------------------------------------------------- 1 | # -*- coding: iso-8859-1 -*- 2 | 3 | "PDF Template Helper for FPDF.py" 4 | 5 | from __future__ import with_statement 6 | 7 | __author__ = "Mariano Reingart " 8 | __copyright__ = "Copyright (C) 2010 Mariano Reingart" 9 | __license__ = "LGPL 3.0" 10 | 11 | import csv 12 | import os 13 | import sys 14 | 15 | from .fpdf import FPDF 16 | from .py3k import PY3K, basestring, unicode 17 | 18 | 19 | def rgb(col): 20 | return (col // 65536), (col // 256 % 256), (col % 256) 21 | 22 | 23 | class Template: 24 | def __init__( 25 | self, 26 | infile=None, 27 | elements=None, 28 | format="A4", 29 | orientation="portrait", 30 | title="", 31 | author="", 32 | subject="", 33 | creator="", 34 | keywords="", 35 | ): 36 | if elements: 37 | self.load_elements(elements) 38 | self.handlers = { 39 | "T": self.text, 40 | "L": self.line, 41 | "I": self.image, 42 | "B": self.rect, 43 | "BC": self.barcode, 44 | "W": self.write, 45 | } 46 | self.texts = {} 47 | pdf = self.pdf = FPDF(format=format, orientation=orientation, unit="mm") 48 | pdf.set_title(title) 49 | pdf.set_author(author) 50 | pdf.set_creator(creator) 51 | pdf.set_subject(subject) 52 | pdf.set_keywords(keywords) 53 | 54 | def load_elements(self, elements): 55 | "Initialize the internal element structures" 56 | self.pg_no = 0 57 | self.elements = elements 58 | self.keys = [v["name"].lower() for v in self.elements] 59 | 60 | def parse_csv(self, infile, delimiter=",", decimal_sep="."): 61 | "Parse template format csv file and create elements dict" 62 | keys = ( 63 | "name", 64 | "type", 65 | "x1", 66 | "y1", 67 | "x2", 68 | "y2", 69 | "font", 70 | "size", 71 | "bold", 72 | "italic", 73 | "underline", 74 | "foreground", 75 | "background", 76 | "align", 77 | "text", 78 | "priority", 79 | "multiline", 80 | ) 81 | self.elements = [] 82 | self.pg_no = 0 83 | if not PY3K: 84 | f = open(infile, "rb") 85 | else: 86 | f = open(infile) 87 | with f: 88 | for row in csv.reader(f, delimiter=delimiter): 89 | kargs = {} 90 | for i, v in enumerate(row): 91 | if not v.startswith("'") and decimal_sep != ".": 92 | v = v.replace(decimal_sep, ".") 93 | else: 94 | v = v 95 | if v == "": 96 | v = None 97 | else: 98 | v = eval(v.strip()) 99 | kargs[keys[i]] = v 100 | self.elements.append(kargs) 101 | self.keys = [v["name"].lower() for v in self.elements] 102 | 103 | def add_page(self): 104 | self.pg_no += 1 105 | self.texts[self.pg_no] = {} 106 | 107 | def __setitem__(self, name, value): 108 | if name.lower() in self.keys: 109 | if not PY3K and isinstance(value, unicode): 110 | value = value.encode("latin1", "ignore") 111 | elif value is None: 112 | value = "" 113 | else: 114 | value = str(value) 115 | self.texts[self.pg_no][name.lower()] = value 116 | 117 | # setitem shortcut (may be further extended) 118 | set = __setitem__ 119 | 120 | def has_key(self, name): 121 | return name.lower() in self.keys 122 | 123 | def __contains__(self, name): 124 | return self.has_key(name) 125 | 126 | def __getitem__(self, name): 127 | if name in self.keys: 128 | key = name.lower() 129 | if key in self.texts: 130 | # text for this page: 131 | return self.texts[self.pg_no][key] 132 | else: 133 | # find first element for default text: 134 | elements = [ 135 | element 136 | for element in self.elements 137 | if element["name"].lower() == key 138 | ] 139 | if elements: 140 | return elements[0]["text"] 141 | 142 | def split_multicell(self, text, element_name): 143 | "Divide (\n) a string using a given element width" 144 | pdf = self.pdf 145 | element = [ 146 | element 147 | for element in self.elements 148 | if element["name"].lower() == element_name.lower() 149 | ][0] 150 | style = "" 151 | if element["bold"]: 152 | style += "B" 153 | if element["italic"]: 154 | style += "I" 155 | if element["underline"]: 156 | style += "U" 157 | pdf.set_font(element["font"], style, element["size"]) 158 | align = {"L": "L", "R": "R", "I": "L", "D": "R", "C": "C", "": ""}.get( 159 | element["align"] 160 | ) # D/I in spanish 161 | if isinstance(text, unicode) and not PY3K: 162 | text = text.encode("latin1", "ignore") 163 | else: 164 | text = str(text) 165 | return pdf.multi_cell( 166 | w=element["x2"] - element["x1"], 167 | h=element["y2"] - element["y1"], 168 | txt=text, 169 | align=align, 170 | split_only=True, 171 | ) 172 | 173 | def render(self, outfile, dest="F"): 174 | pdf = self.pdf 175 | for pg in range(1, self.pg_no + 1): 176 | pdf.add_page() 177 | pdf.set_font("Arial", "B", 16) 178 | pdf.set_auto_page_break(False, margin=0) 179 | 180 | for element in sorted(self.elements, key=lambda x: x["priority"]): 181 | # print "dib",element['type'], element['name'], element['x1'], element['y1'], element['x2'], element['y2'] 182 | element = element.copy() 183 | element["text"] = self.texts[pg].get( 184 | element["name"].lower(), element["text"] 185 | ) 186 | if "rotate" in element: 187 | pdf.rotate(element["rotate"], element["x1"], element["y1"]) 188 | self.handlers[element["type"].upper()](pdf, **element) 189 | if "rotate" in element: 190 | pdf.rotate(0) 191 | 192 | if dest: 193 | return pdf.output(outfile, dest) 194 | 195 | def text( 196 | self, 197 | pdf, 198 | x1=0, 199 | y1=0, 200 | x2=0, 201 | y2=0, 202 | text="", 203 | font="arial", 204 | size=10, 205 | bold=False, 206 | italic=False, 207 | underline=False, 208 | align="", 209 | foreground=0, 210 | backgroud=65535, 211 | multiline=None, 212 | *args, 213 | **kwargs, 214 | ): 215 | if text: 216 | if pdf.text_color != rgb(foreground): 217 | pdf.set_text_color(*rgb(foreground)) 218 | if pdf.fill_color != rgb(backgroud): 219 | pdf.set_fill_color(*rgb(backgroud)) 220 | 221 | font = font.strip().lower() 222 | if font == "arial black": 223 | font = "arial" 224 | style = "" 225 | for tag in "B", "I", "U": 226 | if text.startswith("<%s>" % tag) and text.endswith("" % tag): 227 | text = text[3:-4] 228 | style += tag 229 | if bold: 230 | style += "B" 231 | if italic: 232 | style += "I" 233 | if underline: 234 | style += "U" 235 | align = {"L": "L", "R": "R", "I": "L", "D": "R", "C": "C", "": ""}.get( 236 | align 237 | ) # D/I in spanish 238 | pdf.set_font(font, style, size) 239 | ##m_k = 72 / 2.54 240 | ##h = (size/m_k) 241 | pdf.set_xy(x1, y1) 242 | if multiline is None: 243 | # multiline==None: write without wrapping/trimming (default) 244 | pdf.cell(w=x2 - x1, h=y2 - y1, txt=text, border=0, ln=0, align=align) 245 | elif multiline: 246 | # multiline==True: automatic word - warp 247 | pdf.multi_cell(w=x2 - x1, h=y2 - y1, txt=text, border=0, align=align) 248 | else: 249 | # multiline==False: trim to fit exactly the space defined 250 | text = pdf.multi_cell( 251 | w=x2 - x1, h=y2 - y1, txt=text, align=align, split_only=True 252 | )[0] 253 | print("trimming: *%s*" % text) 254 | pdf.cell(w=x2 - x1, h=y2 - y1, txt=text, border=0, ln=0, align=align) 255 | 256 | # pdf.Text(x=x1,y=y1,txt=text) 257 | 258 | def line(self, pdf, x1=0, y1=0, x2=0, y2=0, size=0, foreground=0, *args, **kwargs): 259 | if pdf.draw_color != rgb(foreground): 260 | # print "SetDrawColor", hex(foreground) 261 | pdf.set_draw_color(*rgb(foreground)) 262 | # print "SetLineWidth", size 263 | pdf.set_line_width(size) 264 | pdf.line(x1, y1, x2, y2) 265 | 266 | def rect( 267 | self, 268 | pdf, 269 | x1=0, 270 | y1=0, 271 | x2=0, 272 | y2=0, 273 | size=0, 274 | foreground=0, 275 | backgroud=65535, 276 | *args, 277 | **kwargs, 278 | ): 279 | if pdf.draw_color != rgb(foreground): 280 | pdf.set_draw_color(*rgb(foreground)) 281 | if pdf.fill_color != rgb(backgroud): 282 | pdf.set_fill_color(*rgb(backgroud)) 283 | pdf.set_line_width(size) 284 | pdf.rect(x1, y1, x2 - x1, y2 - y1) 285 | 286 | def image(self, pdf, x1=0, y1=0, x2=0, y2=0, text="", *args, **kwargs): 287 | if text: 288 | pdf.image(text, x1, y1, w=x2 - x1, h=y2 - y1, type="", link="") 289 | 290 | def barcode( 291 | self, 292 | pdf, 293 | x1=0, 294 | y1=0, 295 | x2=0, 296 | y2=0, 297 | text="", 298 | font="arial", 299 | size=1, 300 | foreground=0, 301 | *args, 302 | **kwargs, 303 | ): 304 | if pdf.draw_color != rgb(foreground): 305 | pdf.set_draw_color(*rgb(foreground)) 306 | font = font.lower().strip() 307 | if font == "interleaved 2of5 nt": 308 | pdf.interleaved2of5(text, x1, y1, w=size, h=y2 - y1) 309 | 310 | # Added by Derek Schwalenberg Schwalenberg1013@gmail.com to allow (url) links in templates (using write method) 2014-02-22 311 | def write( 312 | self, 313 | pdf, 314 | x1=0, 315 | y1=0, 316 | x2=0, 317 | y2=0, 318 | text="", 319 | font="arial", 320 | size=1, 321 | bold=False, 322 | italic=False, 323 | underline=False, 324 | align="", 325 | link="http://example.com", 326 | foreground=0, 327 | *args, 328 | **kwargs, 329 | ): 330 | if pdf.text_color != rgb(foreground): 331 | pdf.set_text_color(*rgb(foreground)) 332 | font = font.strip().lower() 333 | if font == "arial black": 334 | font = "arial" 335 | style = "" 336 | for tag in "B", "I", "U": 337 | if text.startswith("<%s>" % tag) and text.endswith("" % tag): 338 | text = text[3:-4] 339 | style += tag 340 | if bold: 341 | style += "B" 342 | if italic: 343 | style += "I" 344 | if underline: 345 | style += "U" 346 | align = {"L": "L", "R": "R", "I": "L", "D": "R", "C": "C", "": ""}.get( 347 | align 348 | ) # D/I in spanish 349 | pdf.set_font(font, style, size) 350 | ##m_k = 72 / 2.54 351 | ##h = (size/m_k) 352 | pdf.set_xy(x1, y1) 353 | pdf.write(5, text, link) 354 | -------------------------------------------------------------------------------- /slackchannel2pdf/slack_service.py: -------------------------------------------------------------------------------- 1 | """Logic for handling Slack API.""" 2 | 3 | import logging 4 | from typing import Optional 5 | 6 | import slack_sdk 7 | from babel.numbers import format_decimal 8 | 9 | from . import settings 10 | from .helpers import transform_encoding 11 | from .locales import LocaleHelper 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class SlackService: 17 | """Service layer between main app and Slack API""" 18 | 19 | def __init__( 20 | self, slack_token: str, locale_helper: Optional[LocaleHelper] = None 21 | ) -> None: 22 | """ 23 | Args: 24 | - slack_token: Slack token to use for all API calls 25 | - locale_helper: locale to use 26 | """ 27 | if slack_token is None: 28 | raise ValueError("slack_token can not be null") 29 | 30 | # load information for current Slack workspace 31 | self._client = slack_sdk.WebClient(token=slack_token) 32 | if not locale_helper: 33 | locale_helper = LocaleHelper() 34 | self._locale = locale_helper.locale 35 | self._workspace_info = self._fetch_workspace_info() 36 | logger.info("Current Slack workspace: %s", self.team) 37 | self._user_names = self.fetch_user_names() 38 | 39 | # set author 40 | if "user_id" in self._workspace_info: 41 | author_id = self._workspace_info["user_id"] 42 | if author_id in self._user_names: 43 | self._author = self._user_names[author_id] 44 | else: 45 | self._author = f"unknown_user_{author_id}" 46 | else: 47 | author_id = None 48 | self._author = "unknown user" 49 | 50 | logger.info("Current Slack user: %s", self.author) 51 | self._channel_names = self._fetch_channel_names() 52 | self._usergroup_names = self._fetch_usergroup_names() 53 | 54 | if author_id is not None: 55 | self._author_info = self._fetch_user_info(author_id) 56 | else: 57 | self._author_info = {} 58 | 59 | @property 60 | def author(self) -> str: 61 | """Return author.""" 62 | return self._author 63 | 64 | @property 65 | def team(self) -> str: 66 | """Return team.""" 67 | return self._workspace_info.get("team", "") 68 | 69 | def author_info(self) -> dict: 70 | """Return author info.""" 71 | return self._author_info 72 | 73 | def channel_names(self) -> dict: 74 | """Return channel names.""" 75 | return self._channel_names 76 | 77 | def user_names(self) -> dict: 78 | """Return user names.""" 79 | return self._user_names 80 | 81 | def usergroup_names(self) -> dict: 82 | """Return usergroup names.""" 83 | return self._usergroup_names 84 | 85 | def _fetch_workspace_info(self) -> dict: 86 | """returns dict with info about current workspace""" 87 | 88 | logger.info("Fetching workspace info from Slack...") 89 | res = self._client.auth_test() 90 | return res.data # type: ignore 91 | 92 | def fetch_user_names(self) -> dict: 93 | """returns dict of user names with user ID as key""" 94 | user_names_raw = self._fetch_pages( 95 | "users_list", key="members", items_name="users" 96 | ) 97 | user_names = self._reduce_to_dict(user_names_raw, "id", "real_name", "name") 98 | for user in user_names: 99 | user_names[user] = transform_encoding(user_names[user]) 100 | return user_names 101 | 102 | def _fetch_user_info(self, user_id: str) -> dict: 103 | """returns dict of user info for user ID incl. locale""" 104 | logger.info("Fetching user info for author...") 105 | response = self._client.users_info(user=user_id, include_locale=True) 106 | return response["user"] 107 | 108 | def _fetch_channel_names(self) -> dict: 109 | """returns dict of channel names with channel ID as key""" 110 | channel_names_raw = self._fetch_pages( 111 | "conversations_list", 112 | key="channels", 113 | args={"types": "public_channel,private_channel"}, 114 | items_name="channels", 115 | ) 116 | channel_names = self._reduce_to_dict(channel_names_raw, "id", "name") 117 | for channel in channel_names: 118 | channel_names[channel] = transform_encoding(channel_names[channel]) 119 | return channel_names 120 | 121 | def _fetch_usergroup_names(self) -> dict: 122 | """returns dict of usergroup names with usergroup ID as key""" 123 | 124 | logger.info("Fetching usergroups from Slack...") 125 | response = self._client.usergroups_list() 126 | usergroup_names = self._reduce_to_dict(response["usergroups"], "id", "handle") 127 | if usergroup_names: 128 | for usergroup in usergroup_names: 129 | usergroup_names[usergroup] = transform_encoding( 130 | usergroup_names[usergroup] 131 | ) 132 | logger.info( 133 | "Got a total of %s usergroups for this workspace", 134 | format_decimal(len(usergroup_names), locale=self._locale), 135 | ) 136 | else: 137 | logger.info("This workspace has no usergroups") 138 | return usergroup_names 139 | 140 | def fetch_messages_from_channel( 141 | self, channel_id, max_messages, oldest=None, latest=None 142 | ) -> list: 143 | """retrieve messages from a channel on Slack and return as list""" 144 | 145 | oldest_ts = str(oldest.timestamp()) if oldest is not None else 0 146 | latest_ts = str(latest.timestamp()) if latest is not None else 0 147 | messages = self._fetch_pages( 148 | "conversations_history", 149 | key="messages", 150 | args={ 151 | "channel": channel_id, 152 | "oldest": oldest_ts, 153 | "latest": latest_ts, 154 | }, 155 | max_rows=max_messages, 156 | items_name="messages", 157 | collection_name="channel", 158 | ) 159 | return messages 160 | 161 | def fetch_threads_from_messages( 162 | self, channel_id, messages, max_messages, oldest=None, latest=None 163 | ) -> dict: 164 | """returns threads from all messages from for a channel as dict""" 165 | threads = {} 166 | thread_num = 0 167 | thread_messages_total = 0 168 | for msg in messages: 169 | if "thread_ts" in msg and msg["thread_ts"] == msg["ts"]: 170 | thread_ts = msg["thread_ts"] 171 | thread_num += 1 172 | thread_messages = self._fetch_messages_from_thread( 173 | channel_id, thread_ts, max_messages, oldest, latest 174 | ) 175 | threads[thread_ts] = thread_messages 176 | thread_messages_total += len(thread_messages) 177 | 178 | if thread_messages_total: 179 | logger.info( 180 | "Received %s messages from %d threads", 181 | format_decimal(thread_messages_total, locale=self._locale), 182 | thread_num, 183 | ) 184 | else: 185 | logger.info("This channel has no threads") 186 | 187 | return threads 188 | 189 | def _fetch_messages_from_thread( 190 | self, channel_id, thread_ts, max_messages, oldest=None, latest=None 191 | ) -> list: 192 | """retrieve messages from a Slack thread and return as list""" 193 | oldest_ts = str(oldest.timestamp()) if oldest is not None else 0 194 | latest_ts = str(latest.timestamp()) if latest is not None else 0 195 | messages = self._fetch_pages( 196 | "conversations_replies", 197 | key="messages", 198 | args={ 199 | "channel": channel_id, 200 | "ts": thread_ts, 201 | "oldest": oldest_ts, 202 | "latest": latest_ts, 203 | }, 204 | max_rows=max_messages, 205 | items_name="threads", 206 | collection_name="channel", 207 | print_result=False, 208 | ) 209 | return messages 210 | 211 | def _fetch_pages( 212 | self, 213 | method, 214 | key: str, 215 | args: Optional[dict] = None, 216 | limit: Optional[int] = None, 217 | max_rows: Optional[int] = None, 218 | items_name: Optional[str] = None, 219 | collection_name: Optional[str] = None, 220 | print_result: bool = True, 221 | ) -> list: 222 | """helper for retrieving all pages from an API endpoint""" 223 | # fetch first page 224 | page = 1 225 | output_str = ( 226 | f"Fetching {items_name if items_name else method} " 227 | f"from {collection_name if collection_name else 'workspace'}..." 228 | ) 229 | logger.info(output_str) 230 | if not args: 231 | args = {} 232 | if not limit: 233 | limit = settings.SLACK_PAGE_LIMIT 234 | base_args = {**args, **{"limit": limit}} 235 | response = getattr(self._client, method)(**base_args) 236 | rows = response[key] 237 | 238 | # fetch additional page (if any) 239 | while ( 240 | (not max_rows or len(rows) < max_rows) 241 | and response.get("response_metadata") 242 | and response["response_metadata"].get("next_cursor") 243 | ): 244 | page += 1 245 | logger.info("%s - page %s", output_str, page) 246 | page_args = { 247 | **base_args, 248 | **{ 249 | "cursor": response["response_metadata"].get("next_cursor"), 250 | }, 251 | } 252 | response = getattr(self._client, method)(**page_args) 253 | rows += response[key] 254 | 255 | if print_result: 256 | logger.info( 257 | "Received %s %s", 258 | format_decimal(len(rows), locale=self._locale), 259 | items_name if items_name else "objects", 260 | ) 261 | return rows 262 | 263 | def fetch_bot_names_for_messages(self, messages: list, threads: dict) -> dict: 264 | """Fetches bot names from API for provided messages 265 | 266 | Will only fetch names for bots that never appeared with a username 267 | in any message (lazy approach since calls to bots_info are very slow) 268 | """ 269 | # collect bot_ids without user name from messages 270 | bot_ids = [] 271 | bot_names = {} 272 | for msg in messages: 273 | if "bot_id" in msg: 274 | bot_id = msg["bot_id"] 275 | if "username" in msg: 276 | bot_names[bot_id] = transform_encoding(msg["username"]) 277 | else: 278 | bot_ids.append(bot_id) 279 | 280 | # collect bot_ids without user name from thread messages 281 | for thread_messages in threads.values(): 282 | for msg in thread_messages: 283 | if "bot_id" in msg: 284 | bot_id = msg["bot_id"] 285 | if "username" in msg: 286 | bot_names[bot_id] = transform_encoding(msg["username"]) 287 | else: 288 | bot_ids.append(bot_id) 289 | 290 | # Find bot IDs that are not in bot_names 291 | bot_ids = set(bot_ids).difference(bot_names.keys()) 292 | 293 | # collect bot names from API if needed 294 | if len(bot_ids) > 0: 295 | logger.info("Fetching names for %d bots", len(bot_ids)) 296 | for bot_id in bot_ids: 297 | response = self._client.bots_info(bot=bot_id) 298 | if response["ok"]: 299 | bot_names[bot_id] = transform_encoding(response["bot"]["name"]) 300 | return bot_names 301 | 302 | @staticmethod 303 | def _reduce_to_dict( 304 | arr: list, 305 | key_name: str, 306 | col_name_primary: str, 307 | col_name_secondary: Optional[str] = None, 308 | ) -> dict: 309 | """returns dict with selected columns as key and value from list of dict 310 | 311 | Args: 312 | arr: list of dicts to reduce 313 | key_name: name of column to become key 314 | col_name_primary: colum will become value if it exists 315 | col_name_secondary: colum will become value if col_name_primary 316 | does not exist and this argument is provided 317 | 318 | dict items with no matching key_name, col_name_primary and 319 | col_name_secondary will not be included in the resulting new dict 320 | 321 | """ 322 | arr2 = {} 323 | for item in arr: 324 | if key_name in item: 325 | key = item[key_name] 326 | if col_name_primary in item: 327 | arr2[key] = item[col_name_primary] 328 | elif col_name_secondary is not None and col_name_secondary in item: 329 | arr2[key] = item[col_name_secondary] 330 | return arr2 331 | -------------------------------------------------------------------------------- /tests/test_channel_exporter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from pathlib import Path 4 | from unittest.mock import patch 5 | 6 | import babel 7 | import PyPDF2 8 | import pytz 9 | 10 | from slackchannel2pdf import __version__, settings 11 | from slackchannel2pdf.channel_exporter import SlackChannelExporter 12 | 13 | from .helpers import NoSocketsTestCase, SlackClientStub 14 | 15 | """ 16 | def test_run_with_error(self): 17 | self.assertRaises(RuntimeError, self.exporter.run( 18 | ["channel-exporter"], 19 | "invalid_path" 20 | )) 21 | """ 22 | 23 | currentdir = Path(__file__).resolve().parent 24 | outputdir = currentdir / "temp" 25 | if not outputdir.exists(): 26 | os.mkdir(outputdir) 27 | 28 | 29 | @patch("slackchannel2pdf.slack_service.slack_sdk") 30 | class TestSlackChannelExporter(NoSocketsTestCase): 31 | """New test approach with API mocking, that allows full testing of the exporter""" 32 | 33 | def setUp(self) -> None: 34 | for file in outputdir.glob("*"): 35 | file.unlink() 36 | 37 | def test_basic(self, mock_slack): 38 | # given 39 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 40 | exporter = SlackChannelExporter("TOKEN_DUMMY") 41 | channel = "C12345678" 42 | # when 43 | response = exporter.run([channel], outputdir) 44 | # then 45 | self.assertTrue(response["ok"]) 46 | res_channel = response["channels"][channel] 47 | self.assertTrue(res_channel["ok"]) 48 | filename_pdf = Path(res_channel["filename_pdf"]) 49 | self.assertTrue(filename_pdf.is_file()) 50 | self.assertEqual(filename_pdf.name, "test_berlin.pdf") 51 | 52 | def test_run_with_defaults(self, mock_slack): 53 | # given 54 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 55 | exporter = SlackChannelExporter("TOKEN_DUMMY") 56 | channels = ["C12345678", "C72345678"] 57 | # when 58 | response = exporter.run(channels) 59 | # then 60 | self.assertTrue(response["ok"]) 61 | for channel_id in channels: 62 | self.assertIn(channel_id, response["channels"]) 63 | res_channel = response["channels"][channel_id] 64 | channel_name = exporter._slack_service.channel_names()[channel_id] 65 | self.assertEqual( 66 | res_channel["filename_pdf"], 67 | str( 68 | currentdir.parent 69 | / (exporter._slack_service.team + "_" + channel_name + ".pdf") 70 | ), 71 | ) 72 | self.assertTrue(Path(res_channel["filename_pdf"]).is_file()) 73 | 74 | # assert export details are correct 75 | self.assertTrue(res_channel["ok"]) 76 | self.assertEqual(res_channel["dest_path"], str(currentdir.parent)) 77 | self.assertEqual(res_channel["page_format"], "a4") 78 | self.assertEqual(res_channel["page_orientation"], "portrait") 79 | self.assertEqual( 80 | res_channel["max_messages"], 81 | settings.MAX_MESSAGES_PER_CHANNEL, 82 | ) 83 | self.assertEqual(res_channel["timezone"], pytz.UTC) 84 | self.assertEqual(res_channel["locale"], babel.Locale("en", "US")) 85 | 86 | # assert infos in PDF file are correct 87 | pdf_file = open(res_channel["filename_pdf"], "rb") 88 | pdf_reader = PyPDF2.PdfReader(pdf_file) 89 | doc_info = pdf_reader.metadata 90 | self.assertEqual(doc_info.author, "Erik Kalkoken") 91 | self.assertEqual(doc_info.creator, f"Channel Export v{__version__}") 92 | self.assertEqual( 93 | doc_info.title, 94 | (exporter._slack_service.team + " / " + channel_name), 95 | ) 96 | 97 | def test_run_with_args_1(self, mock_slack): 98 | # given 99 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 100 | exporter = SlackChannelExporter("TOKEN_DUMMY") 101 | channel = "C72345678" 102 | # when 103 | response = exporter.run([channel], outputdir, None, None, "landscape", "a3", 42) 104 | # then 105 | self.assertTrue(response["ok"]) 106 | res_channel = response["channels"][channel] 107 | self.assertTrue(res_channel["ok"]) 108 | self.assertEqual(res_channel["message_count"], 5) 109 | self.assertEqual(res_channel["thread_count"], 0) 110 | self.assertEqual(res_channel["dest_path"], str(outputdir)) 111 | self.assertEqual(res_channel["page_format"], "a3") 112 | self.assertEqual(res_channel["page_orientation"], "landscape") 113 | self.assertEqual(res_channel["max_messages"], 42) 114 | 115 | def test_run_with_args_2(self, mock_slack): 116 | # given 117 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 118 | exporter = SlackChannelExporter("TOKEN_DUMMY") 119 | channel = "C72345678" 120 | # when 121 | response = exporter.run([channel], outputdir, write_raw_data=True) 122 | # then 123 | self.assertTrue(response["ok"]) 124 | self.assertTrue((outputdir / "test_bots.json").is_file()) 125 | self.assertTrue((outputdir / "test_channels.json").is_file()) 126 | self.assertTrue((outputdir / "test_london.pdf").is_file()) 127 | self.assertTrue((outputdir / "test_london_messages.json").is_file()) 128 | self.assertTrue((outputdir / "test_usergroups.json").is_file()) 129 | self.assertTrue((outputdir / "test_users.json").is_file()) 130 | 131 | def test_all_message_variants(self, mock_slack): 132 | # given 133 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 134 | exporter = SlackChannelExporter("TOKEN_DUMMY") 135 | channels = ["G1234567X"] 136 | # when 137 | response = exporter.run(channels, outputdir) 138 | # then 139 | self.assertTrue(response["ok"]) 140 | 141 | def test_should_handle_team_name_with_invalid_characters(self, mock_slack): 142 | # given 143 | mock_slack.WebClient.return_value = SlackClientStub(team="T92345678") 144 | exporter = SlackChannelExporter("TOKEN_DUMMY") 145 | channels = ["C12345678"] 146 | # when 147 | response = exporter.run(channels, outputdir) 148 | # then 149 | self.assertTrue(response["ok"]) 150 | 151 | def test_should_use_given_timezone(self, mock_slack): 152 | # given 153 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 154 | exporter = SlackChannelExporter( 155 | slack_token="TOKEN_DUMMY", 156 | my_tz=pytz.timezone("Asia/Bangkok"), 157 | my_locale=babel.Locale.parse("es-MX", sep="-"), 158 | ) 159 | channel = "C12345678" 160 | # when 161 | response = exporter.run([channel], outputdir) 162 | # then 163 | self.assertTrue(response["ok"]) 164 | res_channel = response["channels"][channel] 165 | self.assertTrue(res_channel["ok"]) 166 | self.assertEqual( 167 | res_channel["timezone"], 168 | pytz.timezone("Asia/Bangkok"), 169 | ) 170 | self.assertEqual(res_channel["locale"], babel.Locale.parse("es-MX", sep="-")) 171 | 172 | def test_should_handle_nested_code_formatting(self, mock_slack): 173 | # given 174 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 175 | exporter = SlackChannelExporter(slack_token="TOKEN_DUMMY") 176 | channel = "C92345678" 177 | # when 178 | response = exporter.run([channel], outputdir) 179 | # then 180 | self.assertTrue(response["ok"]) 181 | 182 | def test_should_handle_nested_s_tags(self, mock_slack): 183 | # given 184 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 185 | exporter = SlackChannelExporter(slack_token="TOKEN_DUMMY") 186 | channel = "C98345678" 187 | # when 188 | response = exporter.run([channel], outputdir) 189 | # then 190 | self.assertTrue(response["ok"]) 191 | 192 | 193 | class TestTransformations(NoSocketsTestCase): 194 | @classmethod 195 | def setUpClass(cls): 196 | super().setUpClass() 197 | with patch("slackchannel2pdf.slack_service.slack_sdk") as mock_slack: 198 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 199 | cls.exporter = SlackChannelExporter("TOKEN_DUMMY") 200 | 201 | def test_transform_text_user(self): 202 | self.assertEqual( 203 | self.exporter._transformer.transform_text("<@U62345678>", True), 204 | "@Janet Hakuli", 205 | ) 206 | self.assertEqual( 207 | self.exporter._transformer.transform_text("<@U999999999>", True), 208 | "@user_U999999999", 209 | ) 210 | self.assertEqual( 211 | self.exporter._transformer.transform_text("<@W999999999>", True), 212 | "@user_W999999999", 213 | ) 214 | 215 | def test_transform_text_channel(self): 216 | self.assertEqual( 217 | self.exporter._transformer.transform_text("<#C72345678>", True), 218 | "#london", 219 | ) 220 | self.assertEqual( 221 | self.exporter._transformer.transform_text("<#C55555555>", True), 222 | "#channel_C55555555", 223 | ) 224 | 225 | def test_transform_text_usergroup(self): 226 | self.assertEqual( 227 | self.exporter._transformer.transform_text("", True), 228 | "@marketing", 229 | ) 230 | 231 | self.assertEqual( 232 | self.exporter._transformer.transform_text("", True), 233 | "@usergroup_SAZ94GDB8", 234 | ) 235 | 236 | def test_transform_text_special(self): 237 | self.assertEqual( 238 | self.exporter._transformer.transform_text("", True), 239 | "@everyone", 240 | ) 241 | self.assertEqual( 242 | self.exporter._transformer.transform_text("", True), "@here" 243 | ) 244 | self.assertEqual( 245 | self.exporter._transformer.transform_text("", True), 246 | "@channel", 247 | ) 248 | self.assertEqual( 249 | self.exporter._transformer.transform_text( 250 | "", 252 | True, 253 | ), 254 | self.exporter._locale_helper.get_datetime_formatted_str(1392734382), 255 | ) 256 | self.assertEqual( 257 | self.exporter._transformer.transform_text("", True), 258 | "@special_xyz", 259 | ) 260 | 261 | def test_transform_text_url(self): 262 | self.assertEqual( 263 | self.exporter._transformer.transform_text( 264 | "", True 265 | ), 266 | 'Google', 267 | ) 268 | self.assertEqual( 269 | self.exporter._transformer.transform_text("", True), 270 | 'https://www.google.com', 271 | ) 272 | 273 | def test_transform_text_formatting(self): 274 | self.assertEqual( 275 | self.exporter._transformer.transform_text("*bold*", True), "bold" 276 | ) 277 | self.assertEqual( 278 | self.exporter._transformer.transform_text("_italic_", True), "italic" 279 | ) 280 | self.assertEqual( 281 | self.exporter._transformer.transform_text( 282 | "text *bold* text _italic_ text", True 283 | ), 284 | "text bold text italic text", 285 | ) 286 | self.assertEqual( 287 | self.exporter._transformer.transform_text("`code`", True), 288 | 'code', 289 | ) 290 | self.assertEqual( 291 | self.exporter._transformer.transform_text("*_bold+italic_*", True), 292 | "bold+italic", 293 | ) 294 | 295 | def test_transform_text_general(self): 296 | self.assertEqual( 297 | self.exporter._transformer.transform_text( 298 | "some *text* <@U62345678> more text", True 299 | ), 300 | "some text @Janet Hakuli more text", 301 | ) 302 | self.assertEqual( 303 | self.exporter._transformer.transform_text("first\nsecond\nthird", True), 304 | "first
second
third", 305 | ) 306 | 307 | self.assertEqual( 308 | self.exporter._transformer.transform_text( 309 | "some text <@U62345678> more text", True 310 | ), 311 | "some text @Janet Hakuli more text", 312 | ) 313 | 314 | self.assertEqual( 315 | self.exporter._transformer.transform_text( 316 | "before ident\n>indented text\nafter ident", True 317 | ), 318 | "before ident
indented text

after ident", 319 | ) 320 | 321 | 322 | class TestOther(NoSocketsTestCase): 323 | @classmethod 324 | def setUpClass(cls): 325 | super().setUpClass() 326 | with patch("slackchannel2pdf.slack_service.slack_sdk") as mock_slack: 327 | mock_slack.WebClient.return_value = SlackClientStub(team="T12345678") 328 | cls.exporter = SlackChannelExporter("TOKEN_DUMMY") 329 | 330 | 331 | if __name__ == "__main__": 332 | unittest.main() 333 | -------------------------------------------------------------------------------- /slackchannel2pdf/fpdf_mod/html.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | 3 | "HTML Renderer for FPDF.py" 4 | 5 | __author__ = "Mariano Reingart " 6 | __copyright__ = "Copyright (C) 2010 Mariano Reingart" 7 | __license__ = "LGPL 3.0" 8 | 9 | # Inspired by tuto5.py and several examples from fpdf.org, html2fpdf, etc. 10 | 11 | from .fpdf import FPDF 12 | from .py3k import PY3K, HTMLParser, basestring, unicode 13 | 14 | DEBUG = False 15 | 16 | 17 | def px2mm(px): 18 | return int(px) * 25.4 / 72.0 19 | 20 | 21 | def hex2dec(color="#000000"): 22 | if color: 23 | r = int(color[1:3], 16) 24 | g = int(color[3:5], 16) 25 | b = int(color[5:7], 16) 26 | return r, g, b 27 | 28 | 29 | class HTML2FPDF(HTMLParser): 30 | "Render basic HTML to FPDF" 31 | 32 | def __init__(self, pdf, image_map=None): 33 | HTMLParser.__init__(self) 34 | self.style = {} 35 | self.pre = False 36 | self.href = "" 37 | self.align = "" 38 | self.page_links = {} 39 | self.font = None 40 | self.font_stack = [] 41 | self.pdf = pdf 42 | self.image_map = image_map or (lambda src: src) 43 | self.r = self.g = self.b = 0 44 | self.indent = 0 45 | self.bullet = [] 46 | self.set_font("times", 12) 47 | self.font_face = "times" # initialize font 48 | self.color = 0 # initialize font color 49 | self.table = None # table attributes 50 | self.table_col_width = None # column (header) widths 51 | self.table_col_index = None # current column index 52 | self.td = None # cell attributes 53 | self.th = False # header enabled 54 | self.tr = None 55 | self.theader = None # table header cells 56 | self.tfooter = None # table footer cells 57 | self.thead = None 58 | self.tfoot = None 59 | self.theader_out = self.tfooter_out = False 60 | self.hsize = dict(h1=2, h2=1.5, h3=1.17, h4=1, h5=0.83, h6=0.67) 61 | 62 | def width2mm(self, length): 63 | if length[-1] == "%": 64 | total = self.pdf.w - self.pdf.r_margin - self.pdf.l_margin 65 | if self.table["width"][-1] == "%": 66 | total *= int(self.table["width"][:-1]) / 100.0 67 | return int(length[:-1]) * total / 101.0 68 | else: 69 | return int(length) / 6.0 70 | 71 | def handle_data(self, txt): 72 | if self.td is not None: # drawing a table? 73 | if "width" not in self.td and "colspan" not in self.td: 74 | try: 75 | l = [self.table_col_width[self.table_col_index]] 76 | except IndexError: 77 | raise RuntimeError( 78 | "Table column/cell width not specified, unable to continue" 79 | ) 80 | elif "colspan" in self.td: 81 | i = self.table_col_index 82 | colspan = int(self.td["colspan"]) 83 | l = self.table_col_width[i : i + colspan] 84 | else: 85 | l = [self.td.get("width", "240")] 86 | w = sum([self.width2mm(length) for length in l]) 87 | h = int(self.td.get("height", 0)) // 4 or self.h * 1.30 88 | self.table_h = h 89 | border = int(self.table.get("border", 0)) 90 | if not self.th: 91 | align = self.td.get("align", "L")[0].upper() 92 | border = border and "LR" 93 | else: 94 | self.set_style("B", True) 95 | border = border or "B" 96 | align = self.td.get("align", "C")[0].upper() 97 | bgcolor = hex2dec(self.td.get("bgcolor", self.tr.get("bgcolor", ""))) 98 | # parsing table header/footer (drawn later): 99 | if self.thead is not None: 100 | self.theader.append(((w, h, txt, border, 0, align), bgcolor)) 101 | if self.tfoot is not None: 102 | self.tfooter.append(((w, h, txt, border, 0, align), bgcolor)) 103 | # check if reached end of page, add table footer and header: 104 | height = h + (self.tfooter and self.tfooter[0][0][1] or 0) 105 | if self.pdf.y + height > self.pdf.page_break_trigger and not self.th: 106 | self.output_table_footer() 107 | self.pdf.add_page(same=True) 108 | self.theader_out = self.tfooter_out = False 109 | if self.tfoot is None and self.thead is None: 110 | if not self.theader_out: 111 | self.output_table_header() 112 | self.box_shadow(w, h, bgcolor) 113 | if DEBUG: 114 | print("td cell", self.pdf.x, w, txt, "*") 115 | self.pdf.cell(w, h, txt, border, 0, align) 116 | elif self.table is not None: 117 | # ignore anything else than td inside a table 118 | pass 119 | elif self.align: 120 | if DEBUG: 121 | print("cell", txt, "*") 122 | self.pdf.cell(0, self.h, txt, 0, 1, self.align[0].upper(), self.href) 123 | else: 124 | txt = txt.replace("\n", " ") 125 | if self.href: 126 | self.put_link(self.href, txt) 127 | else: 128 | if DEBUG: 129 | print("write", txt, "*") 130 | self.pdf.write(self.h, txt) 131 | 132 | def box_shadow(self, w, h, bgcolor): 133 | if DEBUG: 134 | print("box_shadow", w, h, bgcolor) 135 | if bgcolor: 136 | fill_color = self.pdf.fill_color 137 | self.pdf.set_fill_color(*bgcolor) 138 | self.pdf.rect(self.pdf.x, self.pdf.y, w, h, "F") 139 | self.pdf.fill_color = fill_color 140 | 141 | def output_table_header(self): 142 | if self.theader: 143 | b = self.b 144 | x = self.pdf.x 145 | self.pdf.set_x(self.table_offset) 146 | self.set_style("B", True) 147 | for cell, bgcolor in self.theader: 148 | self.box_shadow(cell[0], cell[1], bgcolor) 149 | self.pdf.cell(*cell) 150 | self.set_style("B", b) 151 | self.pdf.ln(self.theader[0][0][1]) 152 | self.pdf.set_x(self.table_offset) 153 | # self.pdf.set_x(x) 154 | self.theader_out = True 155 | 156 | def output_table_footer(self): 157 | if self.tfooter: 158 | x = self.pdf.x 159 | self.pdf.set_x(self.table_offset) 160 | # TODO: self.output_table_sep() 161 | for cell, bgcolor in self.tfooter: 162 | self.box_shadow(cell[0], cell[1], bgcolor) 163 | self.pdf.cell(*cell) 164 | self.pdf.ln(self.tfooter[0][0][1]) 165 | self.pdf.set_x(x) 166 | if int(self.table.get("border", 0)): 167 | self.output_table_sep() 168 | self.tfooter_out = True 169 | 170 | def output_table_sep(self): 171 | self.pdf.set_x(self.table_offset) 172 | x1 = self.pdf.x 173 | y1 = self.pdf.y 174 | w = sum([self.width2mm(lenght) for lenght in self.table_col_width]) 175 | self.pdf.line(x1, y1, x1 + w, y1) 176 | 177 | def handle_starttag(self, tag, attrs): 178 | attrs = dict(attrs) 179 | if DEBUG: 180 | print("STARTTAG", tag, attrs) 181 | if tag == "b" or tag == "i" or tag == "u": 182 | self.set_style(tag, 1) 183 | if tag == "a": 184 | self.href = attrs["href"] 185 | if tag == "br": 186 | self.pdf.ln(5) 187 | if tag == "p": 188 | self.pdf.ln(5) 189 | if attrs: 190 | if attrs: 191 | self.align = attrs.get("align") 192 | if tag in self.hsize: 193 | k = self.hsize[tag] 194 | self.pdf.ln(5 * k) 195 | self.pdf.set_text_color(150, 0, 0) 196 | self.pdf.set_font_size(12 * k) 197 | if attrs: 198 | self.align = attrs.get("align") 199 | if tag == "hr": 200 | self.put_line() 201 | if tag == "pre": 202 | self.pdf.set_font("Courier", "", 11) 203 | self.pdf.set_font_size(11) 204 | self.set_style("B", False) 205 | self.set_style("I", False) 206 | self.pre = True 207 | if tag == "blockquote": 208 | self.set_text_color(100, 0, 45) 209 | self.pdf.ln(3) 210 | if tag == "ul": 211 | self.indent += 1 212 | self.bullet.append("\x95") 213 | if tag == "ol": 214 | self.indent += 1 215 | self.bullet.append(0) 216 | if tag == "li": 217 | self.pdf.ln(self.h + 2) 218 | self.pdf.set_text_color(190, 0, 0) 219 | bullet = self.bullet[self.indent - 1] 220 | if not isinstance(bullet, basestring): 221 | bullet += 1 222 | self.bullet[self.indent - 1] = bullet 223 | bullet = "%s. " % bullet 224 | self.pdf.write(self.h, "%s%s " % (" " * 5 * self.indent, bullet)) 225 | self.set_text_color() 226 | if tag == "font": 227 | # save previous font state: 228 | self.font_stack.append((self.font_face, self.font_size, self.color)) 229 | if "color" in attrs: 230 | color = hex2dec(attrs["color"]) 231 | self.set_text_color(*color) 232 | self.color = color 233 | if "face" in attrs: 234 | face = attrs.get("face").lower() 235 | try: 236 | self.pdf.set_font(face) 237 | self.font_face = face 238 | except RuntimeError: 239 | pass # font not found, ignore 240 | if "size" in attrs: 241 | size = int(attrs.get("size")) 242 | self.pdf.set_font(self.font_face, size=int(size)) 243 | self.font_size = size 244 | if tag == "table": 245 | self.table = dict([(k.lower(), v) for k, v in attrs.items()]) 246 | if not "width" in self.table: 247 | self.table["width"] = "100%" 248 | if self.table["width"][-1] == "%": 249 | w = self.pdf.w - self.pdf.r_margin - self.pdf.l_margin 250 | w *= int(self.table["width"][:-1]) / 100.0 251 | self.table_offset = (self.pdf.w - w) / 2.0 252 | self.table_col_width = [] 253 | self.theader_out = self.tfooter_out = False 254 | self.theader = [] 255 | self.tfooter = [] 256 | self.thead = None 257 | self.tfoot = None 258 | self.table_h = 0 259 | self.pdf.ln() 260 | if tag == "tr": 261 | self.tr = dict([(k.lower(), v) for k, v in attrs.items()]) 262 | self.table_col_index = 0 263 | self.pdf.set_x(self.table_offset) 264 | if tag == "td": 265 | self.td = dict([(k.lower(), v) for k, v in attrs.items()]) 266 | if tag == "th": 267 | self.td = dict([(k.lower(), v) for k, v in attrs.items()]) 268 | self.th = True 269 | if "width" in self.td: 270 | self.table_col_width.append(self.td["width"]) 271 | if tag == "thead": 272 | self.thead = {} 273 | if tag == "tfoot": 274 | self.tfoot = {} 275 | if tag == "img": 276 | if "src" in attrs: 277 | x = self.pdf.get_x() 278 | y = self.pdf.get_y() 279 | w = px2mm(attrs.get("width", 0)) 280 | h = px2mm(attrs.get("height", 0)) 281 | if self.align and self.align[0].upper() == "C": 282 | x = (self.pdf.w - x) / 2.0 - w / 2.0 283 | self.pdf.image(self.image_map(attrs["src"]), x, y, w, h, link=self.href) 284 | self.pdf.set_x(x + w) 285 | self.pdf.set_y(y + h) 286 | if tag == "b" or tag == "i" or tag == "u": 287 | self.set_style(tag, True) 288 | if tag == "center": 289 | self.align = "Center" 290 | 291 | def handle_endtag(self, tag): 292 | # Closing tag 293 | if DEBUG: 294 | print("ENDTAG", tag) 295 | if tag == "h1" or tag == "h2" or tag == "h3" or tag == "h4": 296 | self.pdf.ln(6) 297 | self.set_font() 298 | self.set_style() 299 | self.align = None 300 | if tag == "pre": 301 | self.pdf.set_font(self.font or "Times", "", 12) 302 | self.pdf.set_font_size(12) 303 | self.pre = False 304 | if tag == "blockquote": 305 | self.set_text_color(0, 0, 0) 306 | self.pdf.ln(3) 307 | if tag == "strong": 308 | tag = "b" 309 | if tag == "em": 310 | tag = "i" 311 | if tag == "b" or tag == "i" or tag == "u": 312 | self.set_style(tag, False) 313 | if tag == "a": 314 | self.href = "" 315 | if tag == "p": 316 | self.align = "" 317 | if tag in ("ul", "ol"): 318 | self.indent -= 1 319 | self.bullet.pop() 320 | if tag == "table": 321 | if not self.tfooter_out: 322 | self.output_table_footer() 323 | self.table = None 324 | self.th = False 325 | self.theader = None 326 | self.tfooter = None 327 | self.pdf.ln() 328 | if tag == "thead": 329 | self.thead = None 330 | if tag == "tfoot": 331 | self.tfoot = None 332 | if tag == "tbody": 333 | # draw a line separator between table bodies 334 | self.pdf.set_x(self.table_offset) 335 | self.output_table_sep() 336 | if tag == "tr": 337 | h = self.table_h 338 | if self.tfoot is None: 339 | self.pdf.ln(h) 340 | self.tr = None 341 | if tag == "td" or tag == "th": 342 | if self.th: 343 | if DEBUG: 344 | print("revert style") 345 | self.set_style("B", False) # revert style 346 | self.table_col_index += int(self.td.get("colspan", "1")) 347 | self.td = None 348 | self.th = False 349 | if tag == "font": 350 | # recover last font state 351 | face, size, color = self.font_stack.pop() 352 | if face: 353 | self.pdf.set_text_color(0, 0, 0) 354 | self.color = None 355 | self.set_font(face, size) 356 | self.font = None 357 | if tag == "center": 358 | self.align = None 359 | 360 | def set_font(self, face=None, size=None): 361 | if face: 362 | self.font_face = face 363 | if size: 364 | self.font_size = size 365 | self.h = size / 72.0 * 25.4 366 | if DEBUG: 367 | print("H", self.h) 368 | self.pdf.set_font(self.font_face or "times", "", 12) 369 | self.pdf.set_font_size(self.font_size or 12) 370 | self.set_style("u", False) 371 | self.set_style("b", False) 372 | self.set_style("i", False) 373 | self.set_text_color() 374 | 375 | def set_style(self, tag=None, enable=None): 376 | # Modify style and select corresponding font 377 | if tag: 378 | t = self.style.get(tag.lower()) 379 | self.style[tag.lower()] = enable 380 | style = "" 381 | for s in ("b", "i", "u"): 382 | if self.style.get(s): 383 | style += s 384 | if DEBUG: 385 | print("SET_FONT_STYLE", style) 386 | self.pdf.set_font("", style) 387 | 388 | def set_text_color(self, r=None, g=0, b=0): 389 | if r is None: 390 | self.pdf.set_text_color(self.r, self.g, self.b) 391 | else: 392 | self.pdf.set_text_color(r, g, b) 393 | self.r = r 394 | self.g = g 395 | self.b = b 396 | 397 | def put_link(self, url, txt): 398 | # Put a hyperlink 399 | self.set_text_color(0, 0, 255) 400 | self.set_style("u", True) 401 | self.pdf.write(5, txt, url) 402 | self.set_style("u", False) 403 | self.set_text_color(0) 404 | 405 | def put_line(self): 406 | self.pdf.ln(2) 407 | self.pdf.line( 408 | self.pdf.get_x(), self.pdf.get_y(), self.pdf.get_x() + 187, self.pdf.get_y() 409 | ) 410 | self.pdf.ln(3) 411 | 412 | 413 | class HTMLMixin(object): 414 | def write_html(self, text, image_map=None): 415 | "Parse HTML and convert it to PDF" 416 | h2p = HTML2FPDF(self, image_map) 417 | text = h2p.unescape(text) # To deal with HTML entities 418 | h2p.feed(text) 419 | -------------------------------------------------------------------------------- /tests/slack_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "T12345678": { 3 | "auth_test": { 4 | "team": "test", 5 | "user_id": "U9234567X" 6 | }, 7 | "conversations_history": { 8 | "C12345678": [ 9 | { 10 | "text": "Hello user!\nhere we have some *bold* text, some _italic_ and some `code`", 11 | "ts": "1562274541.000800", 12 | "type": "message", 13 | "user": "U92345678" 14 | } 15 | ], 16 | "C92345678": [ 17 | { 18 | "text": "`code`", 19 | "ts": "1562274541.000800", 20 | "type": "message", 21 | "user": "U92345678" 22 | } 23 | ], 24 | "C72345678": [ 25 | { 26 | "text": "Message 1", 27 | "ts": "1562274541.000800", 28 | "type": "message", 29 | "user": "U92345678" 30 | }, 31 | { 32 | "text": "Message 2", 33 | "ts": "1562274542.000800", 34 | "type": "message", 35 | "user": "U92345678" 36 | }, 37 | { 38 | "text": "Message 3", 39 | "ts": "1562274543.000800", 40 | "type": "message", 41 | "user": "U92345678" 42 | }, 43 | { 44 | "text": "Message 4", 45 | "ts": "1562274544.000800", 46 | "type": "message", 47 | "user": "U92345678" 48 | }, 49 | { 50 | "text": "Message 5", 51 | "ts": "1562274545.000800", 52 | "type": "message", 53 | "user": "U92345678" 54 | } 55 | ], 56 | "C98345678": [ 57 | { 58 | "blocks": [ 59 | { 60 | "block_id": "QLA", 61 | "elements": [ 62 | { 63 | "elements": [ 64 | { 65 | "text": "I rarely/never see ", 66 | "type": "text" 67 | }, 68 | { 69 | "style": { 70 | "code": true 71 | }, 72 | "text": "pickup_offset=7", 73 | "type": "text" 74 | }, 75 | { 76 | "text": " successfully picking up a item. Usually takes needs to fully reset after to pick, so I figured less time-wasting trying to offset might fix that", 77 | "type": "text" 78 | } 79 | ], 80 | "type": "rich_text_section" 81 | } 82 | ], 83 | "type": "rich_text" 84 | } 85 | ], 86 | "client_msg_id": "DUMMY", 87 | "team": "T12345678", 88 | "text": "I rarely/never see `pickup_offset=7` successfully picking up a item. Usually takes needs to fully reset after to pick, so I figured less time-wasting trying to offset might fix that", 89 | "ts": "1614075414.168100", 90 | "type": "message", 91 | "user": "U12345678" 92 | } 93 | ], 94 | "G1234567X": [ 95 | { 96 | "team": "T12345678", 97 | "text": "*Simple messages*", 98 | "ts": "1515045940.000800", 99 | "type": "message", 100 | "user": "U92345678" 101 | }, 102 | { 103 | "team": "T12345678", 104 | "text": "Hello user!\nhere we have some *bold* text, some _italic_ and some `code`\n2nd line\n3rd line\n>indented text\nNormal text", 105 | "ts": "1515045941.000800", 106 | "type": "message", 107 | "user": "U92345678" 108 | }, 109 | { 110 | "bot_id": "B12345678", 111 | "subtype": "bot_message", 112 | "text": "\nHere we have a bot message\n", 113 | "ts": "1515045942.000110", 114 | "type": "message", 115 | "username": "magic-webhook" 116 | }, 117 | { 118 | "team": "T12345678", 119 | "text": "*Unicode text*:\nGreek: Γειά σου κόσμος\nPolish: Witaj świecie\nPortuguese: Olá mundo\nRussian: Здравствуй, Мир\nVietnamese: Xin chào thế giới\nArabic: مرحبا العالم\nHebrew: שלום עולם", 120 | "ts": "1515045944.000800", 121 | "type": "message", 122 | "user": "U92345678" 123 | }, 124 | { 125 | "team": "T12345678", 126 | "text": "*File uploads*", 127 | "ts": "1525302151.900800", 128 | "type": "message", 129 | "user": "U92345678" 130 | }, 131 | { 132 | "comment": { 133 | "comment": "Added by <@U92345678>", 134 | "created": 1515045945, 135 | "id": "F12345678", 136 | "is_intro": false, 137 | "timestamp": 1515045945, 138 | "user": "U92345678" 139 | }, 140 | "file": { 141 | "created": 1525302151, 142 | "display_as_bot": false, 143 | "edit_link": "https://test.slack.com/files/U92345678/F22345678/alliance_fleet_schedule___motd__beta_.txt/edit", 144 | "editable": true, 145 | "external_type": "", 146 | "filetype": "text", 147 | "has_rich_preview": false, 148 | "id": "F22345678", 149 | "is_external": false, 150 | "is_public": false, 151 | "is_starred": false, 152 | "lines": 27, 153 | "lines_more": 22, 154 | "mimetype": "text/plain", 155 | "mode": "snippet", 156 | "name": "Alliance_Fleet_Schedule___MOTD__beta_.txt", 157 | "permalink": "https://test.slack.com/files/U92345678/F22345678/alliance_fleet_schedule___motd__beta_.txt", 158 | "permalink_public": "https://slack-files.com/T12345678-F22345678-a621d88ebe", 159 | "pretty_type": "Plain Text", 160 | "preview": "Alliance Staging Akidagi - Mans Got Home \r\n\r\nSWEG. TS :\r\n------------\r\nIP: ts.dummy.org\r", 161 | "preview_highlight": "
\n
\n
Alliance Staging   Akidagi - Mans Got Home 
\n
\n
SWEG. TS :
\n
------------
\n
IP: ts.dummy.org
\n
\n
\n
\n", 162 | "preview_is_truncated": false, 163 | "public_url_shared": false, 164 | "size": 657, 165 | "timestamp": 1525302152, 166 | "title": "Alliance Fleet Schedule / MOTD (beta)", 167 | "url_private": "https://files.slack.com/files-pri/T12345678-F22345678/alliance_fleet_schedule___motd__beta_.txt", 168 | "url_private_download": "https://files.slack.com/files-pri/T12345678-F22345678/download/alliance_fleet_schedule___motd__beta_.txt", 169 | "user": "U92345678", 170 | "username": "" 171 | }, 172 | "files": [ 173 | { 174 | "created": 1525302151, 175 | "display_as_bot": false, 176 | "edit_link": "https://test.slack.com/files/U92345678/F22345678/alliance_fleet_schedule___motd__beta_.txt/edit", 177 | "editable": true, 178 | "external_type": "", 179 | "filetype": "text", 180 | "has_rich_preview": false, 181 | "id": "F22345678", 182 | "is_external": false, 183 | "is_public": false, 184 | "is_starred": false, 185 | "lines": 27, 186 | "lines_more": 22, 187 | "mimetype": "text/plain", 188 | "mode": "snippet", 189 | "name": "Alliance_Fleet_Schedule___MOTD__beta_.txt", 190 | "permalink": "https://test.slack.com/files/U92345678/F22345678/alliance_fleet_schedule___motd__beta_.txt", 191 | "permalink_public": "https://slack-files.com/T12345678-F22345678-a621d88ebe", 192 | "pretty_type": "Plain Text", 193 | "preview": "Alliance Staging Akidagi - Mans Got Home \r\n\r\nSWEG. TS :\r\n------------\r\nIP: ts.dummy.org\r", 194 | "preview_highlight": "
\n
\n
Alliance Staging   Akidagi - Mans Got Home 
\n
\n
SWEG. TS :
\n
------------
\n
IP: ts.dummy.org
\n
\n
\n
\n", 195 | "preview_is_truncated": false, 196 | "public_url_shared": false, 197 | "size": 657, 198 | "timestamp": 1525302151, 199 | "title": "Alliance Fleet Schedule / MOTD (beta)", 200 | "url_private": "https://files.slack.com/files-pri/T12345678-F22345678/alliance_fleet_schedule___motd__beta_.txt", 201 | "url_private_download": "https://files.slack.com/files-pri/T12345678-F22345678/download/alliance_fleet_schedule___motd__beta_.txt", 202 | "user": "U92345678", 203 | "username": "" 204 | } 205 | ], 206 | "is_intro": false, 207 | "subtype": "file_comment", 208 | "text": "<@U92345678> commented on <@U92345678>’s file : Added by <@U92345678>", 209 | "ts": "1525302154.000077", 210 | "type": "message" 211 | }, 212 | { 213 | "display_as_bot": false, 214 | "files": [ 215 | { 216 | "created": 1525302153, 217 | "display_as_bot": false, 218 | "editable": true, 219 | "editor": "U92345678", 220 | "external_type": "", 221 | "filetype": "space", 222 | "has_rich_preview": false, 223 | "id": "F8T2693JP", 224 | "is_external": false, 225 | "is_public": true, 226 | "is_starred": false, 227 | "last_editor": "U92345678", 228 | "mimetype": "text/plain", 229 | "mode": "space", 230 | "name": "-", 231 | "permalink": "https://test.slack.com/files/U92345678/F8T2693JP/-", 232 | "permalink_public": "https://slack-files.com/T12345678-F8T2693JP-64641a2f94", 233 | "pretty_type": "Post", 234 | "preview": "

This is a test file! Hyperlink: https://www.google.de/

", 235 | "public_url_shared": false, 236 | "size": 58, 237 | "state": "locked", 238 | "timestamp": 1525302153, 239 | "title": "Untitled", 240 | "updated": 1525302153, 241 | "url_private": "https://files.slack.com/files-pri/T12345678-F8T2693JP/-", 242 | "url_private_download": "https://files.slack.com/files-pri/T12345678-F8T2693JP/download/-", 243 | "user": "U92345678", 244 | "username": "" 245 | } 246 | ], 247 | "text": "", 248 | "ts": "1525302153.000033", 249 | "type": "message", 250 | "upload": true, 251 | "user": "U92345678" 252 | }, 253 | { 254 | "display_as_bot": false, 255 | "files": [ 256 | { 257 | "created": 1525312153, 258 | "display_as_bot": false, 259 | "editable": true, 260 | "editor": "U92345678", 261 | "external_type": "", 262 | "filetype": "space", 263 | "has_rich_preview": false, 264 | "id": "F8T2693JP", 265 | "is_external": false, 266 | "is_public": true, 267 | "is_starred": false, 268 | "last_editor": "U92345678", 269 | "mimetype": "text/plain", 270 | "mode": "space", 271 | "name": "-", 272 | "permalink": "https://test.slack.com/files/U92345678/F8T2693JP/-", 273 | "permalink_public": "https://slack-files.com/T12345678-F8T2693JP-64641a2f94", 274 | "preview": "

Test file without pretty_type. Hyperlink: https://www.google.de/

", 275 | "public_url_shared": false, 276 | "size": 58, 277 | "state": "locked", 278 | "timestamp": 1525302153, 279 | "title": "Untitled", 280 | "updated": 1525302153, 281 | "url_private": "https://files.slack.com/files-pri/T12345678-F8T2693JP/-", 282 | "url_private_download": "https://files.slack.com/files-pri/T12345678-F8T2693JP/download/-", 283 | "user": "U92345678", 284 | "username": "" 285 | } 286 | ], 287 | "text": "", 288 | "ts": "1525302153.000033", 289 | "type": "message", 290 | "upload": true, 291 | "user": "U92345678" 292 | }, 293 | { 294 | "team": "T12345678", 295 | "text": "*Attachments*", 296 | "ts": "1537212919.000800", 297 | "type": "message", 298 | "user": "U92345678" 299 | }, 300 | { 301 | "attachments": [ 302 | { 303 | "author_name": "Luke Skywalker", 304 | "fallback": "Vivamus et vestibulum lacus, non fermentum velit. Aenean ullamcorper, ipsum et pulvinar hendrerit, enim lectus luctus metus, ut molestie ligula diam eu nunc. Vestibulum fringilla vulputate nisl auctor iaculis. Curabitur sagittis lacus eget purus posuere viverra. Fusce et quam augue. Nam at mauris felis. Sed eu vestibulum lectus. Vestibulum non nisi vehicula, accumsan metus eu, volutpat mauris. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec laoreet erat et mi egestas vulputate. Nam sit amet ex quis diam congue tempus. Cras aliquam ex quam, in pharetra justo vehicula et. Proin metus ligula, sodales non eros semper, dapibus placerat lacus. Proin feugiat mi condimentum varius viverra. Nunc sed lectus lobortis, ornare lacus sed, congue dui. ", 305 | "fields": [ 306 | { 307 | "short": true, 308 | "title": "Project", 309 | "value": "Awesome Project" 310 | }, 311 | { 312 | "short": true, 313 | "title": "Environment", 314 | "value": "production" 315 | } 316 | ], 317 | "footer": "This is the footer", 318 | "id": 1, 319 | "image_bytes": 55308, 320 | "image_height": 453, 321 | "image_url": "https://i.imgur.com/L8uCjRO.png", 322 | "image_width": 619, 323 | "pretext": "this is pretext", 324 | "text": "Vivamus et vestibulum lacus, non fermentum velit. Aenean ullamcorper, ipsum et pulvinar hendrerit, enim lectus luctus metus, ut molestie ligula diam eu nunc. Vestibulum fringilla vulputate nisl auctor iaculis. Curabitur sagittis lacus eget purus posuere viverra. Fusce et quam augue. Nam at mauris felis. Sed eu vestibulum lectus. Vestibulum non nisi vehicula, accumsan metus eu, volutpat mauris. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec laoreet erat et mi egestas vulputate. Nam sit amet ex quis diam congue tempus. Cras aliquam ex quam, in pharetra justo vehicula et. Proin metus ligula, sodales non eros semper, dapibus placerat lacus. Proin feugiat mi condimentum varius viverra. Nunc sed lectus lobortis, ornare lacus sed, congue dui. ", 325 | "title": "This is the title with a link", 326 | "title_link": "https://www.google.com", 327 | "ts": 1562165714 328 | }, 329 | { 330 | "fallback": "Vivamus et vestibulum lacus, non fermentum velit. Aenean ullamcorper, ipsum et pulvinar hendrerit, enim lectus luctus metus, ut molestie ligula diam eu nunc. Vestibulum fringilla vulputate nisl auctor iaculis. Curabitur sagittis lacus eget purus posuere viverra. Fusce et quam augue. Nam at mauris felis. Sed eu vestibulum lectus. Vestibulum non nisi vehicula, accumsan metus eu, volutpat mauris. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec laoreet erat et mi egestas vulputate. Nam sit amet ex quis diam congue tempus. Cras aliquam ex quam, in pharetra justo vehicula et. Proin metus ligula, sodales non eros semper, dapibus placerat lacus. Proin feugiat mi condimentum varius viverra. Nunc sed lectus lobortis, ornare lacus sed, congue dui. ", 331 | "id": 2, 332 | "text": "Vivamus et vestibulum lacus, non fermentum velit. Aenean ullamcorper, ipsum et pulvinar hendrerit, enim lectus luctus metus, ut molestie ligula diam eu nunc. Vestibulum fringilla vulputate nisl auctor iaculis. Curabitur sagittis lacus eget purus posuere viverra. Fusce et quam augue. Nam at mauris felis. Sed eu vestibulum lectus. Vestibulum non nisi vehicula, accumsan metus eu, volutpat mauris. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec laoreet erat et mi egestas vulputate. Nam sit amet ex quis diam congue tempus. Cras aliquam ex quam, in pharetra justo vehicula et. Proin metus ligula, sodales non eros semper, dapibus placerat lacus. Proin feugiat mi condimentum varius viverra. Nunc sed lectus lobortis, ornare lacus sed, congue dui. ", 333 | "title": " in has entered reinforced mode" 334 | }, 335 | { 336 | "fallback": "dummy", 337 | "text": "Click on one of the buttons below", 338 | "actions": [ 339 | { 340 | "type": "button", 341 | "text": "Book flights", 342 | "url": "https://flights.example.com/book/r123456", 343 | "style": "primary" 344 | }, 345 | { 346 | "type": "button", 347 | "text": "Cancel travel request", 348 | "url": "https://requests.example.com/cancel/r123456", 349 | "style": "danger" 350 | } 351 | ] 352 | } 353 | ], 354 | "bot_id": "B12345678", 355 | "subtype": "bot_message", 356 | "text": "How are you doing", 357 | "ts": "1537212980.000300", 358 | "type": "message", 359 | "username": "magic-webhook" 360 | }, 361 | { 362 | "bot_id": "B12345678", 363 | "subtype": "bot_message", 364 | "text": "How are you doing now ??", 365 | "ts": "1537212981.000300", 366 | "type": "message", 367 | "username": "magic-webhook", 368 | "attachments": [ 369 | { 370 | "text": "dummy text" 371 | }, 372 | { 373 | "text": "dummy text" 374 | } 375 | ] 376 | }, 377 | { 378 | "attachments": [ 379 | { 380 | "color": "6ecadc", 381 | "fallback": "Open Slack to cast your vote in this Simple Poll", 382 | "fields": [ 383 | { 384 | "short": false, 385 | "title": "", 386 | "value": ":one: `1`\n<@UBBKA171Q>\n\n" 387 | }, 388 | { 389 | "short": false, 390 | "title": "", 391 | "value": ":two: `1`\n<@U9PTYC16J>\n\n" 392 | } 393 | ], 394 | "id": 1, 395 | "mrkdwn_in": [ 396 | "fields" 397 | ], 398 | "title": "Please make a choice... " 399 | }, 400 | { 401 | "actions": [ 402 | { 403 | "id": "1", 404 | "name": "vote", 405 | "style": "", 406 | "text": ":one:", 407 | "type": "button", 408 | "value": "1" 409 | }, 410 | { 411 | "id": "2", 412 | "name": "vote", 413 | "style": "", 414 | "text": ":two:", 415 | "type": "button", 416 | "value": "2" 417 | }, 418 | { 419 | "confirm": { 420 | "dismiss_text": "No", 421 | "ok_text": "Yes", 422 | "text": "Are you sure you want to delete the Poll?", 423 | "title": "Delete Poll?" 424 | }, 425 | "id": "3", 426 | "name": "delete-v2", 427 | "style": "danger", 428 | "text": "Delete Poll", 429 | "type": "button", 430 | "value": "" 431 | } 432 | ], 433 | "callback_id": "e8922472-6e30-462c-9af3-e930917dfb23", 434 | "color": "6ecadc", 435 | "fallback": "Open Slack to cast your vote in this Simple Poll", 436 | "id": 2 437 | }, 438 | { 439 | "callback_id": "e8922472-6e30-462c-9af3-e930917dfb23", 440 | "color": "6ecadc", 441 | "fallback": "Open Slack to cast your vote in this Simple Poll", 442 | "footer": "Simple Poll ", 443 | "footer_icon": "https://simplepoll.rocks/static/main/favicon.png", 444 | "id": 3 445 | } 446 | ], 447 | "bot_id": "BCKP2TSN6", 448 | "subtype": "bot_message", 449 | "text": "", 450 | "ts": "1537212982.000100", 451 | "type": "message", 452 | "username": "Simple Poll" 453 | }, 454 | { 455 | "team": "T12345678", 456 | "text": "*Threads*", 457 | "ts": "1561764001.000800", 458 | "type": "message", 459 | "user": "U92345678" 460 | }, 461 | { 462 | "last_read": "1562171321.000100", 463 | "latest_reply": "1562171321.000100", 464 | "replies": [ 465 | { 466 | "ts": "1562171321.000100", 467 | "user": "U92345678" 468 | } 469 | ], 470 | "reply_count": 1, 471 | "reply_users": [ 472 | "U92345678" 473 | ], 474 | "reply_users_count": 1, 475 | "subscribed": true, 476 | "team": "T12345678", 477 | "text": "This message *does* start a thread", 478 | "thread_ts": "1561764011.015500", 479 | "ts": "1561764011.015500", 480 | "type": "message", 481 | "user": "U82345678" 482 | }, 483 | { 484 | "team": "T12345678", 485 | "text": "This message does *not* start a thread", 486 | "ts": "1561764015.000800", 487 | "type": "message", 488 | "user": "U92345678" 489 | }, 490 | { 491 | "team": "T12345678", 492 | "text": "*Blocks*", 493 | "ts": "1562274542.000800", 494 | "type": "message", 495 | "user": "U92345678" 496 | }, 497 | { 498 | "team": "T12345678", 499 | "text": "Normal user\nhere we have some *bold* text", 500 | "ts": "1562274543.000800", 501 | "type": "message", 502 | "user": "U92345678", 503 | "blocks": [ 504 | { 505 | "type": "section", 506 | "text": { 507 | "type": "mrkdwn", 508 | "text": "A message *with some bold text* and _some italicized text_." 509 | } 510 | }, 511 | { 512 | "type": "section", 513 | "text": { 514 | "type": "mrkdwn", 515 | "text": "A message *with some bold text* and _some italicized text_." 516 | }, 517 | "fields": [ 518 | { 519 | "type": "mrkdwn", 520 | "text": "*Priority*" 521 | }, 522 | { 523 | "type": "mrkdwn", 524 | "text": "*Type*" 525 | }, 526 | { 527 | "type": "plain_text", 528 | "text": "High" 529 | }, 530 | { 531 | "type": "plain_text", 532 | "text": "String" 533 | } 534 | ] 535 | } 536 | ] 537 | } 538 | ] 539 | }, 540 | "conversations_replies": { 541 | "G1234567X": { 542 | "1561764011.015500": [ 543 | { 544 | "last_read": "1562171321.000100", 545 | "latest_reply": "1562171321.000100", 546 | "replies": [ 547 | { 548 | "ts": "1562171321.000100", 549 | "user": "U92345678" 550 | } 551 | ], 552 | "reply_count": 1, 553 | "reply_users": [ 554 | "U92345678" 555 | ], 556 | "reply_users_count": 1, 557 | "subscribed": true, 558 | "team": "T12345678", 559 | "text": "This message *does* start a thread", 560 | "thread_ts": "1561764011.015500", 561 | "ts": "1561764011.015500", 562 | "type": "message", 563 | "user": "U82345678" 564 | }, 565 | { 566 | "parent_user_id": "U82345678", 567 | "team": "T12345678", 568 | "text": "This is a reply within a thread", 569 | "thread_ts": "1561764011.015500", 570 | "ts": "1562171321.000100", 571 | "type": "message", 572 | "user": "U92345678" 573 | }, 574 | { 575 | "parent_user_id": "U82345678", 576 | "team": "T12345678", 577 | "text": "Another reply", 578 | "thread_ts": "1561764011.015500", 579 | "ts": "1562171322.000100", 580 | "type": "message", 581 | "user": "U92345678" 582 | }, 583 | { 584 | "parent_user_id": "U82345678", 585 | "team": "T12345678", 586 | "text": "Another reply", 587 | "thread_ts": "1561764011.015500", 588 | "ts": "1562171323.000100", 589 | "type": "message", 590 | "user": "U92345678" 591 | }, 592 | { 593 | "parent_user_id": "U82345678", 594 | "team": "T12345678", 595 | "text": "Another reply", 596 | "thread_ts": "1561764011.015500", 597 | "ts": "1562171324.000100", 598 | "type": "message", 599 | "user": "U92345678" 600 | } 601 | ] 602 | } 603 | }, 604 | "conversations_list": { 605 | "channels": [ 606 | { 607 | "id": "C12345678", 608 | "name": "berlin" 609 | }, 610 | { 611 | "id": "C72345678", 612 | "name": "london" 613 | }, 614 | { 615 | "id": "C42345678", 616 | "name": "oslo" 617 | }, 618 | { 619 | "id": "C92345678", 620 | "name": "moscow" 621 | }, 622 | { 623 | "id": "G1234567X", 624 | "name": "bangkok" 625 | }, 626 | { 627 | "id": "G2234567X", 628 | "name": "tokyo" 629 | } 630 | ] 631 | }, 632 | "users_list": { 633 | "members": [ 634 | { 635 | "id": "U12345678", 636 | "name": "Naoko Kobayashi", 637 | "tz": "UTC", 638 | "locale": "en-US" 639 | }, 640 | { 641 | "id": "U62345678", 642 | "name": "Janet Hakuli", 643 | "tz": "UTC", 644 | "locale": "en-US" 645 | }, 646 | { 647 | "id": "U72345678", 648 | "name": "Yuna Kobayashi", 649 | "tz": "UTC", 650 | "locale": "en-US" 651 | }, 652 | { 653 | "id": "U9234567X", 654 | "name": "Erik Kalkoken", 655 | "tz": "UTC", 656 | "locale": "en-US" 657 | }, 658 | { 659 | "id": "U92345678", 660 | "name": "Rosie Dunbar", 661 | "tz": "UTC", 662 | "locale": "en-US" 663 | } 664 | ] 665 | }, 666 | "usergroups_list": { 667 | "usergroups": [ 668 | { 669 | "id": "S12345678", 670 | "handle": "admins" 671 | }, 672 | { 673 | "id": "S72345678", 674 | "handle": "marketing" 675 | }, 676 | { 677 | "id": "S42345678", 678 | "handle": "sales" 679 | } 680 | ] 681 | } 682 | }, 683 | "T92345678": { 684 | "auth_test": { 685 | "team": "alpha<>:\"/\\|?*beta", 686 | "user_id": "U12345678" 687 | }, 688 | "conversations_history": { 689 | "C12345678": [ 690 | { 691 | "text": "Hello user!\nhere we have some *bold* text, some _italic_ and some `code`", 692 | "ts": "1562274541.000800", 693 | "type": "message", 694 | "user": "U12345678" 695 | } 696 | ] 697 | }, 698 | "conversations_replies": {}, 699 | "conversations_list": { 700 | "channels": [ 701 | { 702 | "id": "C12345678", 703 | "name": "berlin" 704 | } 705 | ] 706 | }, 707 | "users_list": { 708 | "members": [ 709 | { 710 | "id": "U12345678", 711 | "name": "Naoko Kobayashi", 712 | "tz": "UTC", 713 | "locale": "en-US" 714 | } 715 | ] 716 | }, 717 | "usergroups_list": { 718 | "usergroups": [] 719 | } 720 | } 721 | } 722 | --------------------------------------------------------------------------------