14 Commits

Author SHA1 Message Date
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
14 changed files with 855 additions and 74 deletions

View File

@@ -5,7 +5,7 @@ on:
# 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'
- cron: '1 * * * 2,3'
workflow_dispatch:
jobs:

4
.gitignore vendored
View File

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

View File

@@ -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** (TuesdayWednesday) and **Hellmouth Cup** (FridaySunday). 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 AM1 AM PT Tue, 5 AM5 PM PT Wed; Hellmouth runs 8 AM4 PM PT Fri, 8 AM8 PM PT SatSun.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,53 @@
star notifications:
- loop for League Division Series and League Championship Series starts running at 5 AM Pacific Tuesday, runs every hour for 20 hours
- loop for Star Cup Series starts running at 5 AM Pacific Wednesday, runs every hour for 12 hours
hellmouth notifications:
- loop for League Division Series starts running at 10 AM Pacific Friday, runs every hour for 8 hours
- loop for League Championship Series starts running at 8 AM Pacific Saturday, runs every hour for 12 hours
- loop for Hellmouth Cup Series starts running at 8 AM Pacific Sunday, runs every hour for 12 hours
general notification rules:
- if mode 21:
- do nothing
- if mode 31:
- if it has been mode 31 for less than 3600 seconds, send a notification that the League Division Series is starting
- otherwise, print two notifications:
- zeroth, determine how many hours have elapsed. that is the zero-indexed Day. you must report the one-indexed Day in the notification.
- for example, if 3700 seconds have elapsed since mode 31, it is Day 2.
- first, determine the outcome of the game from the prior day. create a notification with that score (plus links to those games).
- second, post a notification about the ongoing games for the current day (plus links to those games).
- if mode 22:
- if it has been mode 22 for less than 3600 seconds, send a notification of who won the League Division Series
- otherwise, do not send any notification at all
- if mode 32:
- if it has been mode 32 for less than 3600 seconds, send a notification that the League Championship Series is starting
- otherwise, print two notifications:
- zeroth, determine how many hours have elapsed. that is the zero-indexed Day. you must report the one-indexed Day in the notification.
- for example, if 3700 seconds have elapsed since mode 32, it is Day 2.
- first, determine the outcome of the game from the prior day. create a notification with that score (plus links to those games).
- second, post a notification about the ongoing games for the current day (plus links to those games).
- if mode 23:
- if it has been mode 23 for less than 3600 seconds, send a notification of who won the League Championship Series
- otherwise, do not send any notification at all
- if mode 33:
- if it has been mode 33 for less than 3600 seconds, send a notification that the Star VII Cup Series is starting
- otherwise, send two notifications:
- zeroth, determine how many hours have elapsed. that is the zero-indexed Day. you must report the one-indexed Day in the notification.
- for example, if 3700 seconds have elapsed since mode 33, it is Day 2.
- first, determine the outcome of the game from the prior day. create a notification with that score (plus links to those games).
- second, post a notification about the ongoing games for the current day (plus links to those games).
- if mode 40:
- if it has been mode 40 for less than 3600 seconds, send a notification of who won the Cup Series
- otherwise, do not send any notification at all

View File

