generated from nhcarrigan/template
a40188413a
All Python cohort scripts now use DATA_DIR = Path(__file__).parent.parent.parent / "data" to correctly resolve the repo-root data/ directory regardless of the working directory set by run.sh. All TypeScript scripts have expanded JSDoc headers documenting data file requirements and environment variables.
179 lines
5.7 KiB
Python
179 lines
5.7 KiB
Python
"""Send formatted activity report tables to each team's Discord channel.
|
||
|
||
Parses catch_up_report.md and posts a monospace table of each member's Discord
|
||
and GitHub activity stats to their respective team channel.
|
||
|
||
Data files (place in data/):
|
||
- catch_up_report.md Activity report generated by catch_up_report.py
|
||
|
||
Env vars:
|
||
- DISCORD_BOT_TOKEN Bot token for the Discord API
|
||
"""
|
||
|
||
import asyncio
|
||
import os
|
||
|
||
import aiohttp
|
||
|
||
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||
GUILD_ID = "692816967895220344"
|
||
API_BASE = "https://discord.com/api/v10"
|
||
|
||
CHANNEL_IDS = {
|
||
"Crimson Dahlia": "1464316744909852682",
|
||
"Rose Camellia": "1464316751268286611",
|
||
"Amber Wisteria": "1464316761410113641",
|
||
"Ivory Orchid": "1464316770889240730",
|
||
"Teal Iris": "1464316776459407448",
|
||
"Peach Gardenia": "1464316785040953543",
|
||
"Violet Carnation": "1464316805261824032",
|
||
"Azure Lotus": "1464316814455472139",
|
||
"Coral Sunflower": "1464316819711066263",
|
||
"Indigo Tulip": "1464316826384072925",
|
||
"Scarlet Hydrangea": "1464316839306985506",
|
||
"Mint Narcissus": "1464316844251807952",
|
||
"Sage Marigold": "1464316850669093040",
|
||
}
|
||
|
||
REPORT_PATH = os.path.join(
|
||
os.path.dirname(__file__), "..", "..", "data", "catch_up_report.md"
|
||
)
|
||
|
||
FIELDS = [
|
||
("Discord Username", "Name", 18),
|
||
("Discord Messages", "Msgs", 5),
|
||
("PRs Opened", "PRs", 4),
|
||
("Issues Opened", "Issues", 6),
|
||
("Issue Comments", "Issue♟", 7),
|
||
("PR Comments", "PR♟", 5),
|
||
("PR Reviews", "Reviews", 7),
|
||
("Commits", "Commits", 7),
|
||
]
|
||
|
||
|
||
def parse_report(path: str) -> dict[str, list[dict]]:
|
||
"""Parse the markdown table from catch_up_report.md into team → rows."""
|
||
teams: dict[str, list[dict]] = {}
|
||
with open(path, encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
|
||
header_line = None
|
||
for i, line in enumerate(lines):
|
||
if line.startswith("| Discord ID |"):
|
||
header_line = i
|
||
break
|
||
|
||
if header_line is None:
|
||
raise ValueError("Could not find table header in report")
|
||
|
||
headers = [h.strip() for h in lines[header_line].strip().strip("|").split("|")]
|
||
for line in lines[header_line + 2 :]:
|
||
line = line.strip()
|
||
if not line.startswith("|"):
|
||
break
|
||
row_values = [v.strip() for v in line.strip().strip("|").split("|")]
|
||
row = dict(zip(headers, row_values))
|
||
team = row["Team"]
|
||
teams.setdefault(team, []).append(row)
|
||
|
||
return teams
|
||
|
||
|
||
def format_table(members: list[dict]) -> str:
|
||
"""Format a team's member list as a monospace table for Discord."""
|
||
members = sorted(members, key=lambda r: int(r["Discord Messages"]), reverse=True)
|
||
|
||
col_widths = [width for _, _, width in FIELDS]
|
||
col_headers = [header for _, header, _ in FIELDS]
|
||
|
||
name_col_index = 0
|
||
max_name = max(len(m["Discord Username"]) for m in members)
|
||
col_widths[name_col_index] = max(col_widths[name_col_index], max_name)
|
||
|
||
def pad(val: str, width: int, right_align: bool = False) -> str:
|
||
return val.rjust(width) if right_align else val.ljust(width)
|
||
|
||
header_row = " ".join(
|
||
pad(col_headers[i], col_widths[i], right_align=(i > 0))
|
||
for i in range(len(FIELDS))
|
||
)
|
||
separator = " ".join("-" * w for w in col_widths)
|
||
|
||
rows = []
|
||
for m in members:
|
||
source_keys = [key for key, _, _ in FIELDS]
|
||
values = [m[key] for key in source_keys]
|
||
row = " ".join(
|
||
pad(values[i], col_widths[i], right_align=(i > 0))
|
||
for i in range(len(FIELDS))
|
||
)
|
||
rows.append(row)
|
||
|
||
return "\n".join([header_row, separator] + rows)
|
||
|
||
|
||
async def send_message(
|
||
session: aiohttp.ClientSession, channel_id: str, content: str
|
||
) -> None:
|
||
"""Send a message to a Discord channel."""
|
||
headers = {
|
||
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
url = f"{API_BASE}/channels/{channel_id}/messages"
|
||
while True:
|
||
async with session.post(
|
||
url, json={"content": content}, headers=headers
|
||
) as resp:
|
||
if resp.status == 429:
|
||
data = await resp.json()
|
||
retry_after = data.get("retry_after", 5)
|
||
print(f" Rate limited — sleeping {retry_after}s...")
|
||
await asyncio.sleep(retry_after)
|
||
continue
|
||
if resp.status not in (200, 201):
|
||
text = await resp.text()
|
||
print(f" ERROR {resp.status}: {text}")
|
||
return
|
||
|
||
|
||
async def main() -> None:
|
||
"""Send activity tables to all team channels."""
|
||
teams = parse_report(REPORT_PATH)
|
||
team_names = list(CHANNEL_IDS.keys())
|
||
print(f"Sending activity tables to {len(team_names)} channels...\n")
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
for i, team_name in enumerate(team_names, 1):
|
||
channel_id = CHANNEL_IDS[team_name]
|
||
members = teams.get(team_name, [])
|
||
if not members:
|
||
print(f" [{i}/{len(team_names)}] {team_name} — no data, skipping")
|
||
continue
|
||
|
||
table = format_table(members)
|
||
message = (
|
||
f"**{team_name} — Activity Report (Feb 15–23)**\n```\n{table}\n```"
|
||
)
|
||
|
||
if len(message) > 2000:
|
||
print(
|
||
f" [{i}/{len(team_names)}] {team_name} — WARNING: "
|
||
f"message is {len(message)} chars (over 2000!)"
|
||
)
|
||
|
||
print(
|
||
f" [{i}/{len(team_names)}] Sending to {team_name}... ",
|
||
end="",
|
||
flush=True,
|
||
)
|
||
await send_message(session, channel_id, message)
|
||
print("sent!")
|
||
await asyncio.sleep(1)
|
||
|
||
print("\nAll done! 🌸")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|