From 94d4ba14272a0ef659a55424bf30c42d2beaa08f Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:24:02 -0400 Subject: [PATCH 1/6] test(participation): cover unknown create error keeping the claim and rethrowing --- .../usecases/participate-in-hackathon.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/domain/usecases/participate-in-hackathon.test.ts b/test/domain/usecases/participate-in-hackathon.test.ts index 7ec6c01..d933b8b 100644 --- a/test/domain/usecases/participate-in-hackathon.test.ts +++ b/test/domain/usecases/participate-in-hackathon.test.ts @@ -323,6 +323,20 @@ describe("participateInHackathon: creation failures", () => { expect(retry.kind).toBe("busy"); expect(deps.forumTopicManager.created).toHaveLength(1); }); + + it("keeps the claim and rethrows an unknown create error (the topic may exist)", async () => { + const deps = setup(); + const boom = new TypeError("unexpected create failure"); + deps.forumTopicManager.create = async () => { + throw boom; + }; + + await expect(participateInHackathon(input(), deps)).rejects.toBe(boom); + + expect(deps.hackathonAnalysisRepo.claims.get("a-1")).toBeGreaterThan(deps.clock.now()); + expect(row(deps).threadId).toBeNull(); + expect(deps.chatPublisher.posted).toHaveLength(0); + }); }); describe("participateInHackathon: after the topic exists (never rethrows)", () => { From 812929c8c97e366279728ffc1247a72a5b8b7ca3 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:25:19 -0400 Subject: [PATCH 2/6] feat(hackathon): post the General analysis with the participation button and store its message id --- src/domain/usecases/run-hackathon-job.ts | 48 +++++++--- .../domain/usecases/run-hackathon-job.test.ts | 93 +++++++++++++++++++ 2 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/domain/usecases/run-hackathon-job.ts b/src/domain/usecases/run-hackathon-job.ts index c29a6da..a601d7d 100644 --- a/src/domain/usecases/run-hackathon-job.ts +++ b/src/domain/usecases/run-hackathon-job.ts @@ -10,7 +10,7 @@ import { import { analysisCopy, FETCH_FAILURE_PHRASES } from "../copy"; import { formatAnalysis } from "../hackathon/format"; import { normalizeUrlKey } from "../hackathon/url"; -import type { AnalysisJob, AnalysisJobMessage, JobOutcome } from "../entities"; +import type { AnalysisJob, AnalysisJobMessage, HackathonAnalysis, JobOutcome } from "../entities"; import { analyzeHackathon, type AnalyzeHackathonDeps } from "./analyze-hackathon"; import { postAnalysisAndLinkTopic } from "./link-analysis-to-topic"; import type { AnalysisJobRepo, AnalysisQuota, ChatPublisher, Logger } from "../ports"; @@ -98,11 +98,7 @@ async function postPersistedResult( deps, ); } else { - await deps.chatPublisher.post(job.chatId, null, formatAnalysis({ - slug: analysis.slug, - fields: analysis.fields, - suggestions: analysis.suggestedRepos, - })); + await postToGeneral(job, analysis, deps); } } catch (err) { // RESI-001: postPersistedResult must never throw out of @@ -176,11 +172,7 @@ async function runClaimedJob( deps, ); } else { - await deps.chatPublisher.post(job.chatId, null, formatAnalysis({ - slug: analysis.slug, - fields: analysis.fields, - suggestions: analysis.suggestedRepos, - })); + await postToGeneral(job, analysis, deps); } await deps.analysisJobRepo.markSucceeded(job.id); await deps.analysisQuota.release(job.teamId, job.utcDay, job.id, false); @@ -190,6 +182,40 @@ async function runClaimedJob( } } +// hackathon-participation: the General post carries the participation button +// (a topic post never does) and its message id is stored so the button can be +// removed later. The store is best-effort: the post already went out, so a +// failure here must not fail the job (a retry would repost the analysis). The +// only cost is that the stored message's button is not cleared on join; the +// tapped message's own button still is. +async function postToGeneral( + job: AnalysisJob, + analysis: HackathonAnalysis, + deps: RunHackathonJobDeps, +): Promise { + const messageId = await deps.chatPublisher.post( + job.chatId, + null, + formatAnalysis({ + slug: analysis.slug, + fields: analysis.fields, + suggestions: analysis.suggestedRepos, + }), + { participateSlug: analysis.slug }, + ); + try { + await deps.hackathonAnalysisRepo.setGeneralMessageId(job.teamId, analysis.id, messageId); + } catch (err) { + deps.logger.log({ + event: "hackathon-job", + teamId: job.teamId, + outcome: "error", + errorCode: err instanceof Error ? err.name : "UnknownError", + reason: "general-message-id-store-failed", + }); + } +} + interface JobErrorClassification { transient: boolean; refund: boolean; diff --git a/test/domain/usecases/run-hackathon-job.test.ts b/test/domain/usecases/run-hackathon-job.test.ts index dd6a119..6aa8927 100644 --- a/test/domain/usecases/run-hackathon-job.test.ts +++ b/test/domain/usecases/run-hackathon-job.test.ts @@ -416,6 +416,99 @@ describe("runHackathonJob", () => { expect(deps.analysisJobRepo.succeeded).toEqual(["job-1"]); }); + // hackathon-participation: the General post carries the participation + // button and its message id is stored so the button can be removed later. + it("claimed job in General: posts with the participate button and stores the message id", async () => { + const deps = makeDeps(); + deps.analysisJobRepo = fakeAnalysisJobRepo({ + claimResult: { kind: "claimed", job: baseJob() }, + hackathonAnalysisRepo: deps.hackathonAnalysisRepo, + }); + + const outcome = await runHackathonJob(baseMsg(), 1, deps); + + expect(outcome).toEqual({ kind: "ack" }); + const saved = deps.hackathonAnalysisRepo.rows[0]!; + expect(deps.chatPublisher.postOptions).toEqual([{ participateSlug: saved.slug }]); + // The fake publisher's first message id is 1. + expect(saved.generalMessageId).toBe(1); + }); + + it("claimed job inside a topic: no button and no General message id", async () => { + const deps = makeDeps(); + deps.analysisJobRepo = fakeAnalysisJobRepo({ + claimResult: { kind: "claimed", job: baseJob({ threadId: 500 }) }, + hackathonAnalysisRepo: deps.hackathonAnalysisRepo, + }); + + await runHackathonJob(baseMsg({ threadId: 500 }), 1, deps); + + expect(deps.chatPublisher.postOptions.every((o) => o === undefined)).toBe(true); + expect(deps.hackathonAnalysisRepo.rows[0]?.generalMessageId).toBeNull(); + }); + + it("claimed job in General: a message-id store failure is logged and the job still succeeds without a repost", async () => { + const deps = makeDeps(); + deps.hackathonAnalysisRepo.setGeneralMessageId = async () => { + throw new Error("D1 unavailable"); + }; + deps.analysisJobRepo = fakeAnalysisJobRepo({ + claimResult: { kind: "claimed", job: baseJob() }, + hackathonAnalysisRepo: deps.hackathonAnalysisRepo, + }); + + const outcome = await runHackathonJob(baseMsg(), 1, deps); + + expect(outcome).toEqual({ kind: "ack" }); + expect(deps.chatPublisher.posted).toHaveLength(1); + expect(deps.analysisJobRepo.succeeded).toEqual(["job-1"]); + expect(deps.logger.entries).toContainEqual({ + event: "hackathon-job", + teamId, + outcome: "error", + errorCode: "Error", + reason: "general-message-id-store-failed", + }); + }); + + it("persisted claim in General: reposts with the button and stores the message id", async () => { + const deps = makeDeps(); + const job = baseJob({ status: "persisted", analysisId: "analysis-1" }); + deps.analysisJobRepo = fakeAnalysisJobRepo({ claimResult: { kind: "persisted", job } }); + await deps.hackathonAnalysisRepo.save({ + id: "analysis-1", + teamId, + slug: "meridian", + sourceUrl: job.fetchUrl, + normalizedUrl: "https://example.com/event", + fields: { + name: { value: "Meridian", snippet: "", confidence: 0.9 }, + format: null, + location: null, + teamSize: null, + submissionDeadline: null, + startDate: null, + endDate: null, + resultsDate: null, + prizes: null, + tracks: null, + eligibility: null, + }, + suggestedRepos: [], + threadId: null, + pinnedMessageId: null, + generalMessageId: null, + createdAt: 0, + updatedAt: 0, + }); + + const outcome = await runHackathonJob(baseMsg(), 1, deps); + + expect(outcome).toEqual({ kind: "ack" }); + expect(deps.chatPublisher.postOptions).toEqual([{ participateSlug: "meridian" }]); + expect(deps.hackathonAnalysisRepo.rows[0]?.generalMessageId).toBe(1); + }); + it("transient failure: retries then fails on the final attempt (spec: Transient failure exhausts retries)", async () => { const depsRetry = makeDeps(); const job = baseJob(); From fe92e957a4382e3997c03288a3576cd011139c72 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:27:57 -0400 Subject: [PATCH 3/6] feat(telegram): confirm participation from the hp callback button --- src/adapters/telegram/context.ts | 21 ++ src/adapters/telegram/hackathon-commands.ts | 3 +- src/adapters/telegram/participation.ts | 73 +++++++ test/adapters/telegram/commands.test.ts | 219 +++++++++++++++++++- 4 files changed, 314 insertions(+), 2 deletions(-) diff --git a/src/adapters/telegram/context.ts b/src/adapters/telegram/context.ts index 9cd0fd9..a45e92d 100644 --- a/src/adapters/telegram/context.ts +++ b/src/adapters/telegram/context.ts @@ -23,6 +23,27 @@ export function callerLocation(ctx: Context): CallerLocation | null { }; } +// A callback-query variant of `callerLocation`: the chat and topic are those of +// the message that carried the tapped button (`ctx.msg`), and the user is the +// one who tapped. `callerLocation` is unchanged so commands never widen to +// edited or channel messages. +export interface CallbackLocation extends CallerLocation { + // The message carrying the button, or null when Telegram did not send it. + messageId: number | null; +} + +export function callbackCallerLocation(ctx: Context): CallbackLocation | null { + const chatId = ctx.chat?.id; + const userId = ctx.from?.id; + if (chatId === undefined || userId === undefined) return null; + return { + chatId, + userId, + threadId: ctx.msg?.message_thread_id ?? null, + messageId: ctx.callbackQuery?.message?.message_id ?? null, + }; +} + // Resolves a Telegram (chatId, userId) pair to this team's existing // membership, used by group commands that need the caller's own // membership (e.g. /datachannel's admin check) before delegating to a use diff --git a/src/adapters/telegram/hackathon-commands.ts b/src/adapters/telegram/hackathon-commands.ts index e0c9cef..8a9a093 100644 --- a/src/adapters/telegram/hackathon-commands.ts +++ b/src/adapters/telegram/hackathon-commands.ts @@ -26,7 +26,7 @@ import { runCommand } from "./command-outcome"; import type { DomainErrorReasons, DomainErrorReplies } from "./command-outcome"; import { callerLocation, resolveGroupMembership } from "./context"; import type { CallerLocation } from "./context"; -import { runParticipation } from "./participation"; +import { registerParticipationCallback, runParticipation } from "./participation"; import { isPrivateChat } from "./team-picker"; // `/hackathon` and `/hackathons` (design.md "Data Flow", "Error Taxonomy" — @@ -104,6 +104,7 @@ async function resolveMember(deps: HackathonCommandDeps, loc: CallerLocation) { } export function registerHackathonCommands(bot: Bot, deps: HackathonCommandDeps): void { + registerParticipationCallback(bot, deps); bot.command("hackathon", async (ctx) => { const loc = await requireGroupCaller(ctx, deps, "hackathon"); if (!loc) return; diff --git a/src/adapters/telegram/participation.ts b/src/adapters/telegram/participation.ts index b956a1c..a184179 100644 --- a/src/adapters/telegram/participation.ts +++ b/src/adapters/telegram/participation.ts @@ -1,7 +1,12 @@ +import type { Bot } from "grammy"; import type { TeamId, MembershipId } from "../../domain/ids"; +import type { MemberRepo, MembershipRepo, TeamRepo } from "../../domain/ports"; import { participateInHackathon } from "../../domain/usecases/participate-in-hackathon"; import type { ParticipateInHackathonDeps } from "../../domain/usecases/participate-in-hackathon"; +import { PARTICIPATE_CALLBACK_PREFIX } from "./chat-publisher"; import { participateCopy } from "./copy"; +import { callbackCallerLocation, resolveGroupMembership } from "./context"; +import { isPrivateChat } from "./team-picker"; export interface ParticipationParams { event: string; @@ -78,3 +83,71 @@ export async function runParticipation( }); } } + +// The button's callback data is untrusted: `hp:` plus a slug of at most 40 +// characters (slug.ts MAX_SLUG_LENGTH). The team is never read from it. +const PARTICIPATE_DATA = new RegExp( + `^${PARTICIPATE_CALLBACK_PREFIX}([a-z0-9]+(?:-[a-z0-9]+)*)$`, +); +const MAX_SLUG_LENGTH = 40; +const CALLBACK_EVENT = "hackathon-participate-callback"; + +export interface ParticipationCallbackDeps extends ParticipateInHackathonDeps { + teamRepo: TeamRepo; + memberRepo: MemberRepo; + membershipRepo: MembershipRepo; +} + +function errorCodeOf(err: unknown): string { + return err instanceof Error ? err.name : "UnknownError"; +} + +// Registers the `hp:` handler. Malformed data or a foreign prefix never +// matches; a private or missing chat is ignored. A non-member or non-admin +// gets one alert and nothing changes. An admin's callback is answered early +// (creating a topic can be slow) and the rest is `runParticipation`. Only +// answerCallbackQuery and reply are best-effort: a Telegram failure on them +// must not become a 500 that redelivers the update. +export function registerParticipationCallback(bot: Bot, deps: ParticipationCallbackDeps): void { + bot.callbackQuery(PARTICIPATE_DATA, async (ctx) => { + const slug = PARTICIPATE_DATA.exec(ctx.callbackQuery.data)?.[1]; + const loc = callbackCallerLocation(ctx); + if (slug === undefined || slug.length > MAX_SLUG_LENGTH || !loc || isPrivateChat(ctx)) return; + + const safe = async (action: () => Promise, reason: string): Promise => { + try { + await action(); + } catch (err) { + deps.logger.log({ event: CALLBACK_EVENT, outcome: "error", errorCode: errorCodeOf(err), reason }); + } + }; + + const resolved = await resolveGroupMembership(deps, loc.chatId, loc.userId); + if (!resolved || resolved.membership.role !== "admin") { + deps.logger.log({ + event: CALLBACK_EVENT, + outcome: "refused", + errorCode: resolved ? "UnauthorizedError" : "NotFoundError", + }); + await safe( + () => ctx.answerCallbackQuery({ text: participateCopy.adminOnly, show_alert: true }), + "answer-failed", + ); + return; + } + + await safe(() => ctx.answerCallbackQuery(), "answer-failed"); + await runParticipation( + { + event: CALLBACK_EVENT, + teamId: resolved.team.id, + membershipId: resolved.membership.id, + chatId: loc.chatId, + slug, + callbackMessageId: loc.messageId, + reply: (text) => safe(() => ctx.reply(text), "reply-failed"), + }, + deps, + ); + }); +} diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index 6061c6f..2af13a3 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -2,6 +2,7 @@ import { Bot } from "grammy"; import type { CallbackQuery, Update } from "grammy/types"; import { describe, expect, it, vi } from "vitest"; import { registerCommands } from "../../../src/adapters/telegram/commands"; +import { callbackCallerLocation } from "../../../src/adapters/telegram/context"; import type { HackathonAnalysis } from "../../../src/domain/entities"; import type { TeamId } from "../../../src/domain/ids"; import { createSafeLogger } from "../../../src/adapters/log/safe-logger"; @@ -32,6 +33,8 @@ import { interface HackathonFakeOptions { quota?: "ok" | "busy" | "cap-reached"; queueThrows?: boolean; + // answerCallbackQuery fails on the wire (expired or already answered query). + answerFails?: boolean; } function makeBot( @@ -62,8 +65,12 @@ function makeBot( const payloads: Array> = []; // Text of every answerCallbackQuery alert shown to the user. const alerts: string[] = []; + // Every answerCallbackQuery payload, in call order. + const answers: Array<{ text?: string; show_alert?: boolean }> = []; bot.api.config.use((_prev, method, payload) => { if (method === "answerCallbackQuery") { + answers.push(payload as { text?: string; show_alert?: boolean }); + if (hackathon.answerFails) return Promise.reject(new Error("query is too old")); const text = (payload as { text?: string }).text; if (text !== undefined) alerts.push(text); } @@ -113,7 +120,7 @@ function makeBot( logger: createSafeLogger(), }; registerCommands(bot, deps); - return { bot, replies, payloads, alerts, deps }; + return { bot, replies, payloads, alerts, answers, deps }; } let nextUpdateId = 1; @@ -169,6 +176,31 @@ function callbackUpdate(chatId: number, userId: number, data: string): Update { } as Update; } +// A callback query on a button carried by a group message, with the message +// id and optional topic of that message. +function groupCallbackUpdate( + chatId: number, + userId: number, + data: string, + opts: { messageId?: number; threadId?: number; chatType?: "private" | "supergroup" } = {}, +): Update { + return { + update_id: nextUpdateId++, + callback_query: { + id: `callback-${nextUpdateId}`, + from: { id: userId, is_bot: false, first_name: "User" }, + chat_instance: "test-chat-instance", + data, + message: { + message_id: opts.messageId ?? 4242, + date: 0, + chat: { id: chatId, type: opts.chatType ?? "supergroup", title: "Test group" }, + ...(opts.threadId !== undefined ? { message_thread_id: opts.threadId } : {}), + }, + } as CallbackQuery, + } as Update; +} + describe("registerCommands — routing (telegram-webhook spec)", () => { it("ignores an update that is not a recognized command", async () => { const { bot, replies } = makeBot(); @@ -1565,3 +1597,188 @@ describe("registerCommands — /hackathon join (hackathon-participation s expect(replies.at(-1)?.text).toBe("Uso: /hackathon "); }); }); + +// hackathon-participation (PR2) — callbackCallerLocation. +describe("callbackCallerLocation", () => { + const ctxOf = (over: Record) => over as never; + + it("reads the chat, the tapping user, the button message's topic and its message id", () => { + const loc = callbackCallerLocation( + ctxOf({ + chat: { id: 10 }, + from: { id: 1 }, + msg: { message_thread_id: 77 }, + callbackQuery: { message: { message_id: 4242 } }, + }), + ); + expect(loc).toEqual({ chatId: 10, userId: 1, threadId: 77, messageId: 4242 }); + }); + + it("reports General (null thread) and a null message id when they are absent", () => { + const loc = callbackCallerLocation( + ctxOf({ chat: { id: 10 }, from: { id: 1 }, msg: {}, callbackQuery: {} }), + ); + expect(loc).toEqual({ chatId: 10, userId: 1, threadId: null, messageId: null }); + }); + + it("returns null without a chat or a user", () => { + expect(callbackCallerLocation(ctxOf({ from: { id: 1 } }))).toBeNull(); + expect(callbackCallerLocation(ctxOf({ chat: { id: 10 } }))).toBeNull(); + }); +}); + +// hackathon-participation (PR2) — the hp: callback. +describe("registerCommands — hp: callback (hackathon-participation spec: Two Triggers)", () => { + const ALERT = "Solo un administrador del equipo puede confirmar la participación."; + + it("a non-admin member gets the alert and nothing changes", async () => { + const { bot, deps, teamId, answers } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian", { generalMessageId: 5 })); + + await bot.handleUpdate(groupCallbackUpdate(10, 3, "hp:meridian")); + + expect(answers).toMatchObject([{ text: ALERT, show_alert: true }]); + expect(answers).toHaveLength(1); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.chatPublisher.posted).toHaveLength(0); + expect(deps.chatPublisher.cleared).toHaveLength(0); + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBeNull(); + }); + + it("a non-member gets the same alert", async () => { + const { bot, deps, teamId, answers } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(groupCallbackUpdate(10, 999, "hp:meridian")); + + expect(answers).toMatchObject([{ text: ALERT, show_alert: true }]); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("an admin tap creates the topic, confirms in General and removes the deduped buttons", async () => { + const { bot, deps, teamId, answers } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian", { generalMessageId: 4242 })); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian", { messageId: 4242 })); + + expect(deps.forumTopicManager.created).toEqual([{ chatId: 10, name: "🏆 Hack meridian" }]); + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBe(1000); + expect(deps.chatPublisher.posted.at(-1)).toMatchObject({ + chatId: 10, + threadId: null, + text: "✅ Participamos en Hack meridian", + }); + // Callback message id equals the stored id: one removal, not two. + expect(deps.chatPublisher.cleared).toEqual([{ chatId: 10, messageId: 4242 }]); + // Answered once, silently (no alert text). + expect(answers).toHaveLength(1); + expect(answers[0]?.text).toBeUndefined(); + }); + + it("clears both the tapped message and the stored General message when they differ", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian", { generalMessageId: 9 })); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian", { messageId: 4242, threadId: 3 })); + + expect(deps.chatPublisher.cleared.map((c) => c.messageId).sort((a, b) => a - b)).toEqual([9, 4242]); + }); + + it("answers the callback before creating the topic", async () => { + const { bot, deps, teamId, answers } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + let answeredBeforeCreate = false; + const create = deps.forumTopicManager.create; + deps.forumTopicManager.create = async (chatId, name) => { + answeredBeforeCreate = answers.length === 1; + return create(chatId, name); + }; + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian")); + + expect(answeredBeforeCreate).toBe(true); + }); + + it("a failing answerCallbackQuery does not stop the participation", async () => { + const { bot, deps, teamId } = await hackathonTeam({ answerFails: true }); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian")); + + expect(deps.forumTopicManager.created).toHaveLength(1); + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBe(1000); + }); + + it("a redelivered callback does not create a second topic", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian")); + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian")); + + expect(deps.forumTopicManager.created).toHaveLength(1); + // Chat id 10 has no -100 prefix, so the link is dropped from the text. + expect(deps.chatPublisher.posted.at(-1)?.text).toBe("Este hackathon ya tiene tema."); + }); + + it("an unknown slug replies with the no-analysis text and creates nothing", async () => { + const { bot, deps, replies } = await hackathonTeam(); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:nope")); + + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(replies.at(-1)?.text).toBe("No se encontró ningún análisis con el slug nope."); + }); + + it("derives the team from the chat, never from the payload", async () => { + const ctx = makeBot([TEAM_ADMIN, { chatId: 20, userId: 1 }]); + await ctx.bot.handleUpdate(commandUpdate("setup", 10, 1)); + await ctx.bot.handleUpdate(commandUpdate("setup", 20, 1)); + const otherTeam = ctx.deps.teamRepo.rows.find((t) => t.chatId === 20)!; + ctx.deps.hackathonAnalysisRepo.rows.push(storedAnalysis(otherTeam.id, "meridian")); + + await ctx.bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian")); + + expect(ctx.deps.forumTopicManager.created).toHaveLength(0); + expect(ctx.deps.hackathonAnalysisRepo.rows[0]?.threadId).toBeNull(); + }); + + it.each([ + ["an uppercase/underscore slug", "hp:Bad_Slug"], + ["an empty slug", "hp:"], + ["an over-long slug", `hp:${"a".repeat(41)}`], + ["a path-like payload", "hp:../team"], + ["a foreign prefix", "zz:meridian"], + ["a team id payload", "hp:meridian:team-1"], + ])("ignores %s safely", async (_label, data) => { + const { bot, deps, teamId, answers, replies, baseReplies } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, data)); + + expect(answers).toHaveLength(0); + expect(replies).toHaveLength(baseReplies); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.chatPublisher.posted).toHaveLength(0); + }); + + it("ignores a tap from a private chat", async () => { + const { bot, deps, teamId, answers } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian", { chatType: "private" })); + + expect(answers).toHaveLength(0); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("keeps the team-picker sel: callback working next to hp:", async () => { + const { bot, deps, answers } = await hackathonTeam(); + const teamId = deps.teamRepo.rows[0]!.id; + + await bot.handleUpdate(callbackUpdate(50, 1, `sel:${teamId}`)); + + expect(answers).toHaveLength(1); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); +}); From 74f8924653cfa70117b8fb68fa60b2f8f81db6a6 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:28:53 -0400 Subject: [PATCH 4/6] test(http): cover the hp callback through the webhook route --- test/http/hackathon-command-e2e.test.ts | 151 ++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/test/http/hackathon-command-e2e.test.ts b/test/http/hackathon-command-e2e.test.ts index 4232602..09400ac 100644 --- a/test/http/hackathon-command-e2e.test.ts +++ b/test/http/hackathon-command-e2e.test.ts @@ -254,3 +254,154 @@ describe("POST /telegram/webhook — /hackathon join through real composition", expect(row).toEqual({ thread_id: null, topic_claim_until: 0 }); }); }); + +// hackathon-participation (PR2): the `hp:` callback through the real +// route, composition root, D1 and adapters (telegram-webhook spec: Command-Only +// Routing). +describe("POST /telegram/webhook — hp: callback through real composition", () => { + const stubForum = () => + stubTelegramApi((method) => { + if (method === "createForumTopic") return { message_thread_id: 4242, name: "x", icon_color: 0 }; + if (method === "sendChatAction") return true; + return undefined; + }); + + function callbackUpdate(chatId: number, userId: number, data: string, messageId = 321) { + return { + update_id: nextUpdateId++, + callback_query: { + id: `cb-${nextUpdateId}`, + from: { id: userId, is_bot: false, first_name: "User" }, + chat_instance: "e2e", + data, + message: { + message_id: messageId, + date: 0, + chat: { id: chatId, type: "supergroup", title: "E2E group" }, + }, + }, + }; + } + + async function seed(chatId: number, generalMessageId: number | null) { + const team = await env.DB.prepare("SELECT id FROM teams WHERE telegram_chat_id = ?") + .bind(chatId) + .first<{ id: string }>(); + const teamId = asTeamId(team!.id); + const repo = createD1HackathonAnalysisRepo(env.DB); + const id = `e2e-cb-${chatId}`; + await repo.save({ + id, + teamId, + slug: "meridian", + sourceUrl: "https://example.com/meridian", + normalizedUrl: "https://example.com/meridian", + fields: { + name: { value: "Meridian Hack", snippet: "", confidence: 0.9 }, + format: null, + location: null, + teamSize: null, + submissionDeadline: null, + startDate: null, + endDate: null, + resultsDate: null, + prizes: null, + tracks: null, + eligibility: null, + }, + suggestedRepos: [], + threadId: null, + pinnedMessageId: null, + generalMessageId: null, + createdAt: 0, + updatedAt: 0, + }); + if (generalMessageId !== null) await repo.setGeneralMessageId(teamId, id, generalMessageId); + return id; + } + + const threadOf = (id: string) => + env.DB.prepare("SELECT thread_id FROM hackathon_analyses WHERE id = ?").bind(id).first(); + + it("an admin tap creates the topic, confirms in General, answers the callback and removes the button", async () => { + const calls = stubForum(); + const queue = fakeQueue(); + const chatId = -1_005_552_001; + const userId = 900_201; + + await post(commandUpdate("setup", chatId, userId), queue.binding); + const id = await seed(chatId, 321); + const res = await post(callbackUpdate(chatId, userId, "hp:meridian"), queue.binding); + + expect(res.status).toBe(200); + expect(await threadOf(id)).toEqual({ thread_id: 4242 }); + expect(calls.filter((c) => c.method === "createForumTopic")).toHaveLength(1); + expect(calls.filter((c) => c.method === "answerCallbackQuery")).toHaveLength(1); + expect(calls.filter((c) => c.method === "editMessageReplyMarkup").map((c) => c.body)).toEqual([ + expect.objectContaining({ chat_id: chatId, message_id: 321 }), + ]); + const texts = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); + expect(texts.at(-1)).toBe("✅ Participamos en Meridian Hack → https://t.me/c/5552001/4242"); + }); + + it("a non-admin tap gets the alert and nothing changes", async () => { + const calls = stubForum(); + const queue = fakeQueue(); + const chatId = -1_005_552_002; + const adminId = 900_202; + const memberId = 900_203; + + await post(commandUpdate("setup", chatId, adminId), queue.binding); + await post(commandUpdate("join", chatId, memberId), queue.binding); + const id = await seed(chatId, 321); + const res = await post(callbackUpdate(chatId, memberId, "hp:meridian"), queue.binding); + + expect(res.status).toBe(200); + const answers = calls.filter((c) => c.method === "answerCallbackQuery").map((c) => c.body); + expect(answers).toEqual([ + expect.objectContaining({ + text: "Solo un administrador del equipo puede confirmar la participación.", + show_alert: true, + }), + ]); + expect(calls.some((c) => c.method === "createForumTopic")).toBe(false); + expect(calls.some((c) => c.method === "editMessageReplyMarkup")).toBe(false); + expect(await threadOf(id)).toEqual({ thread_id: null }); + }); + + it("a redelivered tap creates no second topic", async () => { + const calls = stubForum(); + const queue = fakeQueue(); + const chatId = -1_005_552_003; + const userId = 900_204; + + await post(commandUpdate("setup", chatId, userId), queue.binding); + await seed(chatId, null); + const update = callbackUpdate(chatId, userId, "hp:meridian"); + await post(update, queue.binding); + await post(update, queue.binding); + + expect(calls.filter((c) => c.method === "createForumTopic")).toHaveLength(1); + const texts = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); + expect(texts.at(-1)).toBe("Este hackathon ya tiene tema: https://t.me/c/5552003/4242"); + }); + + it.each([ + ["a malformed slug", "hp:Bad_Slug"], + ["a foreign prefix", "zz:meridian"], + ])("%s is ignored with 200 and no Telegram call", async (_label, data) => { + const calls = stubForum(); + const queue = fakeQueue(); + const chatId = -1_005_552_004; + const userId = 900_205; + + await post(commandUpdate("setup", chatId, userId), queue.binding); + const id = await seed(chatId, 321); + const before = calls.length; + const res = await post(callbackUpdate(chatId, userId, data), queue.binding); + + expect(res.status).toBe(200); + expect(calls.slice(before)).toEqual([]); + expect(await threadOf(id)).toEqual({ thread_id: null }); + }); +}); From 9d2cfa14e826ad152291e0c30bd7985d5c96d2bd Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:30:59 -0400 Subject: [PATCH 5/6] docs(hackathon-participation): mark phase 3 tasks done and record apply progress --- .../hackathon-participation/apply-progress.md | 47 ++++++++++++++++++- .../changes/hackathon-participation/tasks.md | 20 ++++---- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md index b6e3036..3a26b26 100644 --- a/openspec/changes/hackathon-participation/apply-progress.md +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -1,6 +1,6 @@ # Apply Progress: hackathon-participation -Completed so far: Phase 1 (1.1-1.12) and Phase 2 (2.1-2.11). Phase 3 (PR2) and Phase 4 pending. +Completed so far: Phase 1 (1.1-1.12), Phase 2 (2.1-2.11) and Phase 3 (3.1-3.10). Phase 4 (operator steps and final verification) pending. ## Batch 1 — Phase 1 Infrastructure (PR1a) — branch `feat/participation-infra` @@ -89,3 +89,48 @@ Mode: Strict TDD. Base: main bfec91d (PR1a merged). Completed: Step 0 (PR1a revi - An unknown error from `create` (not a `ForumTopicCreateError`) keeps the claim and rethrows: the topic may exist. - `ForumTopicManager` is wired in `composition.ts` (needed by join); the button, `hp:` handler and consumer wiring stay in Phase 3. - Phase 3 must keep the `runParticipation` `reply` param for the callback alert. + +## Batch 3 — Phase 3 Button, Callback, Consumer (PR2) — branch `feat/participation-button` + +Mode: Strict TDD. Base: main 934fd01 (PR1a and PR1b merged). Completed: Step 0 (PR1b review warning R3-001) + 3.1–3.10 (all of Phase 3). + +### Step 0 (PR1b advisory warning) + +| Finding | Resolution | +|---|---| +| R3-001 no test for a non-`ForumTopicCreateError` thrown by `create` | Test added in `participate-in-hackathon.test.ts`: the claim is kept (expiry in the future), the same error object is rethrown, nothing is linked or posted. It passed on first run because the branch already existed (regression guard, not a true RED) | + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 3.1/3.2 | `test/domain/usecases/run-hackathon-job.test.ts` | Unit (fakes) | 29/29 | 3 failed (no options, no stored id, no store-failure log) | 33/33 | fresh General, topic (no button, null id), store failure (ack, one post, log), persisted repost | Both General sites share `postToGeneral` | +| 3.3/3.4 | `test/adapters/telegram/commands.test.ts` | Unit | 120/120 | 3 failed (not a function) | pass | full ctx, absent thread/message id, missing chat/user | None needed | +| 3.5/3.6/3.7 | `test/adapters/telegram/commands.test.ts` (real grammY Bot + fakes) | Integration | 120/120 | 8 failed (no handler) | 131/131 | non-admin, non-member, admin, deduped/different buttons, early answer order, answer failure, redelivery, unknown slug, team from chat, 6 malformed payloads, private chat, `sel:` still works | None needed | +| 3.8 | `test/http/hackathon-command-e2e.test.ts` | Integration (real route, composition, D1) | 8/8 | 3 failed with the handler unregistered (verified by disabling it) | 11/11 | admin tap, non-admin alert, redelivery, malformed + foreign prefix ignored | None needed | +| 3.9 | composition | — | — | — | — | ➖ Nothing to wire: the consumer already had `chatPublisher` and `hackathonAnalysisRepo`; the callback rides `registerHackathonCommands` | ➖ | +| 3.10 | full suite | — | — | — | 1027/1027 (76 files), typecheck clean | — | — | + +### Work Unit Evidence + +| Evidence | Value | +|---|---| +| Focused test command and result | `npx vitest run test/domain/usecases/run-hackathon-job.test.ts test/adapters/telegram test/http`: all green; full suite 76 files, 1027/1027, `npm run typecheck` clean | +| Runtime harness | N/A: no fetch/LLM path; e2e drives the real Hono route, composition, D1 and adapters with stubbed Telegram HTTP | +| Rollback boundary | `postToGeneral` in `run-hackathon-job.ts`, `callbackCallerLocation` in `context.ts`, `registerParticipationCallback` and its one-line registration in `hackathon-commands.ts` | + +### Commits +- `94d4ba1` test(participation): cover unknown create error keeping the claim and rethrowing +- `812929c` feat(hackathon): post the General analysis with the participation button and store its message id +- `fe92e95` feat(telegram): confirm participation from the hp callback button +- test(http) commit: hp callback e2e through the webhook route +- docs commit: tasks and apply-progress (this file) + +### Deviations / notes +- Handler tests live in `commands.test.ts` (which already has the real-Bot harness with alert recording), not `participation.test.ts` as tasks 3.5/3.6 name it. The `callbackCallerLocation` tests are there too, as in 3.3. +- `callbackCallerLocation` returns `CallbackLocation = CallerLocation & { messageId }`, so the button message id travels with the location. +- Order in the handler: the role is resolved first (fast, no use case). A non-member or non-admin gets the single alert (one `answerCallbackQuery` per query allows only one). An admin is answered silently and early, then `runParticipation` runs. Its `reply` (pre-creation refusals such as missing rights) is therefore a best-effort `ctx.reply`, not an alert, because the query is already answered. +- Slug length is capped at 40 in the handler (slug.ts limit) on top of the design regex. +- The `setGeneralMessageId` failure is logged as `hackathon-job` / `general-message-id-store-failed`. Consequence: the stored message's button is not cleared on join (the tapped message's own button still is). +- The persisted repost site also stores the message id, so a repost after a crash replaces the stored id with the newest message. +- The only new e2e coverage is the `hp:` callback; other prefixes were already ignored (only `sel:` and `hp:` handlers exist), so no routing code changed in `index.ts`. diff --git a/openspec/changes/hackathon-participation/tasks.md b/openspec/changes/hackathon-participation/tasks.md index f0ed3f5..11f384f 100644 --- a/openspec/changes/hackathon-participation/tasks.md +++ b/openspec/changes/hackathon-participation/tasks.md @@ -67,16 +67,16 @@ Each PR is independently deployable and keeps `npm test` green. PR1a adds unused ## Phase 3: Button, Callback, Consumer (PR2) -- [ ] 3.1 RED: `test/domain/usecases/run-hackathon-job.test.ts` — General post is sent with `participateSlug` and `setGeneralMessageId` stores the returned id at both General sites; a topic post has no button; a `setGeneralMessageId` failure is logged, the job still acks, and there is no retry/repost. -- [ ] 3.2 GREEN: `src/domain/usecases/run-hackathon-job.ts` — `postToGeneral(text, participateSlug)` helper used by both General sites; best-effort id store. -- [ ] 3.3 RED: `test/adapters/telegram/commands.test.ts` — `callbackCallerLocation(ctx)` reads `ctx.chat.id`, `ctx.from.id`, `ctx.msg?.message_thread_id`, `ctx.callbackQuery.message?.message_id`; `callerLocation` behavior for commands unchanged. -- [ ] 3.4 GREEN: `src/adapters/telegram/context.ts` (`callbackCallerLocation`). -- [ ] 3.5 RED: `test/adapters/telegram/participation.test.ts` — **non-admin alert**: `hp:` from a non-admin (and non-member) → `answerCallbackQuery` with `show_alert` "Solo un administrador del equipo puede confirmar la participación.", nothing created, button stays; private/missing chat ignored; team taken from chat id, never the payload; malformed data (`hp:Bad_Slug`, over-long) ignored; admin tap creates the topic. -- [ ] 3.6 RED: same file — **button removed after confirmation**: admin tap → `clearButtons` for the callback message id and the stored `generalMessageId` (deduped); button-clear failure ignored; callback answered early, best-effort. -- [ ] 3.7 GREEN: `src/adapters/telegram/participation.ts` (`bot.callbackQuery(/^hp:(slug)$/)` handler, registered from `registerHackathonCommands`); `src/adapters/telegram/hackathon-commands.ts`. -- [ ] 3.8 RED: `test/http/webhook-e2e.test.ts` and `test/http/hackathon-command-e2e.test.ts` — validated webhook `hp:` callback from a group is routed to the participation handler; callback with any other prefix is ignored without error; `/hackathon join` clears the stored message's button (old analysis with null id: join works, nothing removed). -- [ ] 3.9 GREEN: `src/composition.ts` wiring (consumer uses `postToGeneral`, handler gets `ForumTopicManager`); `test/fakes/index.ts` and `test/support/telegram-stub.ts` callback fixtures/`answerCallbackQuery`/`editMessageReplyMarkup` recording. -- [ ] 3.10 Run `npm test` and `npm run typecheck`. +- [x] 3.1 RED: `test/domain/usecases/run-hackathon-job.test.ts` — General post is sent with `participateSlug` and `setGeneralMessageId` stores the returned id at both General sites; a topic post has no button; a `setGeneralMessageId` failure is logged, the job still acks, and there is no retry/repost. +- [x] 3.2 GREEN: `src/domain/usecases/run-hackathon-job.ts` — `postToGeneral(text, participateSlug)` helper used by both General sites; best-effort id store. +- [x] 3.3 RED: `test/adapters/telegram/commands.test.ts` — `callbackCallerLocation(ctx)` reads `ctx.chat.id`, `ctx.from.id`, `ctx.msg?.message_thread_id`, `ctx.callbackQuery.message?.message_id`; `callerLocation` behavior for commands unchanged. +- [x] 3.4 GREEN: `src/adapters/telegram/context.ts` (`callbackCallerLocation`). +- [x] 3.5 RED: `test/adapters/telegram/participation.test.ts` — **non-admin alert**: `hp:` from a non-admin (and non-member) → `answerCallbackQuery` with `show_alert` "Solo un administrador del equipo puede confirmar la participación.", nothing created, button stays; private/missing chat ignored; team taken from chat id, never the payload; malformed data (`hp:Bad_Slug`, over-long) ignored; admin tap creates the topic. +- [x] 3.6 RED: same file — **button removed after confirmation**: admin tap → `clearButtons` for the callback message id and the stored `generalMessageId` (deduped); button-clear failure ignored; callback answered early, best-effort. +- [x] 3.7 GREEN: `src/adapters/telegram/participation.ts` (`bot.callbackQuery(/^hp:(slug)$/)` handler, registered from `registerHackathonCommands`); `src/adapters/telegram/hackathon-commands.ts`. +- [x] 3.8 RED: `test/http/webhook-e2e.test.ts` and `test/http/hackathon-command-e2e.test.ts` — validated webhook `hp:` callback from a group is routed to the participation handler; callback with any other prefix is ignored without error; `/hackathon join` clears the stored message's button (old analysis with null id: join works, nothing removed). +- [x] 3.9 GREEN: `src/composition.ts` wiring (consumer uses `postToGeneral`, handler gets `ForumTopicManager`); `test/fakes/index.ts` and `test/support/telegram-stub.ts` callback fixtures/`answerCallbackQuery`/`editMessageReplyMarkup` recording. +- [x] 3.10 Run `npm test` and `npm run typecheck`. ## Phase 4: Operator Step and Final Verification (after PR2) From bde676fa7c022db5a5cd87e05897610f539845ee Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:31:03 -0400 Subject: [PATCH 6/6] docs(hackathon-participation): record phase 3 commit hashes --- openspec/changes/hackathon-participation/apply-progress.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md index 3a26b26..bf63209 100644 --- a/openspec/changes/hackathon-participation/apply-progress.md +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -123,8 +123,8 @@ Mode: Strict TDD. Base: main 934fd01 (PR1a and PR1b merged). Completed: Step 0 ( - `94d4ba1` test(participation): cover unknown create error keeping the claim and rethrowing - `812929c` feat(hackathon): post the General analysis with the participation button and store its message id - `fe92e95` feat(telegram): confirm participation from the hp callback button -- test(http) commit: hp callback e2e through the webhook route -- docs commit: tasks and apply-progress (this file) +- `74f8924` test(http): cover the hp callback through the webhook route +- `9d2cfa1` docs: tasks and apply-progress (this file) ### Deviations / notes - Handler tests live in `commands.test.ts` (which already has the real-Bot harness with alert recording), not `participation.test.ts` as tasks 3.5/3.6 name it. The `callbackCallerLocation` tests are there too, as in 3.3.