├── setup.cfg ├── MANIFEST.in ├── pyproject.toml ├── test_deslackify.py ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── CHANGES.md ├── README.md ├── LICENSE.txt ├── setup.py └── deslackify └── __init__.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt README.md 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | -------------------------------------------------------------------------------- /test_deslackify.py: -------------------------------------------------------------------------------- 1 | import deslackify 2 | 3 | 4 | def test_version(): 5 | assert deslackify.__version__ 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info/ 3 | *.eggs/ 4 | *.pyc 5 | *~ 6 | .coverage 7 | _build/ 8 | build/ 9 | dist/ 10 | pip-wheel-metadata/ 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.7 3 | fail_fast: true 4 | repos: 5 | - hooks: 6 | - id: black 7 | repo: https://github.com/ambv/black 8 | rev: stable 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | cache: pip 2 | dist: xenial 3 | install: pip install .[test] 4 | jobs: 5 | include: 6 | - install: pip install .[lint] 7 | python: 3.7 8 | script: 9 | - black --check --verbose *.py deslackify 10 | - flake8 --exclude=.eggs,build,docs 11 | stage: lint 12 | language: python 13 | python: 14 | - 3.7 15 | - 2.7 16 | script: pytest 17 | stages: 18 | - lint 19 | - test 20 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.0] - 2019/02/24 4 | 5 | ### Added 6 | 7 | - Dynamically support most `slacker.Error` messages now. 8 | - Log `RetryException` when requests don't complete after a few retries. 9 | 10 | ### Changed 11 | 12 | - Logging output now includes the log level 13 | 14 | ## [0.4.0] - 2019/02/24 15 | 16 | ### Added 17 | 18 | - `--after` parameter to help narrow down search results. 19 | - `--dry-run` parameter to test prior to deletion. 20 | 21 | ## [0.2.0] - 2019/02/23 22 | 23 | ### Added 24 | 25 | - Support python 2.7. 26 | 27 | ## [0.1.2] - 2019/02/23 28 | 29 | ### Added 30 | 31 | - Everything! This is the initial release. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deslackify 2 | 3 | A tool to remove your own slack messages. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | pip install deslackify 9 | ``` 10 | 11 | ## Obtaining Slack Legacy Token 12 | 13 | Generate a token via: https://api.slack.com/custom-integrations/legacy-tokens 14 | 15 | ## Running 16 | 17 | ```sh 18 | deslackify --token TOKEN USERNAME 19 | ``` 20 | 21 | By default `deslackify` will remove USERNAME's messages that are more than a 22 | year old. You may also manually specify a `before` date via: 23 | 24 | ```sh 25 | deslackify --token TOKEN --before YYYY-MM-DD USERNAME 26 | ``` 27 | 28 | __Note__: If no results are found, you may need to replace `USERNAME` with 29 | `USERID`. You can find the `USERID` by going to `Profile & Account`, and then 30 | clicking the three dots, and at the bottom, "Copy member ID". -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Bryce Boe 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """deslackify setup.py.""" 2 | import re 3 | from codecs import open 4 | from os import path 5 | from setuptools import setup 6 | 7 | 8 | PACKAGE_NAME = "deslackify" 9 | HERE = path.abspath(path.dirname(__file__)) 10 | with open(path.join(HERE, "README.md"), encoding="utf-8") as fp: 11 | README = fp.read() 12 | with open( 13 | path.join(HERE, PACKAGE_NAME, "__init__.py"), encoding="utf-8" 14 | ) as fp: 15 | VERSION = re.search('__version__ = "([^"]+)"', fp.read()).group(1) 16 | 17 | 18 | extras_require = { 19 | "dev": ["pre-commit"], 20 | "lint": ["black", "flake8"], 21 | "test": ["pytest"], 22 | } 23 | extras_require["dev"] = sorted(set(sum(extras_require.values(), []))) 24 | 25 | 26 | setup( 27 | name=PACKAGE_NAME, 28 | author="Bryce Boe", 29 | author_email="bbzbryce@gmail.com", 30 | classifiers=[ 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: BSD License", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | ], 36 | description="A program to delete old slack messages.", 37 | entry_points={"console_scripts": ["deslackify = deslackify:main"]}, 38 | extras_require=extras_require, 39 | install_requires=["slacker >=0.12, <0.13"], 40 | keywords="slack", 41 | license="Simplified BSD License", 42 | long_description=README, 43 | long_description_content_type="text/markdown", 44 | packages=[PACKAGE_NAME], 45 | url="https://github.com/bboe/deslackify", 46 | version=VERSION, 47 | ) 48 | -------------------------------------------------------------------------------- /deslackify/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import sys 5 | import time 6 | from collections import Counter 7 | from datetime import date, datetime 8 | 9 | import slacker 10 | from requests import HTTPError, ReadTimeout, Session 11 | 12 | 13 | __version__ = "0.5.0" 14 | 15 | 16 | logging.basicConfig( 17 | format="%(asctime)-15s %(levelname)-8s %(message)s", level=logging.INFO 18 | ) 19 | 20 | 21 | class RetryException(Exception): 22 | pass 23 | 24 | 25 | def delete_message(slack, message, update_first=False): 26 | if update_first: 27 | handle_rate_limit( 28 | slack.chat.update, 29 | as_user=True, 30 | channel=message["channel"]["id"], 31 | text="-", 32 | ts=message["ts"], 33 | ) 34 | handle_rate_limit( 35 | slack.chat.delete, 36 | as_user=True, 37 | channel=message["channel"]["id"], 38 | ts=message["ts"], 39 | ) 40 | 41 | 42 | def handle_encoding(message): 43 | if sys.version_info > (3, 0): 44 | return message 45 | return message.encode("utf-8") 46 | 47 | 48 | def handle_rate_limit(method, *args, **kwargs): 49 | count = 5 50 | while count > 0: 51 | try: 52 | response = method(*args, **kwargs) 53 | assert response.successful 54 | assert response.body["ok"] 55 | return response 56 | except HTTPError as exception: 57 | if exception.response.status_code == 429: 58 | retry_time = int(exception.response.headers["retry-after"]) 59 | retry_time = min(3, retry_time) 60 | if retry_time > 3: 61 | logging.info("Sleeping for {} seconds".format(retry_time)) 62 | time.sleep(retry_time) 63 | else: 64 | raise 65 | except ReadTimeout: 66 | logging.info("Read timeout. Sleeping for 16 seconds") 67 | time.sleep(16) 68 | count -= 1 69 | raise RetryException 70 | 71 | 72 | def main(): 73 | datetime = date.today() 74 | try: 75 | datetime = datetime.replace(year=datetime.year - 1) 76 | except ValueError: 77 | datetime = datetime.replace( 78 | year=datetime.year - 1, day=datetime.day - 1 79 | ) 80 | default_before = datetime.strftime("%Y-%m-%d") 81 | 82 | parser = argparse.ArgumentParser( 83 | description="Delete slack messages by specified user", 84 | usage="%(prog)s [options] user", 85 | ) 86 | parser.add_argument("user", help="Delete messages from this user") 87 | parser.add_argument( 88 | "--after", 89 | help=( 90 | "Date (YYYY-MM-DD) to delete messages after " 91 | "(default: no restriction)" 92 | ), 93 | ) 94 | parser.add_argument( 95 | "--before", 96 | default=default_before, 97 | help="Date to delete messages prior to (default: %(default)s)", 98 | ) 99 | parser.add_argument( 100 | "--dry-run", 101 | action="store_true", 102 | help="Do not actually delete nor update (default: False)", 103 | ) 104 | parser.add_argument( 105 | "--token", 106 | help=( 107 | "The token used to connect to slack. This value can also be passed via" # noqa: E501 108 | "the SLACK_TOKEN environment variable." 109 | ), 110 | ) 111 | parser.add_argument( 112 | "--update", 113 | action="store_true", 114 | help="Update message to `-` prior to deleting (default: False)", 115 | ) 116 | parser.add_argument( 117 | "--version", 118 | action="version", 119 | version="%(prog)s {}".format(__version__), 120 | ) 121 | args = parser.parse_args() 122 | 123 | if args.after and args.after >= args.before: 124 | sys.stderr.write( 125 | "The --after value must be older than the --before value\n" 126 | ) 127 | return 1 128 | 129 | token = args.token or os.getenv("SLACK_TOKEN") 130 | if not token: 131 | sys.stderr.write( 132 | "Either the argument --token or the environment " 133 | "variable SLACK_TOKEN must be provided\n" 134 | ) 135 | return 1 136 | 137 | with Session() as session: 138 | slack = slacker.Slacker(token, session=session) 139 | return run(slack, args) 140 | 141 | 142 | def run(slack, args): 143 | deleted = 0 144 | errors = Counter() 145 | 146 | try: 147 | for message in search_messages( 148 | slack, args.user, after=args.after, before=args.before 149 | ): 150 | date_string = datetime.utcfromtimestamp( 151 | int(message["ts"].split(".", 1)[0]) 152 | ).strftime("%Y-%m-%d %H:%M:%S") 153 | logging.info( 154 | "{} {}".format(date_string, handle_encoding(message["text"])) 155 | ) 156 | try: 157 | if not args.dry_run: 158 | delete_message(slack, message, args.update) 159 | except RetryException: 160 | errors["max retries exceeded"] += 1 161 | logging.warning("RetryException") 162 | except slacker.Error as exception: 163 | if len(exception.args) == 1: 164 | errors[exception.args[0]] += 1 165 | logging.warning(exception.args[0]) 166 | continue 167 | raise 168 | deleted += 1 169 | except KeyboardInterrupt: 170 | pass 171 | 172 | phrase = "to delete" if args.dry_run else "deleted" 173 | logging.info("Messages {}: {}".format(phrase, deleted)) 174 | for error, count in sorted(errors.items()): 175 | logging.info("{} errors: {}".format(error, count)) 176 | return 0 177 | 178 | 179 | def search_messages(slack, user, after, before): 180 | query = "from:{}".format(user) 181 | if after: 182 | query += " after:{}".format(after) 183 | if before: 184 | query += " before:{}".format(before) 185 | 186 | # Determine the number of pages 187 | search_params = { 188 | "count": 100, 189 | "query": query, 190 | "sort": "timestamp", 191 | "sort_dir": "desc", 192 | } 193 | response = handle_rate_limit( 194 | slack.search.messages, page=1, **search_params 195 | ) 196 | search_result = response.body["messages"] 197 | page = search_result["paging"]["pages"] 198 | logging.info("Found {} items".format(search_result["total"])) 199 | 200 | while page > 0: 201 | response = handle_rate_limit( 202 | slack.search.messages, page=page, **search_params 203 | ) 204 | search_result = response.body["messages"] 205 | 206 | for message in sorted(search_result["matches"], key=lambda x: x["ts"]): 207 | yield message 208 | page -= 1 209 | --------------------------------------------------------------------------------