Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
652 lines (587 loc) · 26.5 KB
/
Copy pathapp.py
File metadata and controls
652 lines (587 loc) · 26.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
import os
import re
import time
import logging
from datetime import datetime, timezone
from dotenv import load_dotenv
import random
from slack_bolt import App
from slack_bolt.adapter.flask import SlackRequestHandler
from flask import Flask, request
from listeners import register_listeners
from helpers import (
is_admin,
get_team_points,
get_all_teams,
get_user_team,
get_user_name,
get_challenges,
get_random_challenge,
get_challenges_left_by_team,
get_unclaimed_challenge_keys,
get_unclaimed_challenge_keys_for_team,
get_unclaimed_challenges_for_team_at_points,
get_unique_prefixes,
get_challenges_by_prefix,
get_pending_count,
add_submission,
get_submission_by_id,
update_submission_status,
reset_ledger,
reset_submissions,
reset_queue,
clear_cache,
)
from sheets import get_members_ws, get_submissions_ws, get_challenges_ws, get_ledger_ws, get_queue_ws
logging.basicConfig(level=logging.INFO)
# Initialization
load_dotenv()
app = App(
token=os.environ.get("SLACK_BOT_TOKEN"),
signing_secret=os.environ.get("SLACK_SIGNING_SECRET"),
)
CHALLENGE_CHANNEL_ID = os.environ["CHALLENGE_CHANNEL_ID"]
REVIEW_CHANNEL_ID = os.environ["REVIEW_CHANNEL_ID"]
# Worksheets are shared with the listeners via sheets.py so each tab is looked up once
members_ws = get_members_ws()
submissions_ws = get_submissions_ws()
challenges_ws = get_challenges_ws()
ledger_ws = get_ledger_ws()
queue_ws = get_queue_ws()
# Message deduplication: track processed message timestamps to prevent duplicate processing
# This is important for Render deployments where the service may wake up and process events multiple times
_processed_messages = set()
_MAX_PROCESSED_MESSAGES = 1000 # Limit size to prevent memory issues
def _normalize(text: str) -> str:
"""Collapse multiple spaces, strip, lowercase. Makes commands tolerant of typos/spaces."""
return " ".join((text or "").split()).strip().lower()
def _format_standings(client, channel_id, thread_ts=None):
"""Format and post team standings. Excludes 'admin' team."""
points = get_team_points(ledger_ws)
teams = get_all_teams(members_ws)
# Exclude admin from leaderboard
teams = [t for t in teams if t.lower() != "admin"]
# Include teams with 0 points
for team in teams:
if team not in points:
points[team] = 0
# Filter out admin from points too
points = {t: p for t, p in points.items() if t.lower() != "admin"}
sorted_teams = sorted(points.items(), key=lambda x: -x[1])
lines = [f"• *{team}*: {pts} pts" for team, pts in sorted_teams]
text = "*Leaderboard*\n\n" + "\n".join(lines) if lines else "*Leaderboards*\n\nNo teams made"
kwargs = {"channel": channel_id, "text": text}
if thread_ts:
kwargs["thread_ts"] = thread_ts
client.chat_postMessage(**kwargs)
def _resolve_team_from_list(team_input: str, team_list: list[str]) -> str | None:
"""Find team in list matching team_input (case-insensitive). Returns canonical name or None."""
team_lower = (team_input or "").strip().lower()
if not team_lower:
return None
for t in team_list:
if t.lower() == team_lower:
return t
return None
def _resolve_team_case_insensitive(team_input: str, known_teams: dict) -> str | None:
"""Find team in known_teams matching team_input (case-insensitive). Returns canonical name or None."""
team_lower = (team_input or "").strip().lower()
if not team_lower:
return None
for key in known_teams:
if key.lower() == team_lower:
return key
return None
def _parse_challenges_left_args(rest: str) -> tuple[str | None, int | None]:
"""Parse 'challenges left [team] [points]' -> (team_name or None, point_filter or None)."""
rest = (rest or "").strip()
if not rest:
return None, None
parts = rest.split()
point_filter = None
# Check if last token is a number (or second-to-last if last is 'pts')
if parts:
last = parts[-1].lower()
if last == "pts" and len(parts) > 1:
try:
point_filter = int(parts[-2])
parts = parts[:-2]
except (ValueError, IndexError):
pass
else:
try:
point_filter = int(parts[-1])
parts = parts[:-1]
except (ValueError, IndexError):
pass
team_name = " ".join(parts).strip() if parts else None
return team_name or None, point_filter
def _format_challenges_left(team_name: str | None, user_id: str, point_filter: int | None = None) -> str:
"""Format challenges left for a team. point_filter restricts to a specific point value."""
left = get_challenges_left_by_team(challenges_ws, submissions_ws, members_ws)
if team_name:
team = _resolve_team_case_insensitive(team_name, left)
if team is None:
return f"Me when I spell wrong - *{team_name.strip()}* doesn't exist lol"
else:
team = get_user_team(members_ws, user_id)
if not team:
return "IVP oopsie, seems like you are not on a team... Try using 'challenges left [team name]'"
by_points = left.get(team, {})
if not by_points:
return f"*{team}* Awk no challenges configured?"
# Exclude negative points; optionally filter to specific point value
items = [(pts, count) for pts, count in by_points.items() if pts >= 0 and count > 0]
if point_filter is not None:
items = [(pts, count) for pts, count in items if pts == point_filter]
lines = [f" {pts} points: {count} remaining" for pts, count in sorted(items)]
if not lines:
if point_filter is not None:
return f"*{team}* – No {point_filter}-pt challenges left. YAYYY"
return f"*{team}* ABSOLUTELY FANTASTICC!!! THIS IS WHAT CODEBASE IS ALL ABOUT!!!! CONGRATS ON COMPLETIING ALL THE CHALLENGESSSSSS"
suffix = f" ({point_filter} points)" if point_filter is not None else ""
out = f"*{team}* – challenges left{suffix}:\n" + "\n".join(lines)
# When point filter set, show first 5 with challenge_name and min_num
if point_filter is not None:
unclaimed = get_unclaimed_challenges_for_team_at_points(challenges_ws, submissions_ws, team, point_filter)
if unclaimed:
detail_lines = []
for i, c in enumerate(unclaimed[:5], 1):
name = (c.get("challenge_name") or "?").strip()
min_num = c.get("min_num", "?")
detail_lines.append(f" {i}. {name} (min {min_num} ppl)")
out += "\n\n*First 5:*\n" + "\n".join(detail_lines)
return out
def _update_queue_message(client, force_new: bool = False):
"""Create or update the single review queue message."""
from helpers import update_queue_message
from sheets import get_queue_ws, get_submissions_ws
update_queue_message(client, get_queue_ws(), get_submissions_ws(), REVIEW_CHANNEL_ID, force_new=force_new)
def _process_challenge_message(client, channel_id: str, message_ts: str, user_id: str,
text_raw: str, files: list, logger) -> bool:
"""React to a challenge submission and add it to the queue. Returns True if added."""
m = re.match(r"(?i)challenges?\s*(.*)", (text_raw or "").strip())
member_text = (m.group(1).strip() if m else "").strip() or "_No description_"
media_urls = []
for f in files or []:
url = f.get("url_private") or f.get("permalink", "")
if url:
media_urls.append({"url": url, "mimetype": f.get("mimetype", "")})
photo_url_storage = "|".join(m.get("url", "") for m in media_urls)
message_url = f"{channel_id}|{message_ts}"
submission_id = f"SUB-{message_ts}"
team = get_user_team(members_ws, user_id)
if not team:
return False
try:
num = random.randint(0, 1)
client.reactions_add(
channel=channel_id,
timestamp=message_ts,
name=["codecomp-1", "codecomp-2"][num],
)
except Exception as e:
logger.warning("Failed to add reaction: %s", e)
add_submission(
submissions_ws,
submission_id=submission_id,
slack_user_id=user_id,
team=team,
member_text=member_text,
message_url=message_url,
photo_url=photo_url_storage,
challenge_key="",
points=0,
)
return True
def _backfill_submissions(client, logger) -> tuple[int, set[str]]:
"""Queue challenge-channel submissions posted before the bot joined.
Returns (number added, user IDs skipped because they have no team)."""
# Read existing submission IDs once instead of once per message
existing = set(submissions_ws.col_values(1))
added = 0
no_team = set()
messages = []
cursor = None
while True:
resp = client.conversations_history(channel=CHALLENGE_CHANNEL_ID, limit=200, cursor=cursor)
messages.extend(resp.get("messages", []))
cursor = (resp.get("response_metadata") or {}).get("next_cursor")
if not cursor:
break
# Slack returns newest first; go oldest first so the queue matches posting order
for msg in reversed(messages):
subtype = msg.get("subtype")
if (subtype is not None and subtype != "file_share") or msg.get("bot_id"):
continue
ts = msg.get("ts", "")
msg_user = msg.get("user", "")
text_raw = (msg.get("text") or "").strip()
files = msg.get("files") or []
if not ts or not msg_user or not files or not _normalize(text_raw).startswith("challenge"):
continue
if f"SUB-{ts}" in existing:
continue
if _process_challenge_message(client, CHALLENGE_CHANNEL_ID, ts, msg_user, text_raw, files, logger):
existing.add(f"SUB-{ts}")
added += 1
# Stay under the Google Sheets write quota (60/min) and Slack's reactions rate limit
time.sleep(1.1)
else:
no_team.add(msg_user)
return added, no_team
def _mark_deleted_submission(client, message_ts: str, logger) -> None:
"""Mark a PENDING submission ERROR when its original message is deleted."""
submission_id = f"SUB-{message_ts}"
try:
submission = get_submission_by_id(submissions_ws, submission_id)
if not submission or (submission.get("status") or "").strip().lower() != "pending":
return
update_submission_status(submissions_ws, submission_id, "ERROR", "BOT (message deleted)")
logger.info("Submission %s message deleted; marked ERROR", submission_id)
_update_queue_message(client)
except Exception as e:
logger.warning("Failed to mark deleted submission %s: %s", submission_id, e)
@app.event("message")
def handle_message_events(event, client, logger):
# 0️⃣ Deduplication: Skip if we've already processed this message
# This prevents duplicate responses when the service wakes up on Render
message_ts = event.get("ts")
if not message_ts:
# No timestamp, can't deduplicate - log and continue (shouldn't happen in normal operation)
logger.warning("Message event missing timestamp, skipping deduplication")
elif message_ts in _processed_messages:
logger.debug(f"Skipping duplicate message: {message_ts}")
return
# Add to processed set (with size limit to prevent memory issues)
if message_ts:
if len(_processed_messages) >= _MAX_PROCESSED_MESSAGES:
# Clear set when full to prevent memory issues
_processed_messages.clear()
_processed_messages.add(message_ts)
# 1️⃣ Ignore bot messages and edits – but allow file_share (messages with photo attachments)
subtype = event.get("subtype")
# A deleted submission message drops out of the review queue. A deleted parent that has
# replies arrives as message_changed with a "tombstone" message instead of message_deleted.
deleted_ts = None
if subtype == "message_deleted":
deleted_ts = event.get("deleted_ts")
elif subtype == "message_changed" and (event.get("message") or {}).get("subtype") == "tombstone":
deleted_ts = event["message"].get("ts")
if deleted_ts:
_mark_deleted_submission(client, deleted_ts, logger)
return
if subtype is not None and subtype != "file_share":
return
# 1️⃣b Prevent bot from responding to its own messages
# Check if message is from a bot (bot_id indicates any bot message)
# Note: We still process file_share messages from bots (handled above)
bot_id = event.get("bot_id")
if bot_id and subtype != "file_share":
# This is a bot message (not a file_share), skip it
return
# 2️⃣ Pull out basic fields (normalize for forgiving command matching)
text_raw = (event.get("text") or "").strip()
text = _normalize(text_raw)
channel_id = event.get("channel")
user_id = event.get("user")
files = event.get("files", [])
thread_ts = event.get("thread_ts")
# ---- Admin submit works in ANY channel (check first) ----
if re.match(r"admin\s+submit\s+", text) and is_admin(user_id):
match = re.match(r"admin\s+submit\s+", text)
rest = text[match.end() :] if match else ""
parts = rest.split(maxsplit=1)
team_input = parts[0].strip() if len(parts) >= 1 else ""
member_text = parts[1].strip() if len(parts) >= 2 else ""
if not team_input:
client.chat_postMessage(
channel=channel_id,
text="Usage: `admin submit [team] [description]` (include photo as attachment)",
thread_ts=thread_ts or message_ts,
)
return
teams = get_all_teams(members_ws)
team = _resolve_team_from_list(team_input, teams)
if team is None:
client.chat_postMessage(
channel=channel_id,
text=f"Team *{team_input}* not found. These are the actual teams: {', '.join(teams)}",
thread_ts=thread_ts or message_ts,
)
return
media_urls = []
for f in files or []:
url = f.get("url_private") or f.get("permalink", "")
if url:
media_urls.append({"url": url, "mimetype": f.get("mimetype", "")})
photo_url_storage = "|".join(m.get("url", "") for m in media_urls)
submission_id = f"SUB-ADMIN-{message_ts}"
message_url = ""
try:
add_submission(
submissions_ws,
submission_id=submission_id,
slack_user_id=user_id,
team=team,
member_text=member_text,
message_url=message_url,
photo_url=photo_url_storage,
challenge_key="",
points=0,
)
client.chat_postMessage(
channel=channel_id,
text=f" Admin submission created for *{team}*. Open Review to assign challenge.",
thread_ts=thread_ts or message_ts,
)
except Exception as e:
logger.exception("Admin submit failed")
client.chat_postMessage(
channel=channel_id,
text=f"❌ Failed to create submission: {e}",
thread_ts=thread_ts or message_ts,
)
return
if re.match(r"admin\s+submit\s+", text) and not is_admin(user_id):
client.chat_postMessage(
channel=channel_id,
text="Only admins can submit for another team.",
thread_ts=thread_ts or message_ts,
)
return
# Only listen in challenge channel or review channel for other commands
if channel_id not in (CHALLENGE_CHANNEL_ID, REVIEW_CHANNEL_ID):
return
# ---- Message commands (work in both channels) ----
# Standings/leaderboard - allow typos: standing, standings, leader, leaderboard, leaderbord
if text in ("standings", "standing", "leaderboard", "leader", "leaderbord"):
_format_standings(client, channel_id, thread_ts or message_ts)
return
if text.startswith("challenge randomize") or text.startswith("challenge randomise"):
# Normalize "challenge randomize" / "challenge randomise" + rest
rest = text.replace("challenge randomise", "challenge randomize", 1)
rest = rest.replace("challenge randomize", "", 1)
rest = _normalize(rest)
# Option 1: "challenge randomize" or "challenge randomize available" – no team has completed
# Option 2: "challenge randomize team" or "challenge randomize my team" – user's team hasn't completed
if rest in ("", "available", "unclaimed"):
filter_keys = get_unclaimed_challenge_keys(challenges_ws, submissions_ws)
label = "No team has completed"
elif rest in ("team", "my team"):
team = get_user_team(members_ws, user_id)
if not team:
client.chat_postMessage(
channel=channel_id,
text="Awk... You're not on a team. Try `challenge randomize` or `challenge randomize available` for unclaimed challenges.",
thread_ts=thread_ts or message_ts,
)
return
filter_keys = get_unclaimed_challenge_keys_for_team(challenges_ws, submissions_ws, team)
label = f"*{team}* hasn't completed"
else:
client.chat_postMessage(
channel=channel_id,
text="Try `challenge randomize available` – pick from challenges no team has done. `challenge randomize team` – pick from challenges your team hasn't done.",
thread_ts=thread_ts or message_ts,
)
return
ch = get_random_challenge(challenges_ws, filter_keys=filter_keys)
if not ch:
client.chat_postMessage(
channel=channel_id,
text="No matching challenges. All challenges may already be completed.",
thread_ts=thread_ts or message_ts,
)
else:
name = ch.get("challenge_name", "Unknown")
key = ch.get("challenge_key", "")
pts = ch.get("points", 0)
client.chat_postMessage(
channel=channel_id,
text=f"BOOM BOOM BOOM *Random challenge* ({label}): {name} ({key}) – {pts} pts",
thread_ts=thread_ts or message_ts,
)
return
if text.startswith("challenges left") or text.startswith("challenge left"):
rest = text.replace("challenges left", "", 1).replace("challenge left", "", 1)
rest = _normalize(rest)
team_name, point_filter = _parse_challenges_left_args(rest)
msg = _format_challenges_left(team_name, user_id, point_filter)
client.chat_postMessage(
channel=channel_id,
text=msg,
thread_ts=thread_ts or message_ts,
)
return
# ---- Reset semester (review channel only, admin) ----
if channel_id == REVIEW_CHANNEL_ID and text == "reset semester" and is_admin(user_id):
try:
reset_ledger(ledger_ws)
reset_submissions(submissions_ws)
reset_queue(queue_ws)
_update_queue_message(client, force_new=True)
client.chat_postMessage(
channel=channel_id,
text="✅ Ledger, submissions, and queue reset for new semester. All team points and pending submissions cleared.",
thread_ts=thread_ts or message_ts,
)
except Exception as e:
logger.exception("Reset ledger failed")
client.chat_postMessage(
channel=channel_id,
text=f"❌ Reset failed: {e}",
thread_ts=thread_ts or message_ts,
)
return
# ---- Queue: post/refresh review queue (review channel only, admin) ----
if channel_id == REVIEW_CHANNEL_ID and text in ("queue", "resend queue", "resend review queue") and is_admin(user_id):
try:
# Force posting a brand new queue message with Review button
_update_queue_message(client, force_new=True)
client.chat_postMessage(
channel=channel_id,
text="✅ Review queue message refreshed.",
thread_ts=thread_ts or message_ts,
)
except Exception as e:
logger.exception("Resend queue failed")
client.chat_postMessage(
channel=channel_id,
text=f"❌ Failed to resend queue: {e}",
thread_ts=thread_ts or message_ts,
)
return
# ---- Surprise challenge creation (review channel only, admin) ----
# Usage: "surprise 5 3+ show up to CodeSoccer | free boba"
# Points first, then name; optional "| prize" at the end.
if channel_id == REVIEW_CHANNEL_ID and (text.startswith("surprise ") or text.startswith("suprise ")) and is_admin(user_id):
from helpers import create_surprise_challenge
# Use raw text to preserve casing in the challenge name/prize
raw = (event.get("text") or "").strip()
# Strip the first word ("surprise"/"suprise")
parts_raw = raw.split(maxsplit=1)
if len(parts_raw) < 2:
client.chat_postMessage(
channel=channel_id,
text="Usage: `surprise [points] [challenge name] | [optional prize]`",
thread_ts=thread_ts or message_ts,
)
return
rest = parts_raw[1].strip()
m = re.match(r"(\\d+)\\s+(.+)", rest)
if not m:
client.chat_postMessage(
channel=channel_id,
text="Usage: `surprise [points] [challenge name] | [optional prize]` (points must be a number).",
thread_ts=thread_ts or message_ts,
)
return
points = int(m.group(1))
name_and_prize = m.group(2).strip()
if "|" in name_and_prize:
challenge_name, prize = [s.strip() for s in name_and_prize.split("|", 1)]
else:
challenge_name, prize = name_and_prize, ""
challenge_key = create_surprise_challenge(challenges_ws, challenge_name, points)
# Announce in the challenge channel
desc_part = f"AND {challenge_name} SURPRISE!"
if prize:
desc_part = f"AND {challenge_name} – Prize: {prize} SURPRISE!"
msg = f"<!channel> SURPRISE CHALLENGE COMPLETE THIS WEEK FOR {points} POINTS!!!! {desc_part}"
client.chat_postMessage(channel=CHALLENGE_CHANNEL_ID, text=msg)
# Confirm in review channel with the created key
client.chat_postMessage(
channel=channel_id,
text=f"✅ Created surprise challenge *{challenge_key}* – {points} pts: {challenge_name}",
thread_ts=thread_ts or message_ts,
)
return
# ---- Backfill: queue past challenge-channel submissions the bot never saw (review channel only, admin) ----
if channel_id == REVIEW_CHANNEL_ID and text == "backfill" and is_admin(user_id):
client.chat_postMessage(
channel=channel_id,
text="Scanning the challenge channel history... this can take a minute.",
thread_ts=thread_ts or message_ts,
)
try:
added, no_team = _backfill_submissions(client, logger)
msg = f"✅ Backfill done. Added *{added}* past submission(s) to the review queue."
if no_team:
msg += (f"\nSkipped *{len(no_team)}* from people not in the Members sheet: "
+ ", ".join(f"<@{u}>" for u in sorted(no_team))
+ ". Add them, send `refresh`, then run `backfill` again.")
if added:
_update_queue_message(client, force_new=True)
client.chat_postMessage(channel=channel_id, text=msg, thread_ts=thread_ts or message_ts)
except Exception as e:
logger.exception("Backfill failed")
client.chat_postMessage(
channel=channel_id,
text=f"❌ Backfill failed: {e}",
thread_ts=thread_ts or message_ts,
)
return
# ---- Refresh cached Members/Challenges after editing the sheet by hand (review channel only, admin) ----
if channel_id == REVIEW_CHANNEL_ID and text == "refresh" and is_admin(user_id):
clear_cache()
client.chat_postMessage(
channel=channel_id,
text="✅ Cache cleared. Members and challenges will be re-read from the sheet.",
thread_ts=thread_ts or message_ts,
)
return
if text == "reset semester" and not is_admin(user_id):
client.chat_postMessage(
channel=channel_id,
text="Only admins can reset the semester.",
thread_ts=thread_ts or message_ts,
)
return
# ---- Challenge channel only: challenge submission with photo ----
# If they forget a photo/video, gently remind them.
if channel_id == CHALLENGE_CHANNEL_ID and text.startswith("challenge") and not files:
client.chat_postEphemeral(
channel=channel_id,
user=user_id,
text="Awk... to submit a challenge, send `challenge [description]` **with a photo or video attached**.",
)
return
if channel_id == CHALLENGE_CHANNEL_ID and text.startswith("challenge") and files:
logger.info("Challenge submission detected")
team = get_user_team(members_ws, user_id)
if not team:
client.chat_postMessage(
channel=channel_id,
text="Awk... I couldn't find your team in the Members sheet. Ask an admin to add you first.",
thread_ts=thread_ts or message_ts,
)
return
try:
_process_challenge_message(
client, channel_id, message_ts, user_id,
event.get("text") or "", files, logger,
)
except Exception as e:
logger.exception("Challenge submit failed")
client.chat_postMessage(
channel=channel_id,
text=f"❌ Failed to create submission: {e}",
thread_ts=thread_ts or message_ts,
)
return
# Register Listeners
register_listeners(app)
# Flask + Bolt adapter entrypoint
flask_app = Flask(__name__)
handler = SlackRequestHandler(app)
@flask_app.route("/slack/events", methods=["POST"])
def slack_events():
return handler.handle(request)
@flask_app.route("/health", methods=["GET"])
def health():
"""Pinged by an uptime monitor so Render's free tier doesn't put the app to sleep."""
return "ok", 200
if __name__ == "__main__":
port = int(os.environ.get("PORT", 3000))
flask_app.run(host="0.0.0.0", port=port)