├── MANIFEST.in ├── requirements.txt ├── setup.cfg ├── slack_cleaner ├── __main__.py ├── __init__.py ├── utils.py ├── args.py └── cli.py ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── question.md │ └── bug_report.md ├── .gitignore ├── Dockerfile ├── .editorconfig ├── LICENSE ├── setup.py ├── .gitattributes ├── tasks.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | slacker>=0.13.0 2 | colorama 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /slack_cleaner/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sgratzl] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | *.pyc 3 | *.egg-info/ 4 | .idea 5 | /internal 6 | .mypy_cache 7 | .vscode 8 | Pipfile 9 | .pytest_cache 10 | /build 11 | -------------------------------------------------------------------------------- /slack_cleaner/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Lin, Ke-fei, Samuel Gratzl' 2 | __authoremail__ = 'kfei@kfei.net, sam@sgratzl.com' 3 | __version__ = '0.7.4' 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | # contact_links: 3 | # - name: Samuel Gratzl 4 | # url: https://www.sgratzl.com 5 | # about: Please ask and answer questions here. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | LABEL maintainer="Samuel Gratzl " 4 | 5 | VOLUME ["/backup"] 6 | WORKDIR /backup 7 | ENTRYPOINT ["/bin/bash"] 8 | 9 | RUN apk add --update bash && rm -rf /var/cache/apk/* 10 | # for better layers 11 | RUN pip install slacker colorama 12 | 13 | ADD . /data 14 | RUN pip install -r /data/requirements.txt 15 | RUN pip install /data 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | 10 | It would be great if ... 11 | 12 | **User story** 13 | 14 | 15 | **Additional context** 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Question 3 | about: ask question about the library (usage, features,...) 4 | title: '' 5 | labels: 'question' 6 | assignees: '' 7 | --- 8 | 9 | 13 | I'm having the following question... 14 | 15 | **Screenshots / Sketches** 16 | 17 | 18 | **Context** 19 | 20 | - Version: 21 | - Python Version: 22 | 23 | **Additional context** 24 | 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: If something isn't working as expected 🤔. 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | 10 | When I... 11 | 12 | **To Reproduce** 13 | 15 | 1. 16 | 17 | **Expected behavior** 18 | 19 | 20 | **Screenshots** 21 | 22 | 23 | **Context** 24 | 25 | - Version: 26 | - Python Version: 27 | 28 | **Additional context** 29 | 30 | -------------------------------------------------------------------------------- /slack_cleaner/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | from colorama import init, Fore 5 | 6 | 7 | init() 8 | 9 | 10 | class Colors(): 11 | BLUE = Fore.BLUE 12 | GREEN = Fore.GREEN 13 | YELLOW = Fore.YELLOW 14 | RED = Fore.RED 15 | ENDC = Fore.RESET 16 | 17 | 18 | class TimeRange(): 19 | def __init__(self, start_time, end_time): 20 | def parse_ts(t): 21 | try: 22 | if len(t) == 8: 23 | return time.mktime(time.strptime(t, "%Y%m%d")) 24 | else: 25 | return time.mktime(time.strptime(t, "%Y%m%d%H%M")) 26 | except: 27 | return '0' 28 | 29 | self.start_ts = parse_ts(start_time) 30 | # Ensure we have the end time since slack will return in different way 31 | # if no end time supplied 32 | self.end_ts = parse_ts(end_time) 33 | if self.end_ts == '0': 34 | self.end_ts = str(time.time()) 35 | 36 | 37 | class Counter(): 38 | def __init__(self): 39 | self.total = 0 40 | 41 | def increase(self): 42 | self.total += 1 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Lin, Ke-fei (kfei). http://kfei.net 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | 4 | pkg_file = open("slack_cleaner/__init__.py").read() 5 | metadata = dict(re.findall("__([a-z]+)__\s*=\s*'([^']+)'", pkg_file)) 6 | description = open('README.md').read() 7 | 8 | from setuptools import setup, find_packages 9 | 10 | install_requires = [] 11 | 12 | setup( 13 | name='slack-cleaner', 14 | description='Bulk delete messages/files on Slack.', 15 | packages=find_packages(), 16 | author=metadata['author'], 17 | author_email=metadata['authoremail'], 18 | version=metadata['version'], 19 | url='https://github.com/sgratzl/slack-cleaner', 20 | license="MIT", 21 | keywords="slack, clean, delete, message, file", 22 | long_description=description, 23 | long_description_content_type="text/markdown", 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | ], 31 | 32 | install_requires=[ 33 | 'setuptools', 34 | 'slacker>=0.13', 35 | 'colorama', 36 | ] + install_requires, 37 | 38 | entry_points={ 39 | 'console_scripts': [ 40 | 'slack-cleaner = slack_cleaner.cli:main' 41 | ] 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # These settings are for any web project 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text 23 | *.ts text 24 | *.coffee text 25 | *.json text 26 | *.htm text 27 | *.html text 28 | *.xml text 29 | *.txt text 30 | *.ini text 31 | *.inc text 32 | *.pl text 33 | *.rb text 34 | *.py text 35 | *.scm text 36 | *.sql text 37 | *.sh text eof=LF 38 | *.bat text 39 | 40 | # templates 41 | *.hbt text 42 | *.jade text 43 | *.haml text 44 | *.hbs text 45 | *.dot text 46 | *.tmpl text 47 | *.phtml text 48 | 49 | # server config 50 | .htaccess text 51 | 52 | # git config 53 | .gitattributes text 54 | .gitignore text 55 | 56 | # code analysis config 57 | .jshintrc text 58 | .jscsrc text 59 | .jshintignore text 60 | .csslintrc text 61 | 62 | # misc config 63 | *.yaml text 64 | *.yml text 65 | .editorconfig text 66 | 67 | # build config 68 | *.npmignore text 69 | *.bowerrc text 70 | Dockerfile text eof=LF 71 | 72 | # Heroku 73 | Procfile text 74 | .slugignore text 75 | 76 | # Documentation 77 | *.md text 78 | LICENSE text 79 | AUTHORS text 80 | 81 | 82 | # 83 | ## These files are binary and should be left untouched 84 | # 85 | 86 | # (binary is a macro for -text -diff) 87 | *.png binary 88 | *.jpg binary 89 | *.jpeg binary 90 | *.gif binary 91 | *.ico binary 92 | *.mov binary 93 | *.mp4 binary 94 | *.mp3 binary 95 | *.flv binary 96 | *.fla binary 97 | *.swf binary 98 | *.gz binary 99 | *.zip binary 100 | *.7z binary 101 | *.ttf binary 102 | *.pyc binary 103 | *.pdf binary 104 | 105 | # Source files 106 | # ============ 107 | *.pxd text 108 | *.py text 109 | *.py3 text 110 | *.pyw text 111 | *.pyx text 112 | *.sh text eol=lf 113 | *.json text 114 | 115 | # Binary files 116 | # ============ 117 | *.db binary 118 | *.p binary 119 | *.pkl binary 120 | *.pyc binary 121 | *.pyd binary 122 | *.pyo binary 123 | 124 | # Note: .db, .p, and .pkl files are associated 125 | # with the python modules ``pickle``, ``dbm.*``, 126 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 127 | # (among others). 128 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tasks for maintaining the project. 3 | 4 | Execute 'invoke --list' for guidance on using Invoke 5 | """ 6 | import shutil 7 | import platform 8 | 9 | from invoke import task 10 | 11 | try: 12 | from pathlib import Path 13 | 14 | Path().expanduser() 15 | except (ImportError, AttributeError): 16 | from pathlib2 import Path 17 | import webbrowser 18 | 19 | 20 | ROOT_DIR = Path(__file__).parent 21 | SETUP_FILE = ROOT_DIR.joinpath("setup.py") 22 | TEST_DIR = ROOT_DIR.joinpath("tests") 23 | SOURCE_DIR = ROOT_DIR.joinpath("slack_cleaner") 24 | TOX_DIR = ROOT_DIR.joinpath(".tox") 25 | COVERAGE_FILE = ROOT_DIR.joinpath(".coverage") 26 | COVERAGE_DIR = ROOT_DIR.joinpath("htmlcov") 27 | COVERAGE_REPORT = COVERAGE_DIR.joinpath("index.html") 28 | DOCS_DIR = ROOT_DIR.joinpath("docs") 29 | DOCS_BUILD_DIR = DOCS_DIR.joinpath("_build") 30 | DOCS_INDEX = DOCS_BUILD_DIR.joinpath("index.html") 31 | PYTHON_DIRS = [str(d) for d in [SOURCE_DIR, TEST_DIR]] 32 | 33 | 34 | def _delete_file(file): 35 | try: 36 | file.unlink(missing_ok=True) 37 | except TypeError: 38 | # missing_ok argument added in 3.8 39 | try: 40 | file.unlink() 41 | except FileNotFoundError: 42 | pass 43 | 44 | @task 45 | def clean_build(c): 46 | """ 47 | Clean up files from package building 48 | """ 49 | c.run("rm -fr build/") 50 | c.run("rm -fr dist/") 51 | c.run("rm -fr .eggs/") 52 | c.run("find . -name '*.egg-info' -exec rm -fr {} +") 53 | c.run("find . -name '*.egg' -exec rm -f {} +") 54 | 55 | 56 | @task 57 | def clean_python(c): 58 | """ 59 | Clean up python file artifacts 60 | """ 61 | c.run("find . -name '*.pyc' -exec rm -f {} +") 62 | c.run("find . -name '*.pyo' -exec rm -f {} +") 63 | c.run("find . -name '*~' -exec rm -f {} +") 64 | c.run("find . -name '__pycache__' -exec rm -fr {} +") 65 | 66 | 67 | @task 68 | def clean_tests(c): 69 | """ 70 | Clean up files from testing 71 | """ 72 | _delete_file(COVERAGE_FILE) 73 | shutil.rmtree(TOX_DIR, ignore_errors=True) 74 | shutil.rmtree(COVERAGE_DIR, ignore_errors=True) 75 | 76 | 77 | @task(pre=[clean_build, clean_python, clean_tests]) 78 | def clean(c): 79 | """ 80 | Runs all clean sub-tasks 81 | """ 82 | pass 83 | 84 | 85 | @task(clean) 86 | def dist(c): 87 | """ 88 | Build source and wheel packages 89 | """ 90 | c.run("python setup.py sdist") 91 | c.run("python setup.py bdist_wheel") 92 | 93 | 94 | @task(pre=[clean, dist]) 95 | def release(c): 96 | """ 97 | Make a release of the python package to pypi 98 | """ 99 | c.run("twine upload dist/*") 100 | -------------------------------------------------------------------------------- /slack_cleaner/args.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | import os 5 | from slack_cleaner import __version__ 6 | 7 | class Args(): 8 | def __init__(self): 9 | p = argparse.ArgumentParser(prog='slack-cleaner') 10 | 11 | # Token 12 | env_token = os.environ.get('SLACK_TOKEN', None) 13 | p.add_argument('--token', required=not env_token, 14 | default=env_token, 15 | help='Slack API token (https://api.slack.com/web) or SLACK_TOKEN env var') 16 | 17 | # Log 18 | p.add_argument('--log', action='store_true', 19 | help='Create a log file in the current directory') 20 | # Quiet 21 | p.add_argument('--quiet', action='store_true', 22 | help='Run quietly, does not log messages deleted') 23 | 24 | # Rate limit 25 | p.add_argument('--rate', type=float, 26 | help='Delay between API calls (in seconds)') 27 | 28 | # user 29 | p.add_argument('--as_user', action='store_true', 30 | help='Pass true to delete the message as the authed user. Bot users in this context are considered authed users.') 31 | 32 | # proxy 33 | p.add_argument('--proxy', 34 | help='Proxy server url:port') 35 | p.add_argument('--verify', 36 | help='Verify option for Session (http://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification)') 37 | 38 | # Type 39 | g_type = p.add_mutually_exclusive_group() 40 | g_type.add_argument('--message', action='store_true', 41 | help='Delete messages') 42 | g_type.add_argument('--file', action='store_true', 43 | help='Delete files') 44 | g_type.add_argument('--info', action='store_true', 45 | help='Show information') 46 | 47 | p.add_argument('--regex', action='store_true', help='Interpret channel, direct, group, and mpdirect as regex') 48 | p.add_argument('--channel', 49 | help='Channel name\'s, e.g., general') 50 | p.add_argument('--direct', 51 | help='Direct message\'s name, e.g., sherry') 52 | p.add_argument('--group', 53 | help='Private group\'s name') 54 | p.add_argument('--mpdirect', 55 | help='Multiparty direct message\'s name, e.g., ' + 56 | 'sherry,james,johndoe') 57 | 58 | # Conditions 59 | p.add_argument('--user', 60 | help='Delete messages/files from certain user') 61 | p.add_argument('--botname', 62 | help='Delete messages/files from certain bots. Implies --bot') 63 | p.add_argument('--bot', action='store_true', 64 | help='Delete messages from bots') 65 | 66 | # Filter 67 | p.add_argument('--keeppinned', action='store_true', 68 | help='exclude pinned messages from deletion') 69 | p.add_argument('--after', 70 | help='Delete messages/files newer than this time ' + 71 | '(YYYYMMDD)') 72 | p.add_argument('--before', 73 | help='Delete messages/files older than this time ' + 74 | '(YYYYMMDD)') 75 | p.add_argument('--types', 76 | help='Delete files of a certain type, e.g., posts,pdfs') 77 | p.add_argument('--pattern', 78 | help='Delete messages/files with specified pattern or when one of their attachments matches (regex)') 79 | 80 | # Our Version 81 | p.add_argument('--version', action='version', version='Version ' + __version__, 82 | help='Print Program Version') 83 | 84 | # Perform or not 85 | p.add_argument('--perform', action='store_true', 86 | help='Perform the task') 87 | 88 | args = p.parse_args() 89 | 90 | if args.message: 91 | if (args.channel is None and args.direct is None and 92 | args.group is None and args.mpdirect is None): 93 | p.error('A channel is required when using --message') 94 | 95 | self.token = args.token 96 | 97 | self.show_infos = args.info 98 | 99 | self.log = args.log 100 | self.quiet = args.quiet 101 | self.rate_limit = args.rate 102 | self.as_user = args.as_user 103 | 104 | self.proxy = args.proxy 105 | self.verify = args.verify 106 | 107 | self.delete_message = args.message 108 | self.delete_file = args.file 109 | 110 | self.regex = args.regex 111 | self.channel_name = args.channel 112 | self.direct_name = args.direct 113 | self.group_name = args.group 114 | self.mpdirect_name = args.mpdirect 115 | 116 | self.user_name = args.user 117 | self.botname = args.botname 118 | self.bot = args.bot or (args.botname is not None) # --botname implies --bot 119 | self.keep_pinned = args.keeppinned 120 | self.pattern = args.pattern 121 | self.start_time = args.after 122 | self.end_time = args.before 123 | self.types = args.types 124 | 125 | self.perform = args.perform 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slack-cleaner 2 | [![License: MIT][mit-image]][mit-url] [![PyPi][pypi-image]][pypi-url] 3 | 4 | Bulk delete messages and files on Slack. 5 | 6 | this is a fork of https://github.com/kfei/slack-cleaner 7 | 8 | An improved Python module based version is located at https://github.com/sgratzl/slack_cleaner2 9 | 10 | ## Install 11 | 12 | Install from Pip: 13 | 14 | ```bash 15 | pip install slack-cleaner 16 | ``` 17 | 18 | current development version: 19 | 20 | ``` 21 | pip install -e git+https://github.com/sgratzl/slack-cleaner.git#egg=slack-cleaner 22 | ``` 23 | 24 | If you prefer Docker, there is a pre-built Docker image as well: 25 | 26 | ```bash 27 | docker pull sgratzl/slack-cleaner 28 | ``` 29 | 30 | Just use `docker run -it --rm sgratzl/slack-cleaner -c "slack-cleaner ..."` for each command or jump into a shell using `docker run -it --rm sgratzl/slack-cleaner`. 31 | 32 | Install for Fedora or EPEL7 33 | 34 | [@rapgro](https://github.com/rapgro) maintains packages for both Fedora and EPEL7 35 | 36 | ```bash 37 | # Fedora 38 | dnf install slack-cleaner 39 | # EPEL7 40 | yum install -y epel-release ; yum install slack-cleaner 41 | ``` 42 | 43 | ## Arguments 44 | 45 | ``` 46 | usage: slack-cleaner [-h] --token TOKEN [--log] [--quiet] [--rate RATE] 47 | [--as_user] [--message | --file | --info] [--regex] 48 | [--channel CHANNEL] [--direct DIRECT] [--group GROUP] 49 | [--mpdirect MPDIRECT] [--user USER] [--botname BOTNAME] 50 | [--bot] [--keeppinned] [--after AFTER] [--before BEFORE] 51 | [--types TYPES] [--pattern PATTERN] [--perform] 52 | 53 | optional arguments: 54 | -h, --help show this help message and exit 55 | --token TOKEN Slack API token (https://api.slack.com/web) or SLACK_TOKEN env var 56 | --log Create a log file in the current directory 57 | --quiet Run quietly, does not log messages deleted 58 | --proxy Proxy Server url:port 59 | --verify Verify option for Session (http://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification) 60 | --rate RATE Delay between API calls (in seconds) 61 | --as_user Pass true to delete the message as the authed user. Bot 62 | users in this context are considered authed users. 63 | --message Delete messages 64 | --file Delete files 65 | --info Show information 66 | --regex Interpret channel, direct, group, and mpdirect as regex 67 | --channel CHANNEL Channel name's, e.g., general 68 | --direct DIRECT Direct message's name, e.g., sherry 69 | --group GROUP Private group's name 70 | --mpdirect MPDIRECT Multiparty direct message's name, e.g., 71 | sherry,james,johndoe 72 | --user USER Delete messages/files from certain user 73 | --botname BOTNAME Delete messages/files from certain bots. Implies '--bot' 74 | --bot Delete messages from bots 75 | --keeppinned exclude pinned messages from deletion 76 | --after AFTER Delete messages/files newer than this time (YYYYMMDD) 77 | --before BEFORE Delete messages/files older than this time (YYYYMMDD) 78 | --types TYPES Delete files of a certain type, e.g., posts,pdfs 79 | --pattern PATTERN Delete messages/files with specified pattern or if one of their attachments matches (regex) 80 | --perform Perform the task 81 | ``` 82 | 83 | ## Permission Scopes needed 84 | 85 | The permissions to grant depend on what you are going to use the script for. 86 | Grant the permissions below depending on your use. 87 | 88 | Beyond granting permissions, if you wish to use this script to delete 89 | messages or files posted by others, you will need to be an [Owner or 90 | Admin](https://get.slack.help/hc/en-us/articles/218124397-Change-a-member-s-role) 91 | of the workspace. 92 | 93 | #### Deleting messages from public channels 94 | 95 | - `channels:history` 96 | - `channels:read` 97 | - `chat:write` (or both `chat:write:user` and `chat:write:bot` for older apps) 98 | - `users:read` 99 | 100 | #### Deleting messages from private channels 101 | 102 | - `groups:history` 103 | - `groups:read` 104 | - `chat:write` (or `chat:write:user` for older apps) 105 | - `users:read` 106 | 107 | #### Deleting messages from 1:1 IMs 108 | 109 | - `im:history` 110 | - `im:read` 111 | - `chat:write` (or `chat:write:user` for older apps) 112 | - `users:read` 113 | 114 | #### Deleting messages from multi-person IMs 115 | 116 | - `mpim:history` 117 | - `mpim:read` 118 | - `chat:write` (or `chat:write:user` for older apps) 119 | - `users:read` 120 | 121 | #### Deleting files 122 | 123 | - `files:read` 124 | - `files:write` (or `files:write:user` for older apps) 125 | - `users:read` 126 | 127 | ## Usage 128 | 129 | ```bash 130 | # Delete all messages from a channel 131 | slack-cleaner --token --message --channel general --user "*" 132 | 133 | # Delete all messages from a private group aka private channel 134 | slack-cleaner --token --message --group hr --user "*" 135 | 136 | # Delete all messages from a direct message channel 137 | slack-cleaner --token --message --direct sherry --user johndoe 138 | 139 | # Delete all messages from a multiparty direct message channel. Note that the 140 | # list of usernames must contains yourself 141 | slack-cleaner --token --message --mpdirect sherry,james,johndoe --user "*" 142 | 143 | # Delete all messages from certain user 144 | slack-cleaner --token --message --channel gossip --user johndoe 145 | 146 | # Delete all messages from bots (especially flooding CI updates) 147 | slack-cleaner --token --message --channel auto-build --bot 148 | 149 | # Delete all messages older than 2015/09/19 150 | slack-cleaner --token --message --channel general --user "*" --before 20150919 151 | 152 | # Delete all files 153 | slack-cleaner --token --file --user "*" 154 | 155 | # Delete all files from certain user 156 | slack-cleaner --token --file --user johndoe 157 | 158 | # Delete all snippets and images 159 | slack-cleaner --token --file --types snippets,images 160 | 161 | # Show information about users, channels: 162 | slack-cleaner --token --info 163 | 164 | # Delete matching a regexp pattern 165 | slack-cleaner --token --pattern "(bar|foo.+)" 166 | 167 | # TODO add add keep_pinned example, add quiet 168 | 169 | # Always have a look at help message 170 | slack-cleaner --help 171 | ``` 172 | 173 | ## Configuring app 174 | 175 | The cleaner needs you to give Slack's API permission to let it run the 176 | operations it needs. You grant these by registering it as an app in the 177 | workspace you want to use it in. 178 | 179 | You can grant these permissions to the app by: 180 | 181 | 1. going to [Your Apps](https://api.slack.com/apps) 182 | 2. select 'Create New App', fill out an App Name (eg 'Slack Cleaner') and 183 | select the Slack workspace you want to use it in 184 | 3. select 'OAuth & Permissions' in the sidebar 185 | 4. scroll down to Scopes and select all scopes you need 186 | 5. select 'Save changes' 187 | 6. select 'Install App to Workspace' 188 | 7. review the permissions and press 'Authorize' 189 | 8. copy the 'OAuth Access Token' shown, and use this token as the `--token` 190 | argument to the script 191 | 192 | ## Tips 193 | 194 | After the task, a backup file `slack-cleaner..log` will be created in current directory if `--log` is supplied. 195 | 196 | If any API problem occurred, try `--rate=` to reduce the API call rate (which by default is unlimited). 197 | 198 | If you see the following warning from `urllib3`, consider to install missing 199 | packages: `pip install --upgrade requests[security]` or just upgrade your Python to 2.7.9. 200 | 201 | ``` 202 | InsecurePlatformWarning: A true SSLContext object is not available. 203 | This prevents urllib3 from configuring SSL appropriately and may cause certain SSL connections to fail. 204 | For more information, see https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning. 205 | ``` 206 | 207 | ## Credits 208 | 209 | **To all the people who can only afford a free plan. :cry:** 210 | 211 | 212 | [mit-image]: https://img.shields.io/badge/License-MIT-yellow.svg 213 | [mit-url]: https://opensource.org/licenses/MIT 214 | [pypi-image]: https://pypip.in/version/slack-cleaner/badge.svg 215 | [pypi-url]: https://pypi.python.org/pypi/slack-cleaner/ 216 | -------------------------------------------------------------------------------- /slack_cleaner/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | import logging 5 | import pprint 6 | import sys 7 | import time 8 | import re 9 | import itertools 10 | from requests.sessions import Session 11 | from slacker import Slacker, Error 12 | 13 | from slack_cleaner import __version__ 14 | from slack_cleaner.utils import Colors, Counter, TimeRange 15 | from slack_cleaner.args import Args 16 | 17 | # Get and parse command line arguments 18 | args = Args() 19 | time_range = TimeRange(args.start_time, args.end_time) 20 | 21 | # Nice slack API wrapper 22 | with Session() as session: 23 | if args.proxy: 24 | session.proxies = { 25 | 'http': args.proxy, 26 | 'https': args.proxy 27 | } 28 | if args.verify == 'true': 29 | session.verify = True 30 | elif args.verify == 'false': 31 | session.verify = False 32 | elif args.verify: 33 | session.verify = args.verify 34 | 35 | slack = Slacker(args.token, session=session, rate_limit_retries=2) 36 | 37 | # So we can print slack's object beautifully 38 | pp = pprint.PrettyPrinter(indent=4) 39 | 40 | # Count how many items we deleted 41 | counter = Counter() 42 | 43 | # Initial logger 44 | logger = logging.getLogger('slack-cleaner') 45 | logger.setLevel(10) 46 | 47 | # Log deleted messages/files if we're gonna actually log the task 48 | if args.log: 49 | ts = datetime.now().strftime('%Y%m%d-%H%M%S') 50 | file_log_handler = logging.FileHandler('slack-cleaner.' + ts + '.log') 51 | logger.addHandler(file_log_handler) 52 | 53 | # And always display on console 54 | stderr_log_handler = logging.StreamHandler() 55 | logger.addHandler(stderr_log_handler) 56 | 57 | # Print version information 58 | logger.info('Running slack-cleaner v' + __version__) 59 | 60 | # User dict: user_id -> name 61 | user_dict = {} 62 | 63 | 64 | # Construct a local user dict for further usage 65 | def init_user_dict(): 66 | res = slack.users.list().body 67 | if not res['ok']: 68 | return 69 | members = res['members'] 70 | 71 | for m in members: 72 | user_dict[m['id']] = m['name'] 73 | 74 | 75 | def get_user(user_id): 76 | if user_id in user_dict: 77 | return user_dict[user_id] 78 | logger.warning(Colors.YELLOW + 'Cannot find user' + Colors.ENDC + '%s', 79 | user_id) 80 | return user_id 81 | 82 | 83 | # Init user dict 84 | init_user_dict() 85 | 86 | 87 | def matches_pattern(m, pattern): 88 | regex = re.compile(args.pattern) 89 | # name ... in case of a file 90 | # text ... in case of a message 91 | text = m.get('text', m.get('name')) 92 | if regex.search(text) is not None: 93 | return True 94 | # search attachments whether any matches the text 95 | attachments = m.get('attachments') 96 | if attachments is not None: 97 | for a in attachments: 98 | if regex.search(a.get('text', '')) is not None or regex.search(a.get('pretext', '')) is not None: 99 | return True 100 | # no by default 101 | return False 102 | 103 | 104 | def should_delete_item(m): 105 | """ 106 | checks whether the given element should be deleted 107 | """ 108 | if args.keep_pinned and m.get('pinned_to'): 109 | return False 110 | if args.pattern and not matches_pattern(m, args.pattern): 111 | return False # only delete messages matching the pattern 112 | 113 | # by default delete 114 | return True 115 | 116 | 117 | def get_message_or_first_attachment_text(message): 118 | text = message.get('text') 119 | if text: 120 | return text 121 | 122 | # If there's no message text, try attachments 123 | attachments = message.get('attachments') 124 | if attachments is not None: 125 | for a in attachments: 126 | text = a.get('text', '') 127 | pretext = a.get('pretext', '') 128 | for t in [pretext, text]: 129 | if t: 130 | return t 131 | 132 | return '' 133 | 134 | def _clean_messages_impl(list_f, channel_id, time_range, user_id=None, bot=False, are_replies_of=None): 135 | # Setup time range for query 136 | oldest = time_range.start_ts 137 | latest = time_range.end_ts 138 | 139 | has_more = True 140 | while has_more: 141 | res = list_f(latest, oldest).body 142 | if not res['ok']: 143 | logger.error('Error occurred on Slack\'s API:') 144 | pp.pprint(res) 145 | sys.exit(1) 146 | 147 | messages = res['messages'] 148 | 149 | if not messages: 150 | if not args.quiet and not are_replies_of: 151 | logger.info('No more messages') 152 | break 153 | 154 | has_more = res['has_more'] 155 | 156 | for m in messages: 157 | # Prepare for next page query 158 | latest = m['ts'] 159 | 160 | if are_replies_of and m['ts'] == are_replies_of: 161 | continue 162 | 163 | # Delete user messages 164 | if m['type'] == 'message': 165 | # exclude pinned message if asked 166 | if not should_delete_item(m): 167 | continue 168 | # If it's a normal user message 169 | if m.get('user'): 170 | # Delete message if user_name matched or `--user=*` 171 | if m.get('user') == user_id or user_id == -1: 172 | delete_message_on_channel(channel_id, m) 173 | # Thread messages 174 | replies = m.get('replies') 175 | if replies: 176 | for r in replies: 177 | if r.get('user') and (r.get('user') == user_id or user_id == -1): 178 | delete_message_on_channel(channel_id, r) 179 | elif m.get('reply_count', 0) > 0 and not are_replies_of: 180 | clean_replies(channel_id, m.get('thread_ts', m['ts']), time_range, user_id, bot) 181 | 182 | # Delete bot messages 183 | if bot and (m.get('subtype') == 'bot_message' or 'bot_id' in m): 184 | # If botname specified conditionalise the match 185 | if args.botname: 186 | if m.get('username') != user_id: 187 | continue 188 | if m.get('subtype') != 'tombstone': 189 | # cannot delete tombstone messages 190 | delete_message_on_channel(channel_id, m) 191 | 192 | # Exceptions 193 | else: 194 | logger.error('Weird message') 195 | pp.pprint(m) 196 | 197 | if args.rate_limit: 198 | time.sleep(args.rate_limit) 199 | 200 | 201 | def clean_replies(channel_id, thread_ts, time_range, user_id=None, bot=False): 202 | def list_f(latest, oldest): 203 | try: 204 | return slack.conversations.replies(channel_id, thread_ts, latest=latest, oldest=oldest, limit=1000) 205 | except Error as e: 206 | if str(e) == 'thread_not_found': 207 | # make it as if there are no more messages 208 | return dict(ok=True, messages=[]) 209 | raise e 210 | 211 | _clean_messages_impl(list_f, channel_id, time_range, user_id, bot, thread_ts) 212 | 213 | 214 | def clean_channel(channel_id, time_range, user_id=None, bot=False): 215 | def list_f(latest, oldest): 216 | return slack.conversations.history(channel_id, latest=latest, oldest=oldest, limit=1000) 217 | 218 | _clean_messages_impl(list_f, channel_id, time_range, user_id, bot) 219 | 220 | 221 | def delete_message_on_channel(channel_id, message): 222 | def get_user_name(m): 223 | if m.get('user'): 224 | _id = m.get('user') 225 | return get_user(_id) 226 | elif m.get('username'): 227 | return m.get('username') 228 | else: 229 | return '_' 230 | 231 | # Actually perform the task 232 | if args.perform: 233 | try: 234 | # No response is a good response 235 | slack.chat.delete(channel_id, message['ts'], as_user=args.as_user) 236 | 237 | counter.increase() 238 | if not args.quiet: 239 | logger.warning(Colors.RED + 'Deleted message -> ' + Colors.ENDC + '%s : %s', 240 | get_user_name(message), get_message_or_first_attachment_text(message)) 241 | except Exception as error: 242 | logger.error(Colors.YELLOW + 'Failed to delete (%s)->' + Colors.ENDC, error) 243 | pp.pprint(message) 244 | 245 | if args.rate_limit: 246 | time.sleep(args.rate_limit) 247 | 248 | # Just simulate the task 249 | else: 250 | counter.increase() 251 | if not args.quiet: 252 | logger.warning(Colors.YELLOW + 'Will delete message -> ' + Colors.ENDC + '%s : %s', 253 | get_user_name(message), get_message_or_first_attachment_text(message)) 254 | 255 | 256 | def remove_files(time_range, user_id=None, types=None, channel_id=None): 257 | # Setup time range for query 258 | oldest = time_range.start_ts 259 | latest = time_range.end_ts 260 | page = 1 261 | 262 | if user_id == -1: 263 | user_id = None 264 | 265 | has_more = True 266 | while has_more: 267 | res = slack.files.list(user=user_id, ts_from=oldest, ts_to=latest, 268 | channel=channel_id, 269 | types=types, page=page).body 270 | 271 | if not res['ok']: 272 | logger.error('Error occurred on Slack\'s API:') 273 | pp.pprint(res) 274 | sys.exit(1) 275 | 276 | files = res['files'] 277 | current_page = res['paging']['page'] 278 | total_pages = res['paging']['pages'] 279 | has_more = current_page < total_pages 280 | page = current_page + 1 281 | 282 | for f in files: 283 | if not should_delete_item(f): 284 | continue 285 | # Delete user file 286 | delete_file(f) 287 | 288 | if args.rate_limit: 289 | time.sleep(args.rate_limit) 290 | 291 | 292 | def delete_file(file): 293 | # Actually perform the task 294 | if args.perform: 295 | try: 296 | # No response is a good response 297 | slack.files.delete(file['id']) 298 | counter.increase() 299 | if not args.quiet: 300 | logger.warning(Colors.RED + 'Deleted file -> ' + Colors.ENDC + '%s', file.get('title', '')) 301 | except Exception as error: 302 | logger.error(Colors.YELLOW + 'Failed to delete (%s) ->' + Colors.ENDC, error) 303 | pp.pprint(file) 304 | 305 | if args.rate_limit: 306 | time.sleep(args.rate_limit) 307 | 308 | # Just simulate the task 309 | elif not args.quiet: 310 | counter.increase() 311 | logger.warning(Colors.YELLOW + 'Will delete file -> ' + Colors.ENDC + '%s', file.get('title', '')) 312 | 313 | 314 | 315 | def get_user_id_by_name(name): 316 | for k, v in user_dict.items(): 317 | if v == name: 318 | return k 319 | 320 | 321 | def match_by_key(pattern, items, key, equality_match): 322 | if equality_match: 323 | return [(item['id'], key(item)) for item in items if pattern == key(item)] 324 | # ensure it matches the whole string 325 | regex = re.compile('^' + pattern + '$', re.I) 326 | return [(item['id'], key(item)) for item in items if regex.match(key(item))] 327 | 328 | 329 | def get_channel_ids_by_pattern(pattern, equality_match): 330 | res = slack.conversations.list(types='public_channel', limit=1000).body 331 | if not res['ok'] or not res['channels']: 332 | return [] 333 | return match_by_key(pattern, res['channels'], lambda c: c['name'], equality_match) 334 | 335 | 336 | def get_direct_ids_by_pattern(pattern, equality_match): 337 | res = slack.conversations.list(types='im', limit=1000).body 338 | if not res['ok'] or not res['channels']: 339 | return [] 340 | return match_by_key(pattern, res['channels'], lambda i: get_user(i['user']), equality_match) 341 | 342 | 343 | def get_group_ids_by_pattern(pattern, equality_match): 344 | res = slack.conversations.list(types='private_channel', limit=1000).body 345 | if not res['ok'] or not res['channels']: 346 | return [] 347 | return match_by_key(pattern, res['channels'], lambda c: c['name'], equality_match) 348 | 349 | 350 | def get_mpdirect_ids_by_pattern(pattern): 351 | res = slack.conversations.list(types='mpim', limit=1000).body 352 | if not res['ok'] or not res['channels']: 353 | return [] 354 | mpims = res['channels'] 355 | 356 | regex = re.compile('^' + pattern + '$', re.I) 357 | 358 | def get_members(mpim_id): 359 | members_res = slack.conversations.members(mpim_id).body 360 | if not members_res['ok']: 361 | return [] 362 | return members_res['members'] 363 | 364 | def matches_members(members): 365 | names = [get_user(m) for m in members] 366 | # has to match at least one permutation of the members 367 | for permutation in itertools.permutations(names): 368 | if regex.match(','.join(permutation)): 369 | return True 370 | return False 371 | 372 | return [(mpim['id'], ','.join(get_user(m) for m in get_members(mpim['id']))) for mpim in mpims if matches_members(get_members(mpim['id']))] 373 | 374 | 375 | def get_mpdirect_ids_compatbility(name): 376 | res = slack.conversations.list(types='mpim', limit=1000).body 377 | if not res['ok'] or not res['channels']: 378 | return [] 379 | mpims = res['channels'] 380 | 381 | # create set of user ids 382 | members = set([get_user_id_by_name(x) for x in name.split(',')]) 383 | 384 | for mpim in mpims: 385 | members_res = slack.conversations.members(mpim['id']).body 386 | if not members_res['ok']: 387 | continue 388 | mpim_members = members_res['members'] 389 | # match the mpdirect user ids 390 | if set(mpim_members) == members: 391 | return [(mpim['id'], ','.join(get_user(m) for m in mpim_members))] 392 | return [] 393 | 394 | 395 | def resolve_channels(): 396 | _channels = [] 397 | # If channel's name is supplied 398 | if args.channel_name: 399 | _channels.extend([(id, name, 'channel') for (id, name) in get_channel_ids_by_pattern(args.channel_name, not args.regex)]) 400 | 401 | # If DM's name is supplied 402 | if args.direct_name: 403 | _channels.extend([(id, name, 'direct') for (id, name) in get_direct_ids_by_pattern(args.direct_name, not args.regex)]) 404 | 405 | # If channel's name is supplied 406 | if args.group_name: 407 | _channels.extend([(id, name, 'group') for (id, name) in get_group_ids_by_pattern(args.group_name, not args.regex)]) 408 | 409 | # If group DM's name is supplied 410 | if args.mpdirect_name: 411 | _channels.extend([(id, name, 'mpdirect') for (id, name) in (get_mpdirect_ids_by_pattern(args.mpdirect_name) if args.regex else get_mpdirect_ids_compatbility(args.mpdirect_name))]) 412 | 413 | return _channels 414 | 415 | 416 | def resolve_user(): 417 | _user_id = None 418 | # If user's name is also supplied 419 | if args.user_name: 420 | # A little bit tricky here, we use -1 to indicates `--user=*` 421 | if args.user_name == "*": 422 | _user_id = -1 423 | else: 424 | _user_id = get_user_id_by_name(args.user_name) 425 | 426 | if _user_id is None: 427 | sys.exit('User not found') 428 | # For bots the username is customisable and can be any name 429 | if args.botname: 430 | _user_id = args.botname 431 | return _user_id 432 | 433 | 434 | def message_cleaner(): 435 | _channels = resolve_channels() 436 | _user_id = resolve_user() 437 | 438 | if not _channels: 439 | sys.exit('Channel, direct message or private group not found') 440 | 441 | for (channel_id, channel_name, channel_type) in _channels: 442 | logger.info('Deleting messages from %s %s', channel_type, channel_name) 443 | # Delete messages on certain channel 444 | clean_channel(channel_id, time_range, user_id=_user_id, bot=args.bot) 445 | 446 | 447 | def file_cleaner(): 448 | _types = args.types if args.types else None 449 | _channels = resolve_channels() 450 | _user_id = resolve_user() 451 | 452 | if not _channels: 453 | logger.info('Deleting all matching files') 454 | remove_files(time_range, user_id=_user_id, types=_types, channel_id=None) 455 | 456 | 457 | for (channel_id, channel_name, channel_type) in _channels: 458 | logger.info('Deleting files from %s %s', channel_type, channel_name) 459 | remove_files(time_range, user_id=_user_id, types=_types, channel_id=channel_id) 460 | 461 | 462 | def show_infos(): 463 | """ 464 | show user and channel information 465 | """ 466 | 467 | def print_dict(name, d): 468 | m = u'{g}{name}:{e}'.format(g=Colors.GREEN, name=name, e=Colors.ENDC) 469 | for k, v in d.items(): 470 | m += u'\n{k} {v}'.format(k=k, v=v) 471 | logger.info(m) 472 | 473 | res = slack.users.list().body 474 | if res['ok'] and res.get('members'): 475 | users = {c['id']: u'{n} = {r}'.format(n=c['name'], r=c['profile']['real_name']) for c in res['members']} 476 | else: 477 | users = {} 478 | print_dict('users', users) 479 | 480 | res = slack.conversations.list(types='public_channel', limit=1000).body 481 | if res['ok'] and res.get('channels'): 482 | channels = {c['id']: c['name'] for c in res['channels']} 483 | else: 484 | channels = {} 485 | print_dict('public channels', channels) 486 | 487 | res = slack.conversations.list(types='private_channel', limit=1000).body 488 | if res['ok'] and res.get('channels'): 489 | groups = {c['id']: c['name'] for c in res['channels']} 490 | else: 491 | groups = {} 492 | print_dict('private channels', groups) 493 | 494 | res = slack.conversations.list(types='im', limit=1000).body 495 | if res['ok'] and res.get('channels'): 496 | ims = { c['id']: get_user(c['user']) for c in res['channels']} 497 | else: 498 | ims = {} 499 | print_dict('instant messages', ims) 500 | 501 | res = slack.conversations.list(types='mpim', limit=1000).body 502 | if res['ok'] and res['channels']: 503 | mpin = { c['id']: c['name'] for c in res['channels']} 504 | else: 505 | mpin = {} 506 | print_dict('multi user direct messages', mpin) 507 | 508 | 509 | def main(): 510 | if args.show_infos: 511 | show_infos() 512 | 513 | # Dispatch 514 | if args.delete_message: 515 | message_cleaner() 516 | elif args.delete_file: 517 | file_cleaner() 518 | 519 | # Compose result string 520 | result = Colors.GREEN + str(counter.total) + Colors.ENDC 521 | if args.delete_message: 522 | result += ' message(s)' 523 | elif args.delete_file: 524 | result += ' file(s)' 525 | 526 | if not args.perform: 527 | result += ' will be cleaned.' 528 | else: 529 | result += ' cleaned.' 530 | 531 | # Print result 532 | logger.info('\n' + result + '\n') 533 | 534 | if not args.perform: 535 | logger.info('Now you can re-run this program with `--perform`' + 536 | ' to actually perform the task.' + '\n') 537 | 538 | 539 | if __name__ == '__main__': 540 | main() 541 | --------------------------------------------------------------------------------