22 Commits

Author SHA1 Message Date
92ad93a710 Merge branch 'remove-golly-notifier'
All checks were successful
The Waste Land / post-line (push) Successful in 5s
The Odyssey / post-chunk (push) Successful in 7s
Paradise Lost / post-line (push) Successful in 6s
* remove-golly-notifier:
  remove gitea workflows
  remove golly-notifier files
2026-04-25 07:31:00 -07:00
a75748073c remove gitea workflows 2026-04-25 07:30:46 -07:00
f9ed9f63e9 remove golly-notifier files 2026-04-25 07:30:06 -07:00
cbb18d7fb2 update readme 2026-04-25 07:29:40 -07:00
84a89adabf fix duplicate header and text
All checks were successful
The Waste Land / post-line (push) Successful in 6s
Hellmouth Cup Notifier / notify (push) Successful in 6s
The Odyssey / post-chunk (push) Successful in 6s
Paradise Lost / post-line (push) Successful in 6s
2026-04-24 13:21:02 -07:00
53133e4074 fix color issue
All checks were successful
The Waste Land / post-line (push) Successful in 8s
Hellmouth Cup Notifier / notify (push) Successful in 7s
The Odyssey / post-chunk (push) Successful in 7s
Paradise Lost / post-line (push) Successful in 7s
2026-04-24 11:23:45 -07:00
8487bb5b48 tighten up cron schedule
All checks were successful
The Waste Land / post-line (push) Successful in 6s
Hellmouth Cup Notifier / notify (push) Successful in 7s
The Odyssey / post-chunk (push) Successful in 7s
Paradise Lost / post-line (push) Successful in 7s
2026-04-24 10:15:34 -07:00
823304964a Merge branch 'golly-showy'
All checks were successful
The Waste Land / post-line (push) Successful in 6s
Hellmouth Cup Notifier / notify (push) Successful in 6s
The Odyssey / post-chunk (push) Successful in 7s
Paradise Lost / post-line (push) Successful in 6s
* golly-showy:
  update golly notifier to use Block Kit blocks
2026-04-22 19:05:27 -07:00
3e787962fa update golly notifier to use Block Kit blocks 2026-04-22 19:05:02 -07:00
782e98e8f2 put each team series record next to their own name in outcome messages
All checks were successful
Star Cup Notifier / notify (push) Successful in 6s
Paradise Lost / post-line (push) Successful in 6s
The Waste Land / post-line (push) Successful in 6s
The Odyssey / post-chunk (push) Successful in 6s
2026-04-22 11:36:42 -07:00
af077bbe9d fix series win-loss to include final game outcome, rename icons to bust Slack cache 2026-04-22 11:33:49 -07:00
265e099cec update notifier text
All checks were successful
The Waste Land / post-line (push) Successful in 7s
Star Cup Notifier / notify (push) Successful in 7s
The Odyssey / post-chunk (push) Successful in 6s
Paradise Lost / post-line (push) Successful in 6s
2026-04-22 07:07:42 -07:00
493cef12d9 add utilities folder
All checks were successful
Paradise Lost / post-line (push) Successful in 7s
The Waste Land / post-line (push) Successful in 7s
Star Cup Notifier / notify (push) Successful in 6s
The Odyssey / post-chunk (push) Successful in 7s
2026-04-21 20:40:04 -07:00
cde1af1ce9 update golly-notifier descr in readme 2026-04-21 20:39:50 -07:00
4fe755c781 update gitignore 2026-04-21 20:39:35 -07:00
7f83f21319 use opencode ultrawork team to fix golly-notifier, with more explicit instructions 2026-04-21 20:27:58 -07:00
50d61c6b34 update gitignore 2026-04-21 20:27:29 -07:00
379c2cc80b black background for golly-notifier icons 2026-04-21 20:26:06 -07:00
cb0453a518 try again. on the 40.
All checks were successful
Star Cup Notifier / notify (push) Successful in 7s
The Waste Land / post-line (push) Successful in 7s
The Odyssey / post-chunk (push) Successful in 7s
Paradise Lost / post-line (push) Successful in 6s
2026-04-21 14:35:46 -07:00
d6a8244a93 Merge branch 'fix-golly-notifier'
* fix-golly-notifier:
  are you freaking kidding me