@@ -14,9 +14,12 @@ CUPS = {
"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,
"bot_icon_url": "https://git.charlesreid1.com/charlesreid1/slack-tv/raw/branch/main/golly-notifier/star-cup-icon.png",
# weekday(): 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri, 5=Sat, 6=Sun
"schedule": {
1: {"start_hour": 5, "duration": 20}, # Tuesday: LDS+LCS, 5 AM for 20h
2: {"start_hour": 5, "duration": 12}, # Wednesday: Cup Series, 5 AM for 12h
},
},
"hellmouth": {
"api_base": "https://cloud.vii.golly.life",
@@ -24,17 +27,21 @@ CUPS = {
"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,
"bot_icon_url": "https://git.charlesreid1.com/charlesreid1/slack-tv/raw/branch/main/golly-notifier/hellmouth-cup-icon.png",
# weekday(): 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri, 5=Sat, 6=Sun
"schedule": {
4: {"start_hour": 8, "duration": 8}, # Friday: LDS, 8 AM for 8h
5: {"start_hour": 8, "duration": 12}, # Saturday: LCS, 8 AM for 12h
6: {"start_hour": 8, "duration": 12}, # Sunday: Cup Series, 8 AM for 12h
},
},
}
SERIES_NAMES = {
"LDS": "Division Series",
"LCS": "League Championship Series",
"LCS": "Championship Series",
"HCS": "Hellmouth VII Cup",
"SCS": "Star Cup Series",
"SCS": "Star VII Cup",
}
MODE_TO_SERIES = {
@@ -44,19 +51,25 @@ MODE_TO_SERIES = {
}
# ---------------------------------------------------------------------------
# Slack helpers
# ---------------------------------------------------------------------------
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):
def send_slack_message(token, channel, text, username, icon_url, attachments=None):
payload = {
"channel": channel,
"text": text,
"username": username,
"icon_url": icon_url,
}
if attachments:
payload["attachments"] = attachments
response = requests.post(
SLACK_API_URL,
headers={"Authorization": f"Bearer {token}"},
@@ -70,14 +83,6 @@ def send_slack_message(token, channel, text, username, icon_url):
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", "")
@@ -87,22 +92,251 @@ def game_link(site_base, game):
return f"<{url}|{label}>"
def game_link_url(site_base, game):
game_id = game.get("id", "")
return f"{site_base}/simulator/index.html?gameId={game_id}"
def game_link_label(game):
desc = game.get("description", "")
match = re.match(r"Game (\d+)", desc)
return f"Game {match.group(1)}" if match else "View Simulation"
# ---------------------------------------------------------------------------
# Block Kit card builders (mimic vii.golly.life game card style)
#
# Design reference (from the site's HTML/CSS):
# - Dark card background (#272b30, Bootswatch Slate)
# - Team 1 name + (W-L) record, score right-aligned
# - Team 2 name + (W-L) record, score right-aligned
# - Winner is bold, loser is normal weight
# - Map name + generation count below
# - Green "View Simulation" button (#62c462)
# - Sidebar color = winning team's color
#
# Slack constraints:
# - No colored text in Block Kit, so we use *bold* for the winner
# - Colored sidebar via attachment "color" field
# - Section blocks with fields for the two-column team/score layout
# ---------------------------------------------------------------------------
def _finished_game_attachment(g, site_base=None):
"""Build a Slack attachment with Block Kit blocks for a finished game.
Mirrors the vii.golly.life finished-game-card layout:
Team 1 Name (W-L) score
Team 2 Name (W-L) score
Map: <map name>
<generations> Generations
[View Simulation]
"""
t1_score = g.get("team1Score", 0)
t2_score = g.get("team2Score", 0)
t1_color = g.get("team1Color", "#aaaaaa")
t2_color = g.get("team2Color", "#aaaaaa")
# Determine winner for bold styling and sidebar color
if t1_score > t2_score:
winner_color = t1_color
t1_fmt = "*{name}*"
t2_fmt = "{name}"
else:
winner_color = t2_color
t1_fmt = "{name}"
t2_fmt = "*{name}*"
# Series win-loss records (pre-game, as shown on the card)
w1, l1 = g.get("team1SeriesWinLoss", [0, 0])
w2, l2 = g.get("team2SeriesWinLoss", [0, 0])
t1_record = f" ({w1}-{l1})" if w1 or l1 else ""
t2_record = f" ({w2}-{l2})" if w2 or l2 else ""
t1_label = t1_fmt.format(name=g["team1Name"]) + t1_record
t2_label = t2_fmt.format(name=g["team2Name"]) + t2_record
map_name = g.get("mapName", "Unknown")
generations = g.get("generations", 0)
blocks = [
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": t1_label},
{"type": "mrkdwn", "text": f"*{t1_score}*" if t1_score > t2_score else str(t1_score)},
],
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": t2_label},
{"type": "mrkdwn", "text": f"*{t2_score}*" if t2_score > t1_score else str(t2_score)},
],
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f"Map: {map_name}"},
{"type": "mrkdwn", "text": f"{generations} Generations"},
],
},
]
if site_base:
url = game_link_url(site_base, g)
label = "View Simulation"
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": label},
"url": url,
"style": "primary",
},
],
})
# Plain text fallback for notifications / non-Block-Kit clients
fallback = (
f"{g['team1Name']} {t1_score} - {g['team2Name']} {t2_score} "
f"| Map: {map_name} | {generations} Gen"
)
return {
"color": winner_color,
"blocks": blocks,
"fallback": fallback,
}
def _upcoming_game_attachment(g, site_base=None):
"""Build a Slack attachment for an upcoming/in-progress game (no scores yet).
Mirrors the vii.golly.life scheduled/in-progress game card:
Team 1 Name (W-L)
Team 2 Name (W-L)
Map: <map name>
[View Simulation]
"""
w1, l1 = g.get("team1SeriesWinLoss", [0, 0])
w2, l2 = g.get("team2SeriesWinLoss", [0, 0])
t1_record = f" ({w1}-{l1})" if w1 or l1 else ""
t2_record = f" ({w2}-{l2})" if w2 or l2 else ""
t1_label = g["team1Name"] + t1_record
t2_label = g["team2Name"] + t2_record
map_name = g.get("mapName", "Unknown")
blocks = [
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*{t1_label}*\n*{t2_label}*"},
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f"Map: {map_name}"},
],
},
]
if site_base:
url = game_link_url(site_base, g)
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "View Simulation"},
"url": url,
"style": "primary",
},
],
})
fallback = f"{g['team1Name']} vs. {g['team2Name']} | Map: {map_name}"
# Use team1's color for the sidebar on upcoming games
sidebar_color = g.get("team1Color", "#3a3f44")
return {
"color": sidebar_color,
"blocks": blocks,
"fallback": fallback,
}
def _header_attachment(header_text):
"""Simple styled header as an attachment (for series titles, day headers)."""
return {
"color": "#3a3f44",
"blocks": [
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*{header_text}*"},
},
],
"fallback": header_text,
}
def _series_clinch_attachment(winner, winner_wl, loser, loser_wl, winner_color):
"""Styled announcement that a team has won the series."""
text = f"*{winner}* ({winner_wl}) wins the series over {loser} ({loser_wl})"
return {
"color": winner_color,
"blocks": [
{
"type": "section",
"text": {"type": "mrkdwn", "text": text},
},
],
"fallback": f"{winner} ({winner_wl}) wins the series over {loser} ({loser_wl})",
}
# ---------------------------------------------------------------------------
# Notification builders
#
# Each returns a list of message dicts: {"text": str, "attachments": list}
# A single Slack API call sends one message dict.
# ---------------------------------------------------------------------------
def format_matchup(g, site_base=None):
"""Build a single attachment for a finished game result."""
return _finished_game_attachment(g, site_base)
def format_current_games(current_games, site_base=None):
lines = []
"""Build a list of attachments for today's upcoming/in-progress games."""
attachments = []
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
attachments.append(_upcoming_game_attachment(g, site_base))
return attachments
def updated_series_wl(game):
"""Return (team1_wins, team1_losses, team2_wins, team2_losses) accounting for this game's outcome."""
w1, l1 = game["team1SeriesWinLoss"]
w2, l2 = game["team2SeriesWinLoss"]
if game["team1Score"] > game["team2Score"]:
w1 += 1
l2 += 1
else:
w2 += 1
l1 += 1
return w1, l1, w2, l2
def build_notification(cup_config, mode_data, postseason, current_games=None):
"""Build styled Slack messages for the current game state.
Returns a list of message dicts, each with:
- "text": plain-text fallback
- "attachments": list of Slack attachments with Block Kit blocks
"""
mode = mode_data["mode"]
elapsed = mode_data.get("elapsed", 0)
cup_name = cup_config["cup_name"]
@@ -114,35 +348,35 @@ def build_notification(cup_config, mode_data, postseason, current_games=None):
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)
# hours elapsed = zero-indexed day; report one-indexed day
# e.g., 3700s elapsed -> 3700//3600 = 1 -> Day 2
series_day = (elapsed // 3600) + 1
series_data = postseason.get(series_key, [])
if just_entered and series_day == 1:
if just_entered:
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()]
header_text = f"{cup_name}: {series_name} is starting now! {site_link}".strip()
attachments = [_header_attachment(header_text)]
if current_games:
lines.extend(format_current_games(current_games))
return ["\n".join(lines)]
attachments.extend(format_current_games(current_games, site_base))
return [{"text": header_text, "attachments": attachments}]
messages = []
if just_entered and series_data:
yesterday_games = series_data[-1]
outcome_lines = [f"*{series_name} Results*"]
# Yesterday's results: use day-based index (series_day - 2 for 0-indexed prior day)
yesterday_idx = series_day - 2
if series_data and 0 <= yesterday_idx < len(series_data):
yesterday_games = series_data[yesterday_idx]
header = f"{series_name} \u2014 Day {series_day - 1} Results"
result_attachments = [_header_attachment(header)]
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}")
result_attachments.append(format_matchup(g, site_base))
today_pairs = set()
if current_games:
@@ -153,20 +387,26 @@ def build_notification(cup_config, mode_data, postseason, current_games=None):
for g in yesterday_games:
pair = tuple(sorted([g["team1Name"], g["team2Name"]]))
if pair not in today_pairs:
w1, _ = g["team1SeriesWinLoss"]
w2, _ = g["team2SeriesWinLoss"]
w1, l1, w2, l2 = updated_series_wl(g)
if w1 > w2:
winner, loser, wl = g["team1Name"], g["team2Name"], f"{w1}-{w2}"
winner, winner_wl = g["team1Name"], f"{w1}-{l1}"
loser, loser_wl = g["team2Name"], f"{w2}-{l2}"
winner_color = g.get("team1Color", "#62c462")
else:
winner, loser, wl = g["team2Name"], g["team1Name"], f"{w2}-{w1}"
outcome_lines.append(f"{winner} wins the series over {loser} ({wl})")
winner, winner_wl = g["team2Name"], f"{w2}-{l2}"
loser, loser_wl = g["team1Name"], f"{w1}-{l1}"
winner_color = g.get("team2Color", "#62c462")
result_attachments.append(
_series_clinch_attachment(winner, winner_wl, loser, loser_wl, winner_color)
)
messages.append("\n".join(outcome_lines))
messages.append({"text": header, "attachments": result_attachments})
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))
header = f"{series_name} \u2014 Day {series_day} Games"
upcoming_attachments = [_header_attachment(header)]
upcoming_attachments.extend(format_current_games(current_games, site_base))
messages.append({"text": header, "attachments": upcoming_attachments})
return messages
@@ -179,7 +419,7 @@ def build_notification(cup_config, mode_data, postseason, current_games=None):
if mode == 23:
if not just_entered:
return []
result = announce_series_outcome(postseason, "LCS", "League Championship Series")
result = announce_series_outcome(postseason, "LCS", "Championship Series")
return [result] if result else []
if mode == 40:
@@ -192,26 +432,35 @@ def build_notification(cup_config, mode_data, postseason, current_games=None):
def announce_series_outcome(postseason, series_key, series_name):
"""Build a styled message announcing the final outcome of a series."""
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)
header = f"{series_name} Results"
attachments = [_header_attachment(header)]
for game in last_day:
w1, l1, w2, l2 = updated_series_wl(game)
if w1 > w2:
winner, winner_wl = game["team1Name"], f"{w1}-{l1}"
loser, loser_wl = game["team2Name"], f"{w2}-{l2}"
winner_color = game.get("team1Color", "#62c462")
else:
winner, winner_wl = game["team2Name"], f"{w2}-{l2}"
loser, loser_wl = game["team1Name"], f"{w1}-{l1}"
winner_color = game.get("team2Color", "#62c462")
attachments.append(
_series_clinch_attachment(winner, winner_wl, loser, loser_wl, winner_color)
)
return {"text": header, "attachments": attachments}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def run(cup_key):
token = os.getenv("SLACK_BOT_TOKEN")
@@ -231,12 +480,22 @@ def run(cup_key):
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"]:
schedule = cup.get("schedule", {})
if weekday not in schedule:
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.")
day_config = schedule[weekday]
start_hour = day_config["start_hour"]
duration = day_config["duration"]
end_hour = start_hour + duration
if now_pt.hour < start_hour:
print(f" Too early (hour={now_pt.hour}, start={start_hour}). Skipping.")
return
if now_pt.hour >= end_hour:
print(f" Past window (hour={now_pt.hour}, end={end_hour}). Skipping.")
return
api_base = cup["api_base"]
@@ -253,8 +512,10 @@ def run(cup_key):
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"])
text = msg["text"]
attachments = msg.get("attachments")
print(f" Sending message {i}/{len(messages)}: {text[:80]}")
send_slack_message(token, channel, text, cup["bot_username"], cup["bot_icon_url"], attachments)
print(f" Sent message {i}.")
else:
print(" No notification to send.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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

@@ -0,0 +1 @@
requests==2.32.3