Compare commits
20 Commits
fix-golly-
...
remove-gol
| Author | SHA1 | Date | |
|---|---|---|---|
| a75748073c | |||
| f9ed9f63e9 | |||
| 84a89adabf | |||
| 53133e4074 | |||
| 8487bb5b48 | |||
| 823304964a | |||
| 3e787962fa | |||
| 782e98e8f2 | |||
| af077bbe9d | |||
| 265e099cec | |||
| 493cef12d9 | |||
| cde1af1ce9 | |||
| 4fe755c781 | |||
| 7f83f21319 | |||
| 50d61c6b34 | |||
| 379c2cc80b | |||
| cb0453a518 | |||
| d6a8244a93 | |||
| dffd5b6a5f | |||
| d4c2c48633 |
31
.github/workflows/golly-hellmouth-cup.yml
vendored
31
.github/workflows/golly-hellmouth-cup.yml
vendored
@@ -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
|
||||
31
.github/workflows/golly-star-cup.yml
vendored
31
.github/workflows/golly-star-cup.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.claude
|
||||
.DS_Store
|
||||
__pycache__
|
||||
.worktrees/
|
||||
.worktrees
|
||||
utilities/.environment
|
||||
|
||||
@@ -19,5 +19,6 @@ 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.
|
||||
- `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.
|
||||
- `golly-notifier/` - **Golly Postseason Notifications** — Tracks the [Golly](https://golly.life) postseason and posts game results, series updates, and schedule notifications. Two bot personas: **Star Cup** (Tuesday–Wednesday) and **Hellmouth Cup** (Friday–Sunday). Each checks the Golly API hourly during its window, reporting series starts, daily scores with replay links, series clinches, and cup winners. Scheduling: Star runs 5 AM–1 AM PT Tue, 5 AM–5 PM PT Wed; Hellmouth runs 8 AM–4 PM PT Fri, 8 AM–8 PM PT Sat–Sun.
|
||||
|
||||
|
||||
@@ -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 |
@@ -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])
|
||||
4
utilities/.environment.example
Normal file
4
utilities/.environment.example
Normal 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
147
utilities/README.md
Normal 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
206
utilities/clear_channel.py
Normal 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
106
utilities/delete_message.py
Normal 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()
|
||||
@@ -1,2 +1 @@
|
||||
requests==2.32.3
|
||||
pytz==2024.1
|
||||
Reference in New Issue
Block a user