2026-04-21 14:33:12 -07:00
dffd5b6a5f are you freaking kidding me 2026-04-21 14:33:01 -07:00
d4c2c48633 Merge branch 'fix-golly-notifier'
All checks were successful
The Waste Land / post-line (push) Successful in 6s
The Odyssey / post-chunk (push) Successful in 7s
Paradise Lost / post-line (push) Successful in 6s
Star Cup Notifier / notify (push) Successful in 7s
* fix-golly-notifier:
  add outcome notification plus ongoing game notification
  try again to fix golly-notifier
  add links to golly notifier
  512x512
  on the 5
2026-04-21 13:42:54 -07:00
13 changed files with 470 additions and 376 deletions

View File

@@ -1,31 +0,0 @@
name: Hellmouth Cup Notifier
on:
schedule:
# Every hour at minute 1, Friday through Sunday UTC
# 8 AM Pacific = 15:00 UTC (PDT) or 16:00 UTC (PST)
# Run every hour Fri-Sun UTC to cover both; script checks Pacific time
- cron: '1 * * * 5,6,0'
workflow_dispatch:
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r golly-notifier/requirements.txt
- name: Run Hellmouth Cup notifier
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.GOLLY_NOTIFIER_CHANNEL_ID }}
run: python golly-notifier/notifier.py hellmouth

View File

@@ -1,31 +0,0 @@
name: Star Cup Notifier
on:
schedule:
# Every hour at minute 1, Tuesday and Wednesday UTC
# 5 AM Pacific = 12:00 UTC (PDT) or 13:00 UTC (PST)
# Run every hour Tue-Wed UTC to cover both; script checks Pacific time
- cron: '20 * * * 2,3'
workflow_dispatch:
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r golly-notifier/requirements.txt
- name: Run Star Cup notifier
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.GOLLY_NOTIFIER_CHANNEL_ID }}
run: python golly-notifier/notifier.py star

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.claude
.DS_Store
__pycache__
.worktrees/
.worktrees
utilities/.environment

View File

@@ -4,13 +4,9 @@ Scheduled bots that post content into Slack channels. One app, multiple personas
## Structure
Each channel lives in its own subdirectory:
Each notifier (slack tv channel) lives in its own subdirectory.
```
waste-land/ # Posts one line of The Waste Land per hour
```
A channel directory contains a `notifier.py` (what to post and how the bot appears), an `icon.png` (bot avatar), and a `poem.txt` or equivalent source file.
A notifier directory contains a `notifier.py` (what to post and how the bot appears), an `icon.png` (bot avatar), and any supplementary files.
## Setup
@@ -19,5 +15,5 @@ See [SETUP.md](SETUP.md) for Slack app creation, GitHub secrets, and how to add
## Slack TV Channel Directory
- `waste-land/` - **The Waste Land, One Line Per Hour** — Eliot's poem is 434 lines. At one line per hour, it takes ~18 days to complete, then loops. The line-per-hour pace is critical: each line sits alone in the channel long enough to be read as a standalone fragment, which is how the poem actually works — collage, juxtaposition, voices interrupting each other. The footnotes post as threaded replies.
- `dactylic-odyssey` - **The Odyssey in Dactylic Drip** — Post 5 lines of the Fagles (or Lattimore, or Fitzgerald) translation every 4 hours. The Odyssey is ~12,110 lines. At 30 lines/day, the full poem takes ~403 days — just over a year. A 5-line chunk is roughly one complete Homeric image or action beat. The year-long duration mirrors the scale of the journey itself. When it finishes, start the Iliad.
- `paradise-lost/` - **Paradise Lost, Two Lines Per Hour** — Paradise Lost, Book I through Book XII, one line every 30 minutes (:15 and :45).
- `dactylic-odyssey/` - **The Odyssey in Dactylic Drip** — Post 5 lines of the Fagles (or Lattimore, or Fitzgerald) translation every 4 hours. The Odyssey is ~12,110 lines. At 30 lines/day, the full poem takes ~403 days — just over a year. A 5-line chunk is roughly one complete Homeric image or action beat. The year-long duration mirrors the scale of the journey itself. When it finishes, start the Iliad.

