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
|
.DS_Store
|
||||||
__pycache__
|
__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
|
## 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.
|
- `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
|
requests==2.32.3
|
||||||
pytz==2024.1
|
|
||||||
Reference in New Issue
Block a user