View File

@@ -1,37 +0,0 @@
I want to create a notifier kind of like @waste-land but with more customized
logic. instead of sending notifications that are lines of a poem sent one
per hour, we are going to check an API during a specific time slot.
we are going to have a star cup notifier and a hellmouth cup notifier. Star
cup notifier runs Tuesday and Wednesday, hellmouth cup notifier runs Friday,
Saturday, Sunday. Star Cup notifier will start at 5 AM Pacific, Hellmouth Cup
notifier will start at 8 AM Pacific.
Both notifiers will hit an API /mode endpoint that will indicate the state of
the game simulator. the mode shifts from 21 (waiting for round 1 series of
postseason to begin), to 31 (round 1 series of postseason ongoing), to 22
(waiting for round 2 series of postseason to begin), to 32 (round 2 series of
postseason ongoing), to 23 (waiting for final cup series of postseason to
begin), to 33 (final cup series of postseason is ongoing), to 40 (postseason
is over).
The procedure for the notifications will work as follows.
at the top of each hour (minute 1) during each specified window:
- check the mode
- if the mode is 31, 32, or 33, the series is ongoing.
- if the game description has "Game 1" in the title, it is the notification of the series. send a notification
that says "The XYZ Series is starting now!"
- if the game description has any other non-1 game number in the title, the series is ongoing. send a
notification that has yesterday's game outcome/scores.
- for each day, if the series is ongoing, and a matchup occurred yesterday but not today, determine who won
the series, and the notification should announce who won the series.
- if the mode is 21, the postseason has not started yet, and there is nothing to do.
- if the mode is 22, and it is Friday, check if less than one hour has elapsed, if so, announce the outcome of
round 1 of the postseason
- if the mode is 22, and it is Saturday, there is nothing to do.
- if the mode is 23, and it is Saturday, check if less than one hour has elapsed, if so, announce the outcome of
round 2 of the postseason
- if the mode is 23, and it is Sunday, there is nothing to do.
- if the mode is 40, and less than one hour has elapsed, announce the outcome of the final cup series.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,267 +0,0 @@
import os
import sys
import re
import requests
from datetime import datetime
import pytz
SLACK_API_URL = "https://slack.com/api/chat.postMessage"
CUPS = {
"star": {
"api_base": "https://cloud.star.vii.golly.life",
"site_base": "https://star.vii.golly.life",
"cup_name": "Star Cup",
"cup_series_key": "SCS",
"bot_username": "Star Cup",
"bot_icon_url": "https://git.charlesreid1.com/charlesreid1/slack-tv/raw/branch/main/golly-notifier/icon-star.png",
"days": [1, 2], # Tuesday, Wednesday (weekday() values)
"start_hour": 5,
},
"hellmouth": {
"api_base": "https://cloud.vii.golly.life",
"site_base": "https://vii.golly.life",
"cup_name": "Hellmouth Cup",
"cup_series_key": "HCS",
"bot_username": "Hellmouth Cup",
"bot_icon_url": "https://git.charlesreid1.com/charlesreid1/slack-tv/raw/branch/main/golly-notifier/icon-hellmouth.png",
"days": [4, 5, 6], # Friday, Saturday, Sunday
"start_hour": 8,
},
}
SERIES_NAMES = {
"LDS": "Division Series",
"LCS": "League Championship Series",
"HCS": "Hellmouth VII Cup",
"SCS": "Star Cup Series",
}
MODE_TO_SERIES = {
31: "LDS",
32: "LCS",
33: None, # filled per-cup: HCS or SCS
}
def fetch_json(url):
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
def send_slack_message(token, channel, text, username, icon_url):
payload = {
"channel": channel,
"text": text,
"username": username,
"icon_url": icon_url,
}
response = requests.post(
SLACK_API_URL,
headers={"Authorization": f"Bearer {token}"},
json=payload,
timeout=10,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Slack API error: {data.get('error')}")
return data
def format_matchup(g):
"""Two-line hockey-bot style: bold name line, then abbr/score line."""
name_line = f"*{g['team1Name']} vs. {g['team2Name']}*"
score_line = f"{g['team1Abbr']}: {g['team1Score']} | {g['team2Abbr']}: {g['team2Score']} | Final"
return [name_line, score_line]
def game_link(site_base, game):
game_id = game.get("id", "")
desc = game.get("description", "")
match = re.match(r"Game (\d+)", desc)
label = f"Game {match.group(1)}" if match else "Watch"
url = f"{site_base}/simulator/index.html?gameId={game_id}"
return f"<{url}|{label}>"
def format_current_games(current_games, site_base=None):
lines = []
for g in sorted(current_games, key=lambda x: x.get("description", "")):
w1, l1 = g["team1SeriesWinLoss"]
w2, l2 = g["team2SeriesWinLoss"]
t1 = f"{g['team1Name']} ({w1}-{l1})" if w1 or l1 else g["team1Name"]
t2 = f"{g['team2Name']} ({w2}-{l2})" if w2 or l2 else g["team2Name"]
if site_base:
link = game_link(site_base, g)
lines.append(f"{t1} vs. {t2}: {link}")
else:
lines.append(f"*{t1} vs. {t2}*")
return lines
def build_notification(cup_config, mode_data, postseason, current_games=None):
mode = mode_data["mode"]
elapsed = mode_data.get("elapsed", 0)
cup_name = cup_config["cup_name"]
cup_series_key = cup_config["cup_series_key"]
site_base = cup_config.get("site_base", "")
just_entered = elapsed < 3600
if mode == 21:
return []
if mode in (31, 32, 33):
if not just_entered:
return []
if mode == 33:
series_key = cup_series_key
else:
series_key = MODE_TO_SERIES[mode]
series_name = SERIES_NAMES.get(series_key, series_key)
series_day = (elapsed // 3600) + 1
series_data = postseason.get(series_key, [])
if just_entered and series_day == 1:
site_link = f"<{site_base}|{site_base.replace('https://', '')}>" if site_base else ""
lines = [f"{cup_name}: {series_name} is starting now! {site_link}".strip()]
if current_games:
lines.extend(format_current_games(current_games))
return ["\n".join(lines)]
messages = []
if just_entered and series_data:
yesterday_games = series_data[-1]
outcome_lines = [f"*{series_name} Results*"]
for g in yesterday_games:
outcome_lines.extend(format_matchup(g))
w1, l1 = g["team1SeriesWinLoss"]
w2, l2 = g["team2SeriesWinLoss"]
outcome_lines.append(f"Series: {g['team1Name']} {w1}-{l1}, {g['team2Name']} {w2}-{l2}")
today_pairs = set()
if current_games:
for g in current_games:
pair = tuple(sorted([g["team1Name"], g["team2Name"]]))
today_pairs.add(pair)
for g in yesterday_games:
pair = tuple(sorted([g["team1Name"], g["team2Name"]]))
if pair not in today_pairs:
w1, _ = g["team1SeriesWinLoss"]
w2, _ = g["team2SeriesWinLoss"]
if w1 > w2:
winner, loser, wl = g["team1Name"], g["team2Name"], f"{w1}-{w2}"
else:
winner, loser, wl = g["team2Name"], g["team1Name"], f"{w2}-{w1}"
outcome_lines.append(f"{winner} wins the series over {loser} ({wl})")
messages.append("\n".join(outcome_lines))
if current_games:
upcoming_lines = [f"*{series_name} — Today's Games*"]
upcoming_lines.extend(format_current_games(current_games))
messages.append("\n".join(upcoming_lines))
return messages
if mode == 22:
if not just_entered:
return []
result = announce_series_outcome(postseason, "LDS", "Division Series")
return [result] if result else []
if mode == 23:
if not just_entered:
return []
result = announce_series_outcome(postseason, "LCS", "League Championship Series")
return [result] if result else []
if mode == 40:
if not just_entered:
return []
result = announce_series_outcome(postseason, cup_series_key, cup_name)
return [result] if result else []
return []
def announce_series_outcome(postseason, series_key, series_name):
series_data = postseason.get(series_key, [])
if not series_data:
return None
last_day = series_data[-1]
lines = [f"*{series_name} Results*"]
for game in last_day:
w1, _ = game["team1SeriesWinLoss"]
w2, _ = game["team2SeriesWinLoss"]
if w1 > w2:
winner = game["team1Name"]
loser = game["team2Name"]
series_wl = f"{w1}-{w2}"
else:
winner = game["team2Name"]
loser = game["team1Name"]
series_wl = f"{w2}-{w1}"
lines.append(f"{winner} defeats {loser} ({series_wl})")
return "\n".join(lines)
def run(cup_key):
token = os.getenv("SLACK_BOT_TOKEN")
channel = os.getenv("SLACK_CHANNEL_ID")
if not token or not channel:
print("SLACK_BOT_TOKEN and SLACK_CHANNEL_ID must be set. Skipping.")
return
if cup_key not in CUPS:
print(f"Unknown cup: {cup_key}. Must be 'star' or 'hellmouth'.")
return
cup = CUPS[cup_key]
pacific_tz = pytz.timezone("America/Los_Angeles")
now_pt = datetime.now(pacific_tz)
print(f"[{now_pt.strftime('%Y-%m-%d %H:%M PT')}] Running {cup['cup_name']} notifier")
weekday = now_pt.weekday()
if weekday not in cup["days"]:
print(f" Not a {cup['cup_name']} day (weekday={weekday}). Skipping.")
return
if now_pt.hour < cup["start_hour"]:
print(f" Too early (hour={now_pt.hour}, start={cup['start_hour']}). Skipping.")
return
api_base = cup["api_base"]
mode_data = fetch_json(f"{api_base}/mode")
print(f" Mode: {mode_data}")
postseason = fetch_json(f"{api_base}/postseason")
print(f" Postseason series keys: {list(postseason.keys())}")
current_games = fetch_json(f"{api_base}/currentGames")
print(f" Current games: {len(current_games)}")
messages = build_notification(cup, mode_data, postseason, current_games)
if messages:
for i, msg in enumerate(messages, 1):
print(f" Sending message {i}/{len(messages)}: {msg[:80]}")
send_slack_message(token, channel, msg, cup["bot_username"], cup["bot_icon_url"])
print(f" Sent message {i}.")
else:
print(" No notification to send.")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python notifier.py <star|hellmouth>")
sys.exit(1)
run(sys.argv[1])

View File

@@ -0,0 +1,4 @@
# Copy this file to .environment and fill in real values.
# Then: source .environment
SLACK_BOT_TOKEN=xoxb-your-token-here

147
utilities/README.md Normal file
View File

@@ -0,0 +1,147 @@
# Utilities — Message Cleanup
Scripts for deleting messages posted by the Slack TV bot. Useful when testing a new channel, resetting after a bad deploy, or cleaning up a channel before archiving it.
Both scripts default to **dry-run mode** — they show what would happen without touching anything. Pass `--real` to actually delete.
---
## Before you start: expand the app's permissions
The channel notifiers only need `chat:write` and `chat:write.customize`. These utilities need two additional scopes so they can read channel history and identify bot messages.
### 1. Add the new scopes
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and select the **Slack TV** app
2. In the left sidebar, click **OAuth & Permissions**
3. Scroll to **Bot Token Scopes** and add:
| Scope | Why |
|-------|-----|
| `channels:history` | Read message history in public channels (`conversations.history`) |
| `groups:history` | Read message history in private channels (only needed if any Slack TV channel is private) |
The app should now have four (or five) scopes total:
| Scope | Used by |
|-------|---------|
| `chat:write` | notifiers (post messages) + utilities (delete messages) |
| `chat:write.customize` | notifiers (override bot name and icon per message) |
| `channels:history` | utilities (list messages in public channels) |
| `groups:history` | utilities (list messages in private channels) — optional |
### 2. Reinstall the app
After adding scopes, Slack requires you to reinstall:
1. Scroll to the top of the **OAuth & Permissions** page
2. Click **Reinstall to Workspace****Allow**
3. The **Bot User OAuth Token** (`xoxb-...`) stays the same — no secrets or config to update
That's it. The existing notifiers are unaffected; the new scopes are additive.
---
## Files
```
utilities/
├── delete_message.py # Delete a single message by URL
├── clear_channel.py # Delete all bot messages in a channel
├── requirements.txt # Python dependencies
├── .environment.example # Template — copy to .environment and fill in
├── .environment # Your real tokens (git-ignored)
└── README.md
```
---
## Environment variables
These scripts are meant to be run locally on a developer's machine, not in CI. You need to set one variable:
| Variable | Value | Where to find it |
|----------|-------|-------------------|
| `SLACK_BOT_TOKEN` | `xoxb-...` | [api.slack.com/apps](https://api.slack.com/apps) → Slack TV → **OAuth & Permissions****Bot User OAuth Token** |
A `.environment.example` template is checked in. Copy it, fill in your token, and source it:
```bash
cp .environment.example .environment
# edit .environment — paste your real xoxb-... token
```
The real `.environment` file is git-ignored so tokens never get committed.
---
## Setup
```bash
cd utilities
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
source .environment
```
---
## delete_message.py
Delete a single message by its Slack URL. Get the URL by right-clicking a message in Slack → **Copy link**.
```bash
# Dry run (default) — shows what would be deleted
python delete_message.py "https://myworkspace.slack.com/archives/C012AB3CD/p1234567890123456"
# Actually delete
python delete_message.py "https://myworkspace.slack.com/archives/C012AB3CD/p1234567890123456" --real
```
The script parses the channel ID and message timestamp from the URL automatically. It can only delete messages posted by the bot (Slack enforces this — bots cannot delete other users' messages).
---
## clear_channel.py
Delete **all** messages posted by the bot in a given channel. Takes a channel ID, not a channel name.
```bash
# Dry run (default) — lists every bot message that would be deleted
python clear_channel.py C012AB3CD
# Actually delete (prompts for confirmation)
python clear_channel.py C012AB3CD --real
# Actually delete (skip confirmation prompt)
python clear_channel.py C012AB3CD --real --force
```
### How it works
1. Calls `auth.test` to learn the bot's own user ID
2. Pages through `conversations.history` to find all messages in the channel
3. Filters to messages where `user` matches the bot's user ID
4. In dry-run mode, prints the list and stops
5. In real mode, deletes each message via `chat.delete` with a 1.2-second delay between calls to stay within Slack's rate limits (~50 requests/minute for Tier 3 methods)
### Safety rails
- **Dry run by default** — you have to explicitly opt in with `--real`
- **Confirmation prompt** — in `--real` mode, prints a loud warning and requires typing `y` before proceeding
- **`--force` flag** — skips the confirmation prompt for scripted use, but still requires `--real`
- **Bot messages only** — the script identifies and deletes only messages posted by the bot token you're using; other users' messages are untouched
---
## Finding the channel ID
Slack's API uses channel IDs, not names. To find a channel's ID:
- Open Slack in a browser, navigate to the channel, and grab the ID from the URL:
```
https://app.slack.com/client/T.../C012AB3CD
^^^^^^^^^^^ channel ID
```
- Or: right-click the channel name → **View channel details** → scroll to the bottom of the **About** tab

206
utilities/clear_channel.py Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Delete ALL messages posted by this bot in a Slack channel.
Usage:
python clear_channel.py <channel_id> # dry run (default)
python clear_channel.py <channel_id> --dry-run # explicit dry run
python clear_channel.py <channel_id> --real # actually delete (prompts for confirmation)
python clear_channel.py <channel_id> --real --force # actually delete (skip confirmation)
Environment:
SLACK_BOT_TOKEN — Bot User OAuth Token (xoxb-...)
Required Slack scopes:
channels:history — read message history in public channels
groups:history — read message history in private channels (if needed)
chat:write — delete messages posted by this bot
"""
import os
import sys
import time
import requests
SLACK_HISTORY_URL = "https://slack.com/api/conversations.history"
SLACK_DELETE_URL = "https://slack.com/api/chat.delete"
SLACK_AUTH_TEST_URL = "https://slack.com/api/auth.test"
def get_bot_user_id(token):
"""Get the bot's own user ID via auth.test."""
response = requests.post(
SLACK_AUTH_TEST_URL,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Slack API error: {data.get('error')}")
return data["user_id"]
def fetch_bot_messages(token, channel, bot_user_id):
"""Fetch all messages in a channel posted by the bot.
Pages through conversations.history (newest-first, 200 per page) and
collects messages where the user field matches the bot's user ID.
"""
messages = []
cursor = None
while True:
params = {"channel": channel, "limit": 200}
if cursor:
params["cursor"] = cursor
response = requests.get(
SLACK_HISTORY_URL,
headers={"Authorization": f"Bearer {token}"},
params=params,
timeout=15,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Slack API error: {data.get('error')}")
for msg in data.get("messages", []):
if msg.get("user") == bot_user_id:
messages.append(msg)
# Pagination
metadata = data.get("response_metadata", {})
cursor = metadata.get("next_cursor")
if not cursor:
break
return messages
def delete_message(token, channel, ts):
"""Call chat.delete to remove a message. Returns True on success."""
response = requests.post(
SLACK_DELETE_URL,
headers={"Authorization": f"Bearer {token}"},
json={"channel": channel, "ts": ts},
timeout=10,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
error = data.get("error")
if error == "message_not_found":
# Already deleted or ephemeral — not a failure
return False
raise RuntimeError(f"Slack API error: {error}")
return True
def confirm_destructive_action(channel, count):
"""Print a loud warning and require 'y' + Enter to proceed."""
print()
print("=" * 60)
print(" WARNING: DESTRUCTIVE ACTION")
print("=" * 60)
print()
print(f" You are about to permanently delete {count} message(s)")
print(f" from channel {channel}.")
print()
print(" This CANNOT be undone.")
print()
print("=" * 60)
print()
answer = input(" Type 'y' and press Enter to continue: ")
return answer.strip().lower() == "y"
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print(__doc__.strip())
sys.exit(0)
channel = sys.argv[1]
flags = sys.argv[2:]
real_mode = "--real" in flags
force = "--force" in flags
if "--dry-run" in flags and "--real" in flags:
print("Error: --dry-run and --real are mutually exclusive.")
sys.exit(1)
if force and not real_mode:
print("Error: --force only makes sense with --real.")
sys.exit(1)
token = os.getenv("SLACK_BOT_TOKEN")
if not token:
print("Error: SLACK_BOT_TOKEN environment variable is not set.")
sys.exit(1)
# Identify who we are
print("Identifying bot user...")
bot_user_id = get_bot_user_id(token)
print(f"Bot user ID: {bot_user_id}")
print()
# Fetch messages
print(f"Fetching messages from channel {channel}...")
bot_messages = fetch_bot_messages(token, channel, bot_user_id)
print(f"Found {len(bot_messages)} message(s) from this bot.")
if not bot_messages:
print("Nothing to delete.")
return
# Show preview
print()
print("Messages to delete:")
print("-" * 60)
for msg in bot_messages:
ts = msg["ts"]
text = msg.get("text", "")
preview = text[:70].replace("\n", " ")
if len(text) > 70:
preview += "..."
print(f" [{ts}] {preview}")
print("-" * 60)
print()
if not real_mode:
print(f"[DRY RUN] Would delete {len(bot_messages)} message(s) from channel {channel}.")
print("Pass --real to actually delete.")
return
# Confirmation gate
if not force:
if not confirm_destructive_action(channel, len(bot_messages)):
print("Aborted.")
sys.exit(0)
# Delete
deleted = 0
skipped = 0
for i, msg in enumerate(bot_messages, 1):
ts = msg["ts"]
text_preview = msg.get("text", "")[:50].replace("\n", " ")
sys.stdout.write(f"\r Deleting {i}/{len(bot_messages)}: [{ts}] {text_preview}")
sys.stdout.flush()
if delete_message(token, channel, ts):
deleted += 1
else:
skipped += 1
# Slack rate limit: ~50 requests per minute for chat.delete (Tier 3)
# Sleep briefly between deletions to stay well within limits.
time.sleep(1.2)
print()
print()
print(f"Done. Deleted {deleted}, skipped {skipped} (already gone).")
if __name__ == "__main__":
main()

106
utilities/delete_message.py Normal file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Delete a single Slack message by its message URL.
Usage:
python delete_message.py <message_url> # dry run (default)
python delete_message.py <message_url> --dry-run # explicit dry run
python delete_message.py <message_url> --real # actually delete
Message URL format (copy from Slack via "Copy link"):
https://yourworkspace.slack.com/archives/C012AB3CD/p1234567890123456
Environment:
SLACK_BOT_TOKEN — Bot User OAuth Token (xoxb-...)
Required Slack scopes:
chat:write — delete messages posted by this bot
"""
import os
import re
import sys
import requests
SLACK_DELETE_URL = "https://slack.com/api/chat.delete"
def parse_message_url(url):
"""Extract channel ID and message timestamp from a Slack message URL.
Slack message URLs look like:
https://workspace.slack.com/archives/C012AB3CD/p1234567890123456
https://workspace.slack.com/archives/C012AB3CD/p1234567890123456?thread_ts=...
The 'p' prefix on the timestamp is Slack's URL encoding — the actual ts
is the digits with a dot inserted: p1234567890123456 -> 1234567890.123456
"""
pattern = r"archives/([A-Z0-9]+)/p(\d{10})(\d{6})"
match = re.search(pattern, url)
if not match:
return None, None
channel_id = match.group(1)
ts = f"{match.group(2)}.{match.group(3)}"
return channel_id, ts
def delete_message(token, channel, ts):
"""Call chat.delete to remove a message."""
response = requests.post(
SLACK_DELETE_URL,
headers={"Authorization": f"Bearer {token}"},
json={"channel": channel, "ts": ts},
timeout=10,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Slack API error: {data.get('error')}")
return data
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print(__doc__.strip())
sys.exit(0)
url = sys.argv[1]
flags = sys.argv[2:]
real_mode = "--real" in flags
# --dry-run is the default, but accept it explicitly too
if "--dry-run" in flags and "--real" in flags:
print("Error: --dry-run and --real are mutually exclusive.")
sys.exit(1)
channel_id, ts = parse_message_url(url)
if not channel_id or not ts:
print(f"Error: Could not parse message URL: {url}")
print()
print("Expected format:")
print(" https://yourworkspace.slack.com/archives/C012AB3CD/p1234567890123456")
sys.exit(1)
token = os.getenv("SLACK_BOT_TOKEN")
if not token:
print("Error: SLACK_BOT_TOKEN environment variable is not set.")
sys.exit(1)
print(f"Channel: {channel_id}")
print(f"Message: {ts}")
print()
if not real_mode:
print("[DRY RUN] Would delete message {ts} from channel {channel_id}.".format(
ts=ts, channel_id=channel_id,
))
print("Pass --real to actually delete.")
return
print(f"Deleting message {ts} from channel {channel_id}...")
delete_message(token, channel_id, ts)
print("Deleted.")
if __name__ == "__main__":
main()

View File

@@ -1,2 +1 @@
requests==2.32.3
pytz==2024.1