From 3e648d6f4093fb92eca32608367513cb4156a930 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:26:41 -0400 Subject: [PATCH 1/5] fix(participation): verify an existing topic by posting the analysis into it --- src/adapters/telegram/forum-topic-manager.ts | 25 +------ src/domain/ports.ts | 6 +- .../usecases/participate-in-hackathon.ts | 32 +++++++-- .../telegram/forum-topic-manager.test.ts | 59 +--------------- .../usecases/participate-in-hackathon.test.ts | 70 ++++++++++++++----- test/fakes/index.ts | 16 +---- test/http/hackathon-command-e2e.test.ts | 2 - 7 files changed, 85 insertions(+), 125 deletions(-) diff --git a/src/adapters/telegram/forum-topic-manager.ts b/src/adapters/telegram/forum-topic-manager.ts index c666d82..c3f6914 100644 --- a/src/adapters/telegram/forum-topic-manager.ts +++ b/src/adapters/telegram/forum-topic-manager.ts @@ -1,7 +1,7 @@ import { GrammyError } from "grammy"; import type { Api } from "grammy"; import { ForumTopicCreateError } from "../../domain/errors"; -import type { ForumTopicManager, TopicCreateFailure, TopicProbe } from "../../domain/ports"; +import type { ForumTopicManager, TopicCreateFailure } from "../../domain/ports"; // Same 10 s Telegram budget as the chat publisher; a timeout surfaces as a // non-Grammy error and is classified `unavailable` (the topic may exist). @@ -15,9 +15,6 @@ const topicSignal = (): GrammySignal => const NO_RIGHTS = /not enough rights|chat_admin_required/i; const NOT_FORUM = /not a forum|channel_forum_missing/i; -// Telegram does not document these strings (design.md decision 2): they come -// from community reports and are validated by the operator smoke test. -const THREAD_GONE = /message thread not found|topic_id_invalid|topic_deleted/i; function classifyCreateFailure(err: unknown): TopicCreateFailure { if (!(err instanceof GrammyError)) return "unavailable"; @@ -28,15 +25,6 @@ function classifyCreateFailure(err: unknown): TopicCreateFailure { return "rejected"; } -// Only a 400 carrying a "thread gone" description is a positive deleted -// signal; everything else (other 400s, 403, 429, 5xx, timeout) is `unknown`. -function classifyProbeFailure(err: unknown): TopicProbe { - if (err instanceof GrammyError && err.error_code === 400 && THREAD_GONE.test(err.description)) { - return "deleted"; - } - return "unknown"; -} - export function createTelegramForumTopicManager(api: Api): ForumTopicManager { return { async create(chatId, name) { @@ -48,16 +36,5 @@ export function createTelegramForumTopicManager(api: Api): ForumTopicManager { throw new ForumTopicCreateError("createForumTopic failed", classifyCreateFailure(err)); } }, - - // sendChatAction needs no can_manage_topics; its only side effect is a - // "typing…" indicator of at most 5 s (design.md decision 2). - async probe(chatId, threadId) { - try { - await api.sendChatAction(chatId, "typing", { message_thread_id: threadId }, topicSignal()); - return "live"; - } catch (err) { - return classifyProbeFailure(err); - } - }, }; } diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 60dd102..3a961ea 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -337,10 +337,7 @@ export interface RepoMetadataSource { fetchDescription(repo: RepoFullName): Promise; } -// hackathon-participation (design.md "Interfaces / Contracts"). `probe` never -// throws; `unknown` (any ambiguous result) must be treated as live so a -// topic is never recreated on ambiguity. -export type TopicProbe = "live" | "deleted" | "unknown"; +// hackathon-participation (design.md "Interfaces / Contracts"). export type TopicCreateFailure = | "no-rights" | "not-forum" @@ -352,7 +349,6 @@ export type TopicCreateFailure = // `create` throws ForumTopicCreateError(failure). export interface ForumTopicManager { create(chatId: number, name: string): Promise; - probe(chatId: number, threadId: number): Promise; } // Semantic post options (hackathon-participation design.md decision 1): the diff --git a/src/domain/usecases/participate-in-hackathon.ts b/src/domain/usecases/participate-in-hackathon.ts index 9a1c387..ae724da 100644 --- a/src/domain/usecases/participate-in-hackathon.ts +++ b/src/domain/usecases/participate-in-hackathon.ts @@ -3,6 +3,7 @@ import { AnalysisNotFoundError, ChatNotForumError, ForumTopicCreateError, + PublishFailedError, TopicCreationFailedError, TopicCreationUncertainError, TopicRightsMissingError, @@ -70,12 +71,13 @@ export async function participateInHackathon( throw new AnalysisNotFoundError("No analysis with that slug"); } - // 2. Probe an existing topic. Live and unknown (ambiguous) both mean "keep - // it": never recreate on ambiguity. + // 2. Verify an existing topic by doing the real action: post the analysis + // into it. Telegram accepts a chat action for a deleted thread, so only a + // rejected post proves the topic is gone. Success and any ambiguous failure + // both mean "keep it": never recreate on ambiguity. let expected: number | null = null; if (analysis.threadId !== null) { - const probe = await deps.forumTopicManager.probe(chatId, analysis.threadId); - if (probe !== "deleted") { + if (!(await topicIsGone(input, analysis, analysis.threadId, deps))) { await clearButtons(input, analysis, deps); return { kind: "already", @@ -151,6 +153,28 @@ export async function participateInHackathon( return { kind: "created", replyText: [confirmed, ...notes].join("\n") }; } +// Posts (and pins) the analysis in the linked topic. Only a 4xx rejection +// (`rejected`, e.g. "message thread not found") is a positive deleted signal; +// unavailable, rate-limited and any other failure leave the topic as unknown. +async function topicIsGone( + input: ParticipateInHackathonInput, + analysis: HackathonAnalysis, + threadId: number, + deps: ParticipateInHackathonDeps, +): Promise { + try { + await postAnalysisAndLinkTopic( + { teamId: input.teamId, chatId: input.chatId, threadId, analysis }, + deps, + ); + return false; + } catch (err) { + if (err instanceof PublishFailedError && err.failureClass === "rejected") return true; + logFailure(deps, input.teamId, "topic-check-failed", err); + return false; + } +} + function refusalFor(failure: ForumTopicCreateError["failure"]): Error { switch (failure) { case "no-rights": diff --git a/test/adapters/telegram/forum-topic-manager.test.ts b/test/adapters/telegram/forum-topic-manager.test.ts index 50a4ae8..51aa5f8 100644 --- a/test/adapters/telegram/forum-topic-manager.test.ts +++ b/test/adapters/telegram/forum-topic-manager.test.ts @@ -5,10 +5,8 @@ import type { TopicCreateFailure } from "../../../src/domain/ports"; import { createTelegramForumTopicManager } from "../../../src/adapters/telegram/forum-topic-manager"; import { stubTelegramApi } from "../../support/telegram-stub"; -// hackathon-participation design.md decision 2 + "Interfaces / Contracts": -// `create` classifies a failed createForumTopic into a fixed failure code; -// `probe` maps sendChatAction(typing, message_thread_id) to live / deleted / -// unknown and never throws (an ambiguous result is treated as live). +// hackathon-participation design.md "Interfaces / Contracts": +// `create` classifies a failed createForumTopic into a fixed failure code. afterEach(() => { vi.unstubAllGlobals(); @@ -103,56 +101,3 @@ describe("createTelegramForumTopicManager.create", () => { expect(signal?.aborted).toBe(false); }); }); - -describe("createTelegramForumTopicManager.probe", () => { - it("returns live when sendChatAction succeeds, using typing with message_thread_id", async () => { - const calls = stubTelegramApi((method) => (method === "sendChatAction" ? true : undefined)); - - const result = await makeManager().probe(-1001234, 4321); - - expect(result).toBe("live"); - expect(calls.find((c) => c.method === "sendChatAction")?.body).toMatchObject({ - chat_id: -1001234, - action: "typing", - message_thread_id: 4321, - }); - }); - - const deleted = [ - "Bad Request: message thread not found", - "Bad Request: TOPIC_ID_INVALID", - "Bad Request: TOPIC_DELETED", - "bad request: Message Thread Not Found", - ]; - for (const description of deleted) { - it(`returns deleted for a 400 "${description}"`, async () => { - stubTelegramApi(() => fail(400, description)); - - expect(await makeManager().probe(-1001234, 4321)).toBe("deleted"); - }); - } - - const ambiguous: Array<[string, unknown]> = [ - ["another 400", fail(400, "Bad Request: chat not found")], - ["a 403", fail(403, "Forbidden: bot is not a member of the supergroup chat")], - ["a 429", fail(429, "Too Many Requests: retry after 5")], - ["a 502", fail(502, "Bad Gateway")], - // The same deleted-topic text on a non-400 must not count as a positive signal. - ["a non-400 with a deleted-looking description", fail(403, "message thread not found")], - ]; - for (const [label, response] of ambiguous) { - it(`returns unknown (treated as live) for ${label}`, async () => { - stubTelegramApi(() => response); - - expect(await makeManager().probe(-1001234, 4321)).toBe("unknown"); - }); - } - - it("returns unknown on a network failure or timeout and never throws", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("network down"); - }); - - await expect(makeManager().probe(-1001234, 4321)).resolves.toBe("unknown"); - }); -}); diff --git a/test/domain/usecases/participate-in-hackathon.test.ts b/test/domain/usecases/participate-in-hackathon.test.ts index d933b8b..0fabb3b 100644 --- a/test/domain/usecases/participate-in-hackathon.test.ts +++ b/test/domain/usecases/participate-in-hackathon.test.ts @@ -10,7 +10,6 @@ import { } from "../../../src/domain/errors"; import type { HackathonAnalysis } from "../../../src/domain/entities"; import { asMemberId, asMembershipId, asTeamId } from "../../../src/domain/ids"; -import type { TopicProbe } from "../../../src/domain/ports"; import { participateInHackathon } from "../../../src/domain/usecases/participate-in-hackathon"; import { fakeChatPublisher, @@ -61,7 +60,6 @@ function analysis(overrides: Partial = {}): HackathonAnalysis function setup( opts: { row?: Partial; - probe?: TopicProbe[]; create?: TopicCreateStep[]; role?: "admin" | "member"; } = {}, @@ -82,7 +80,6 @@ function setup( hackathonAnalysisRepo, chatPublisher: fakeChatPublisher(), forumTopicManager: fakeForumTopicManager({ - ...(opts.probe ? { probe: opts.probe } : {}), ...(opts.create ? { create: opts.create } : {}), }), clock: fakeClock(), @@ -99,6 +96,15 @@ const input = (over: Partial<{ callbackMessageId: number | null; slug: string; c ...over, }); +// The user deleted the topic: Telegram rejects (4xx) a post into that thread. +function rejectPostsTo(deps: ReturnType, deletedThreadId: number) { + const realPost = deps.chatPublisher.post; + deps.chatPublisher.post = async (chatId, threadId, text, options) => { + if (threadId === deletedThreadId) throw new PublishFailedError("sendMessage failed", "rejected"); + return realPost(chatId, threadId, text, options); + }; +} + const row = (deps: ReturnType) => deps.hackathonAnalysisRepo.rows[0]!; describe("participateInHackathon: gate and lookup", () => { @@ -168,7 +174,7 @@ describe("participateInHackathon: happy path", () => { describe("participateInHackathon: redelivery and live topics", () => { it("never creates a second topic on redelivery: already(link), exactly one create", async () => { - const deps = setup({ create: [{ threadId: 77 }], probe: ["live"] }); + const deps = setup({ create: [{ threadId: 77 }] }); const first = await participateInHackathon(input(), deps); const second = await participateInHackathon(input(), deps); @@ -181,35 +187,59 @@ describe("participateInHackathon: redelivery and live topics", () => { expect(deps.forumTopicManager.created).toHaveLength(1); }); - it("returns already for a live topic without claiming or creating", async () => { - const deps = setup({ row: { threadId: 40 }, probe: ["live"] }); - const result = await participateInHackathon(input(), deps); + it("verifies a live topic by posting the analysis into it: already(link), re-pinned, no create", async () => { + const deps = setup({ row: { threadId: 40, pinnedMessageId: 8, generalMessageId: 900 } }); + const result = await participateInHackathon(input({ callbackMessageId: 901 }), deps); expect(result).toEqual({ kind: "already", replyText: `Este hackathon ya tiene tema: ${LINK(40)}` }); - expect(deps.forumTopicManager.probed).toEqual([{ chatId: CHAT_ID, threadId: 40 }]); + expect(deps.chatPublisher.posted).toHaveLength(1); + expect(deps.chatPublisher.posted[0]?.threadId).toBe(40); + expect(deps.chatPublisher.pinned).toEqual([1]); + expect(row(deps).threadId).toBe(40); + expect(row(deps).pinnedMessageId).toBe(1); expect(deps.forumTopicManager.created).toHaveLength(0); expect(deps.hackathonAnalysisRepo.claims.size).toBe(0); - }); - - it("treats an ambiguous probe as live: no recreate, buttons cleared best-effort", async () => { - const deps = setup({ row: { threadId: 40, generalMessageId: 900 }, probe: ["unknown"] }); - const result = await participateInHackathon(input({ callbackMessageId: 901 }), deps); - expect(result.kind).toBe("already"); - expect(deps.forumTopicManager.created).toHaveLength(0); - expect(row(deps).threadId).toBe(40); expect(deps.chatPublisher.cleared).toEqual([ { chatId: CHAT_ID, messageId: 901 }, { chatId: CHAT_ID, messageId: 900 }, ]); }); + + it.each(["telegram-unavailable", "rate-limited"] as const)( + "keeps the link and does not recreate when the verifying post fails as %s", + async (failureClass) => { + const deps = setup({ row: { threadId: 40, generalMessageId: 900 } }); + deps.chatPublisher.post = async () => { + throw new PublishFailedError("sendMessage failed", failureClass); + }; + const result = await participateInHackathon(input(), deps); + expect(result).toEqual({ kind: "already", replyText: `Este hackathon ya tiene tema: ${LINK(40)}` }); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.hackathonAnalysisRepo.claims.size).toBe(0); + expect(row(deps).threadId).toBe(40); + expect(deps.chatPublisher.cleared).toEqual([{ chatId: CHAT_ID, messageId: 900 }]); + }, + ); + + it("never creates a second topic after a recreation: the redelivery posts into the new thread", async () => { + const deps = setup({ row: { threadId: 40 }, create: [{ threadId: 41 }, { threadId: 42 }] }); + rejectPostsTo(deps, 40); + + const first = await participateInHackathon(input(), deps); + const second = await participateInHackathon(input(), deps); + + expect(first.kind).toBe("created"); + expect(second).toEqual({ kind: "already", replyText: `Este hackathon ya tiene tema: ${LINK(41)}` }); + expect(deps.forumTopicManager.created).toHaveLength(1); + }); }); describe("participateInHackathon: deleted topic", () => { - it("detects the deleted topic, recreates it, replaces the stale id, no moved note", async () => { + it("detects the deleted topic from a rejected post, recreates it, replaces the stale id, no moved note", async () => { const deps = setup({ row: { threadId: 40, pinnedMessageId: 8 }, - probe: ["deleted"], create: [{ threadId: 41 }], }); + rejectPostsTo(deps, 40); const result = await participateInHackathon(input(), deps); @@ -218,10 +248,12 @@ describe("participateInHackathon: deleted topic", () => { expect(result.kind).toBe("created"); expect(result.replyText).toBe(`✅ Participamos en Meridian Hack 2026 → ${LINK(41)}`); expect(deps.chatPublisher.unpinned).toEqual([]); + expect(deps.chatPublisher.posted.map((p) => p.threadId)).toEqual([41]); }); it("claims with the stale id: a concurrent relink to a new id makes the claim lose", async () => { - const deps = setup({ row: { threadId: 40 }, probe: ["deleted"], create: [{ threadId: 41 }] }); + const deps = setup({ row: { threadId: 40 }, create: [{ threadId: 41 }] }); + rejectPostsTo(deps, 40); const realFind = deps.hackathonAnalysisRepo.findBySlug; let reads = 0; // First read still shows the stale id; then another tap relinks it to 50. diff --git a/test/fakes/index.ts b/test/fakes/index.ts index 59af25b..1df05f1 100644 --- a/test/fakes/index.ts +++ b/test/fakes/index.ts @@ -47,7 +47,6 @@ import type { RepoTopicLinkRepo, TeamRepo, TopicCreateFailure, - TopicProbe, } from "../../src/domain/ports"; import type { MemberId, MembershipId, TeamId } from "../../src/domain/ids"; @@ -585,23 +584,19 @@ export function fakeChatPublisher( // Scripted ForumTopicManager: each call consumes the next outcome (repeating // the last once exhausted); a default `create` yields sequential thread ids. -// `calls` logs every create/probe so tests can assert "exactly one create". +// `calls` logs every create so tests can assert "exactly one create". export type TopicCreateStep = { threadId: number } | { fails: TopicCreateFailure }; export function fakeForumTopicManager( - opts: { create?: TopicCreateStep[]; probe?: TopicProbe[] } = {}, + opts: { create?: TopicCreateStep[] } = {}, ): ForumTopicManager & { created: Array<{ chatId: number; name: string }>; - probed: Array<{ chatId: number; threadId: number }>; } { const created: Array<{ chatId: number; name: string }> = []; - const probed: Array<{ chatId: number; threadId: number }> = []; let createIdx = 0; - let probeIdx = 0; let nextThreadId = 1000; return { created, - probed, create: async (chatId: number, name: string) => { created.push({ chatId, name }); const script = opts.create; @@ -612,13 +607,6 @@ export function fakeForumTopicManager( } return step ? step.threadId : nextThreadId++; }, - probe: async (chatId: number, threadId: number) => { - probed.push({ chatId, threadId }); - const script = opts.probe; - const step = script && script.length > 0 ? script[Math.min(probeIdx, script.length - 1)] : undefined; - probeIdx += 1; - return step ?? "live"; - }, }; } diff --git a/test/http/hackathon-command-e2e.test.ts b/test/http/hackathon-command-e2e.test.ts index 09400ac..b7925ff 100644 --- a/test/http/hackathon-command-e2e.test.ts +++ b/test/http/hackathon-command-e2e.test.ts @@ -144,7 +144,6 @@ describe("POST /telegram/webhook — /hackathon join 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; }); @@ -262,7 +261,6 @@ 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; }); From 65c2fac49ab0b93789233ba4c5a30ae7a8eed3e8 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:26:42 -0400 Subject: [PATCH 2/5] docs(hackathon-participation): describe the post-based existing-topic check --- .../changes/hackathon-participation/design.md | 20 +++++++++---------- .../hackathon-participation/proposal.md | 2 +- .../specs/hackathon-participation/spec.md | 9 ++++++++- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/openspec/changes/hackathon-participation/design.md b/openspec/changes/hackathon-participation/design.md index c21dc33..78c4ad2 100644 --- a/openspec/changes/hackathon-participation/design.md +++ b/openspec/changes/hackathon-participation/design.md @@ -6,14 +6,14 @@ Hexagonal, same as change 3. There is one pure use case, `participateInHackathon - the `hp:` callback on the General analysis post; - `/hackathon join `. -The use case owns five things: the admin gate, the slug lookup, the live/deleted probe, a D1 compare-and-set claim, and topic creation. It then links through the existing `moveTopicLink` / `postAnalysisAndLinkTopic` flow and removes the button. Telegram access sits behind a new `ForumTopicManager` port plus two small `ChatPublisher` additions. The domain never imports grammY. The consumer (`runHackathonJob`) attaches the button to its two General post sites and stores the message id. Specs: `specs/hackathon-participation`, plus the deltas for `hackathon-analysis` and `telegram-webhook`. +The use case owns five things: the admin gate, the slug lookup, the existing-topic check (a real post), a D1 compare-and-set claim, and topic creation. It then links through the existing `moveTopicLink` / `postAnalysisAndLinkTopic` flow and removes the button. Telegram access sits behind a new `ForumTopicManager` port plus two small `ChatPublisher` additions. The domain never imports grammY. The consumer (`runHackathonJob`) attaches the button to its two General post sites and stores the message id. Specs: `specs/hackathon-participation`, plus the deltas for `hackathon-analysis` and `telegram-webhook`. ## Architecture Decisions | # | Topic | Choice | Rejected (tradeoff) | |---|---|---|---| -| 1 | Ports | New ISP port `ForumTopicManager { create, probe }`. `ChatPublisher.post` gains an optional `PostOptions { participateSlug?: string }`, which is semantic, not a keyboard. It also gains `clearButtons(chatId, messageId)`. The adapter owns the label, the `hp:` encoding and `editMessageReplyMarkup` (omitting `reply_markup` removes the keyboard). | Extending `ChatPublisher` with topic methods: the consumer would see topic rights it never needs. A domain-built `{label,data}` keyboard would leak the Telegram payload format into the domain. | -| 2 | Deleted-topic probe | `sendChatAction(chat, "typing", {message_thread_id})`. **ok ⇒ `live`** (a positive signal). A 400 whose description matches `/message thread not found\|TOPIC_ID_INVALID\|TOPIC_DELETED/i` ⇒ `deleted`. **Anything else** (other 400s, 403, 429, 5xx, timeout) ⇒ `unknown`, treated as live: never recreate on ambiguity. It needs no `can_manage_topics`. Its side effect is a "typing…" indicator of at most 5 s. | `editForumTopic` with no changes: needs `can_manage_topics` unless the bot created the topic, and a live topic also fails (`TOPIC_NOT_MODIFIED`), so live vs deleted hinges on two error strings. `closeForumTopic`/`reopenForumTopic`: they change state, post a service message, and need `can_manage_topics`. Telegram does not document its error descriptions (they come from community reports), so validation is an operator smoke step. The port hides the mechanism, so switching it is adapter-only. | +| 1 | Ports | New ISP port `ForumTopicManager { create }`. `ChatPublisher.post` gains an optional `PostOptions { participateSlug?: string }`, which is semantic, not a keyboard. It also gains `clearButtons(chatId, messageId)`. The adapter owns the label, the `hp:` encoding and `editMessageReplyMarkup` (omitting `reply_markup` removes the keyboard). | Extending `ChatPublisher` with topic methods: the consumer would see topic rights it never needs. A domain-built `{label,data}` keyboard would leak the Telegram payload format into the domain. | +| 2 | Existing-topic check | Do the real action: `postAnalysisAndLinkTopic` posts (and re-pins) the analysis into the linked thread. **Post succeeds ⇒ the topic is live**: reply `already(link)`; the analysis is re-posted there. A `PublishFailedError` classified `rejected` (a 4xx such as 400 "message thread not found") ⇒ the topic is deleted: `expected = threadId`, then claim → create → link → post/pin → confirm. `telegram-unavailable` and `rate-limited` (5xx, network, 429) ⇒ unknown: never recreate on ambiguity, reply `already(link)`. | `sendChatAction(chat, "typing", {message_thread_id})`: production showed Telegram accepts a chat action for a deleted thread, so it never detected the deletion. `editForumTopic`, `closeForumTopic`/`reopenForumTopic`: they need `can_manage_topics` and change topic state. The `PublishFailedError.failureClass` already carries the classification, so the domain stays free of grammY and Telegram error strings. | | 3 | Concurrency and redelivery | **(a) Migration 0004 with a claim.** `topic_claim_until` is set by a conditional `UPDATE … WHERE team_id=? AND id=? AND thread_id IS ?expected AND topic_claim_until <= ?now` (CAS on the observed `thread_id`: null, or the stale id). TTL is 60 s. | (b) Accepting the race. Webhook `max_connections` defaults to 40, so parallel taps are real, and the cost (a duplicate public topic) is visible and manual to undo. | | 4 | Button and message id | Migration 0004 adds `general_message_id`. A helper `postToGeneral` in `run-hackathon-job` (used by both General sites) posts with `participateSlug`. It then calls `setGeneralMessageId`, best-effort: catch and log, never retry, because a retry would repost. Each repost overwrites the id; old buttons stay idempotent. `/hackathon join` clears the stored id's button. Old analyses have null, so nothing is cleared. | Not storing the id: `join` could never remove the button. | | 5 | Callback handling | `bot.callbackQuery(/^hp:([a-z0-9]+(?:-[a-z0-9]+)*)$/)`. A private chat or a missing chat is ignored. The new `callbackCallerLocation(ctx)` reads `ctx.chat.id`, `ctx.from.id`, `ctx.msg?.message_thread_id` and `ctx.callbackQuery.message?.message_id`. The team comes from `teamRepo.findByChatId` and the role from `ctx.from.id` (`resolveGroupMembership`). Non-members and non-admins get the same alert (`show_alert`). The handler answers the callback early and best-effort. | Changing `callerLocation` to `ctx.msg`: it would widen every command to edited or channel messages. Trusting a team id in the payload is ruled out by the spec. | @@ -24,7 +24,7 @@ The use case owns five things: the admin gate, the slug lookup, the live/deleted ## Use Case Step Order (`participateInHackathon`) 1. Membership role check: not an admin ⇒ `UnauthorizedError`. `findBySlug` finds nothing ⇒ `AnalysisNotFoundError`. -2. `threadId != null` ⇒ `probe`. On `live` or `unknown`: clear the buttons (best-effort) and return `already(link)`. On `deleted`: set `expected = threadId`. +2. `threadId != null` ⇒ post the analysis into that thread. On success or an unknown failure (`telegram-unavailable`, `rate-limited`): clear the buttons (best-effort) and return `already(link)`. On a `rejected` post: set `expected = threadId`. 3. `claimTopicCreation(expected, now, 60s)`. If it returns false, re-read the analysis. Linked ⇒ `already(link)`. Otherwise `busy`, a neutral no-op: the callback is answered silently and nothing is posted. 4. `create(chat, topicNameFor(analysis))` can fail as follows: @@ -45,7 +45,7 @@ The use case owns five things: the admin gate, the slug lookup, the live/deleted tap hp: ─ callbackCallerLocation ─ member+admin? ─no→ alert /hackathon join ─ parseJoinArgument ─┐ │yes └─ participateInHackathon - probe ─ claim(CAS) ─ create ─ moveTopicLink ─ post+pin (existing) ─ clearButtons + post-check ─ claim(CAS) ─ create ─ moveTopicLink ─ post+pin (existing) ─ clearButtons └→ replyText ─ safe post to General (threadId null) consumer: postToGeneral(text, participateSlug) ─ setGeneralMessageId (best-effort) ``` @@ -53,11 +53,9 @@ consumer: postToGeneral(text, participateSlug) ─ setGeneralMessageId (best-eff ## Interfaces / Contracts ```ts -type TopicProbe = "live" | "deleted" | "unknown"; // probe never throws type TopicCreateFailure = "no-rights" | "not-forum" | "rate-limited" | "rejected" | "unavailable"; interface ForumTopicManager { create(chatId: number, name: string): Promise; // throws ForumTopicCreateError(failure) - probe(chatId: number, threadId: number): Promise; } interface ChatPublisher { post(chatId, threadId, text, options?: { participateSlug?: string }): Promise; pin; unpin; clearButtons(chatId: number, messageId: number): Promise; } @@ -112,9 +110,9 @@ or record `size:exception`. | Layer | Cases | Approach | |---|---|---| | Pure | `topicNameFor` (control and bidi characters, whitespace, empty ⇒ slug, 128-unit cap, no split surrogate); `topicLink` (`-100` stripping, non-`-100` ⇒ null); `parseJoinArgument` (bare, non-slug, three tokens) | Vitest | -| Use case | Non-admin or non-member changes nothing. A live topic gets `already` and nothing is created. An `unknown` probe is treated as live. A **deleted** topic is recreated and the stale id replaced. **Redelivery:** a second call after success returns `already`. **Concurrent taps:** a claim loss followed by a re-read gives `already` or `busy`, with exactly one `create`. Missing rights or not a forum: the claim is released and nothing is linked. `unavailable` keeps the claim. **Link failure / post failure after creation**: never throws. **Pin failure** adds a note. A button-clear failure is ignored. | `FakeForumTopicManager` (scripted outcomes, call log), plus the fake publisher and repo | +| Use case | Non-admin or non-member changes nothing. A live topic (the post succeeds) gets `already` and nothing is created. An unavailable or rate-limited post is treated as unknown: no recreate. A **deleted** topic (the post is `rejected`) is recreated and the stale id replaced. **Redelivery:** a second call after success returns `already`. **Concurrent taps:** a claim loss followed by a re-read gives `already` or `busy`, with exactly one `create`. Missing rights or not a forum: the claim is released and nothing is linked. `unavailable` keeps the claim. **Link failure / post failure after creation**: never throws. **Pin failure** adds a note. A button-clear failure is ignored. | `FakeForumTopicManager` (scripted outcomes, call log), plus the fake publisher and repo | | D1 | Claim CAS on `IS NULL` and `IS stale`, TTL expiry, release, `setGeneralMessageId`; `save`/`persistAnalysis` leave the new columns alone | vitest-pool-workers | -| Adapters | Create-error classification table; probe mapping (ok, deleted strings, other ⇒ unknown); `clearButtons` sends `editMessageReplyMarkup` without markup; the keyboard carries `hp:` of at most 64 bytes | Injected `Api` stub | +| Adapters | Create-error classification table; `clearButtons` sends `editMessageReplyMarkup` without markup; the keyboard carries `hp:` of at most 64 bytes | Injected `Api` stub | | Handlers | The `hp:` alert for non-admins; a private chat is ignored; the join usage; a failing safe General post never produces a 500; the consumer General post has the button, a topic post has none, and a `setGeneralMessageId` failure still acks | `telegram-stub.ts`, callback fixtures from `commands.test.ts` | No fetch, LLM or validation path changes, so `npm run harness` is not required. @@ -132,12 +130,12 @@ The shell, VCS and PR rows are N/A: this change has no shell, subprocess, VCS or Both are additive. Operator steps: 1. Apply 0004 remotely, then deploy. 2. Grant "Administrar temas". -3. Smoke test: tap the button; delete the topic and tap again (validates the probe strings and the link format); run a join on an old analysis. +3. Smoke test: tap the button; delete the topic and tap again (validates that the rejected post recreates the topic, and the link format); run a join on an old analysis. Rollback: redeploy the previous Worker. The columns are ignored. ## Open Questions - [ ] Can the spec adopt the three design-proposed strings (`createFailed`, `createUncertain`, `linkFailed`)? -- [ ] Probe error strings and the `t.me/c` topic link are unverified against production (smoke step 3). +- [ ] The `t.me/c` topic link and the rejected-post recreation are unverified against production (smoke step 3). - [ ] Known race: a refresh job whose `persistAnalysis` upsert runs during participation can overwrite `thread_id` with its stale value. This is pre-existing behavior; recovery is `/hackathon ` in the topic. diff --git a/openspec/changes/hackathon-participation/proposal.md b/openspec/changes/hackathon-participation/proposal.md index 0a642ea..359bfd6 100644 --- a/openspec/changes/hackathon-participation/proposal.md +++ b/openspec/changes/hackathon-participation/proposal.md @@ -17,7 +17,7 @@ After a General-chat analysis, the team decides whether to join. Today an admin - the result stays within 1–128 characters. - Idempotency: - if the analysis has a live topic, create nothing and reply with the topic link; - - if the link is stale (the topic was deleted), a lightweight probe detects it, the stale link is dropped, and the topic is recreated. + - if the link is stale (the topic was deleted), posting the analysis into it detects it (a rejected post), the stale link is dropped, and the topic is recreated. - On success: - post "✅ Participating in → " in General; - remove the button from the original message; diff --git a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md index 7c76e81..f272aa6 100644 --- a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md +++ b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md @@ -86,7 +86,7 @@ The system MUST NOT create a topic when the analysis is linked to a live topic. ### Requirement: Deleted Topic Is Recreated -The system MUST detect a linked topic that no longer exists, drop the stale link, and create and link a new topic as for a fresh confirmation. A topic that still exists MUST NOT be treated as deleted. +The system MUST detect a linked topic that no longer exists by posting the analysis into it (a rejected post means the topic is gone), drop the stale link, and create and link a new topic as for a fresh confirmation. A topic that still exists, or whose post fails for an unknown reason (unavailable, rate-limited), MUST NOT be treated as deleted. #### Scenario: Linked topic was deleted @@ -95,6 +95,13 @@ The system MUST detect a linked topic that no longer exists, drop the stale link - THEN the stale link is dropped and a new topic is created, linked and pinned - AND General shows the confirmation with the new link +#### Scenario: Topic check is inconclusive + +- GIVEN `meridian` is linked to a topic and posting the analysis into it fails as unavailable or rate-limited +- WHEN an admin confirms participation +- THEN no new topic is created and the link is kept +- AND General replies with the existing topic link + ### Requirement: At Most One Topic per Analysis The system MUST guarantee at most one topic per analysis under webhook redelivery and concurrent confirmations. Once a topic exists it MUST be linked immediately, and no later failure MUST cause the update to be retried into a second topic. From a65d7657599ddb0daf82e13002fa28eaad9a26c6 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:28:38 -0400 Subject: [PATCH 3/5] docs(hackathon-participation): record the post-based topic check in apply-progress --- .../hackathon-participation/apply-progress.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md index bf63209..e1e3fbb 100644 --- a/openspec/changes/hackathon-participation/apply-progress.md +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -134,3 +134,16 @@ Mode: Strict TDD. Base: main 934fd01 (PR1a and PR1b merged). Completed: Step 0 ( - 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`. + +## Fix: existing-topic check by posting (branch `fix/participation-topic-check`) + +Production evidence: after the user deleted topic 262, `/hackathon join` still replied "Este hackathon ya tiene tema" (`hackathon-join outcome ok`). `ForumTopicManager.probe` used `sendChatAction`, and Telegram accepts a chat action for a deleted thread, so the check never detected the deletion. + +- `participateInHackathon` now verifies a linked topic by posting the analysis into it through `postAnalysisAndLinkTopic` (which also re-pins and refreshes `pinnedMessageId`). + - Post succeeds: the topic is live, reply `already(link)`. + - `PublishFailedError` with `failureClass === "rejected"`: the topic is deleted, then the existing claim, create, link, post/pin, confirm path runs. + - Any other failure (`telegram-unavailable`, `rate-limited`, unexpected errors): unknown, logged as `topic-check-failed`, reply `already(link)`, never recreate. +- `probe`, `TopicProbe`, the adapter probe and its classifier, the fake probe and the adapter probe tests were removed (nothing else used them). The e2e `sendChatAction` stubs were dropped. +- TDD: RED first (4 failing tests: live post, deleted via rejected post, unavailable and rate-limited without recreate, redelivery after recreation), then GREEN. Focused file 28/28, `npm run typecheck` clean, full suite 76 files, 1018/1018 (was 1027; the 10 probe tests were removed, 1 net new use-case test). +- Known limit: a live re-check re-posts the analysis and re-pins it; the previous pinned message stays pinned (Telegram allows several pins). +- Commits: `3e648d6` fix(participation) code and tests, `65c2fac` docs (design decision 2, spec scenario, proposal). From bfd8baf7495335952fb3f7415931f552223dc998 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:35:31 -0400 Subject: [PATCH 4/5] fix(participation): recreate only when Telegram reports the thread is gone --- src/adapters/telegram/chat-publisher.ts | 4 +-- src/adapters/telegram/send-failure.ts | 16 ++++++++- src/domain/errors.ts | 10 ++++-- src/domain/ports.ts | 3 +- .../usecases/participate-in-hackathon.ts | 12 ++++--- src/domain/usecases/run-hackathon-job.ts | 5 ++- test/adapters/telegram/chat-publisher.test.ts | 36 ++++++++++++++++++- .../usecases/participate-in-hackathon.test.ts | 22 +++++++++--- 8 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/adapters/telegram/chat-publisher.ts b/src/adapters/telegram/chat-publisher.ts index 0a5c25d..e046662 100644 --- a/src/adapters/telegram/chat-publisher.ts +++ b/src/adapters/telegram/chat-publisher.ts @@ -2,7 +2,7 @@ import type { Api } from "grammy"; import { PublishFailedError } from "../../domain/errors"; import type { ChatPublisher } from "../../domain/ports"; import { participateButton } from "./copy"; -import { classifyTelegramFailure } from "./send-failure"; +import { classifyPublishFailure, classifyTelegramFailure } from "./send-failure"; // Callback data for the participation button: `hp:`. Slugs are capped // at 40 chars, so it always fits Telegram's 64-byte limit. The team is never @@ -59,7 +59,7 @@ export function createTelegramChatPublisher(api: Api): ChatPublisher { ); return sent.message_id; } catch (err) { - throw new PublishFailedError("sendMessage failed", classifyTelegramFailure(err)); + throw new PublishFailedError("sendMessage failed", classifyPublishFailure(err)); } }, diff --git a/src/adapters/telegram/send-failure.ts b/src/adapters/telegram/send-failure.ts index 8569010..0dc6307 100644 --- a/src/adapters/telegram/send-failure.ts +++ b/src/adapters/telegram/send-failure.ts @@ -1,5 +1,5 @@ import { GrammyError, HttpError } from "grammy"; -import type { AlertSendFailureClass } from "../../domain/errors"; +import type { AlertSendFailureClass, PublishFailureClass } from "../../domain/errors"; // Classifies a failed Telegram Bot API call into a fixed, non-sensitive // bucket a caller can safely log or branch on. `GrammyError` is Telegram's @@ -18,3 +18,17 @@ export function classifyTelegramFailure(err: unknown): AlertSendFailureClass { if (err instanceof HttpError) return "telegram-unavailable"; return "telegram-unavailable"; } + +// Telegram's wording when a forum thread no longer exists. +const THREAD_GONE_DESCRIPTION = /message thread not found|topic_id_invalid|topic_deleted/i; + +// Like `classifyTelegramFailure`, plus "thread-gone": a 400 whose description +// says the forum thread does not exist. Every other 4xx (a closed topic, no +// rights, ...) stays "rejected", because a LIVE topic can return those too. +// Description matching stays here; the domain only sees the class. +export function classifyPublishFailure(err: unknown): PublishFailureClass { + if (err instanceof GrammyError && err.error_code === 400 && THREAD_GONE_DESCRIPTION.test(err.description)) { + return "thread-gone"; + } + return classifyTelegramFailure(err); +} diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 0173c56..b7577e4 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -204,10 +204,16 @@ export class AnalysisNotFoundError extends DomainError {} // PublishFailedError(AlertSendFailureClass)"). Mirrors AlertSendFailedError // exactly — same three non-sensitive failure classes, same "still a // permanent-for-this-request failure" contract. +// +// `PublishFailureClass` adds "thread-gone": Telegram said the target forum +// thread does not exist (only `post` reports it). Any other 4xx, including a +// closed topic or missing rights on a live topic, stays "rejected". +export type PublishFailureClass = AlertSendFailureClass | "thread-gone"; + export class PublishFailedError extends DomainError { - readonly failureClass: AlertSendFailureClass; + readonly failureClass: PublishFailureClass; - constructor(message: string, failureClass: AlertSendFailureClass) { + constructor(message: string, failureClass: PublishFailureClass) { super(message); this.failureClass = failureClass; } diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 3a961ea..5f312df 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -269,7 +269,8 @@ export interface HackathonAnalysisRepo { ): Promise; // hackathon-participation (design.md decision 3): compare-and-set claim on // topic creation. Wins only when the row still holds `expectedThreadId` - // (null, or the stale id observed by the probe) and no live claim exists + // (null, or the stale id whose verifying post reported the thread gone) and + // no live claim exists // (`topic_claim_until <= now`); a win stamps `now + ttlMs`. Returns false // when the claim is lost. claimTopicCreation( diff --git a/src/domain/usecases/participate-in-hackathon.ts b/src/domain/usecases/participate-in-hackathon.ts index ae724da..074b7a3 100644 --- a/src/domain/usecases/participate-in-hackathon.ts +++ b/src/domain/usecases/participate-in-hackathon.ts @@ -73,7 +73,7 @@ export async function participateInHackathon( // 2. Verify an existing topic by doing the real action: post the analysis // into it. Telegram accepts a chat action for a deleted thread, so only a - // rejected post proves the topic is gone. Success and any ambiguous failure + // thread-gone post proves the topic is gone. Success and any ambiguous failure // both mean "keep it": never recreate on ambiguity. let expected: number | null = null; if (analysis.threadId !== null) { @@ -153,9 +153,11 @@ export async function participateInHackathon( return { kind: "created", replyText: [confirmed, ...notes].join("\n") }; } -// Posts (and pins) the analysis in the linked topic. Only a 4xx rejection -// (`rejected`, e.g. "message thread not found") is a positive deleted signal; -// unavailable, rate-limited and any other failure leave the topic as unknown. +// Posts (and pins) the analysis in the linked topic. Only `thread-gone` (the +// adapter saw a 400 saying the thread does not exist) is a positive deleted +// signal. `rejected` (closed topic, no rights, any other 4xx), unavailable, +// rate-limited and any other failure leave the topic as unknown: a live topic +// can produce them, and recreating would duplicate it. async function topicIsGone( input: ParticipateInHackathonInput, analysis: HackathonAnalysis, @@ -169,7 +171,7 @@ async function topicIsGone( ); return false; } catch (err) { - if (err instanceof PublishFailedError && err.failureClass === "rejected") return true; + if (err instanceof PublishFailedError && err.failureClass === "thread-gone") return true; logFailure(deps, input.teamId, "topic-check-failed", err); return false; } diff --git a/src/domain/usecases/run-hackathon-job.ts b/src/domain/usecases/run-hackathon-job.ts index a601d7d..6c8ce08 100644 --- a/src/domain/usecases/run-hackathon-job.ts +++ b/src/domain/usecases/run-hackathon-job.ts @@ -276,7 +276,10 @@ function classifyJobError(err: unknown): JobErrorClassification { reply: analysisCopy.notConfigured, }; } - if (err instanceof PublishFailedError && err.failureClass === "rejected") { + if ( + err instanceof PublishFailedError && + (err.failureClass === "rejected" || err.failureClass === "thread-gone") + ) { return { transient: false, refund: false, reason: "publish:rejected", reply: null }; } const name = err instanceof Error ? err.name : "unknown"; diff --git a/test/adapters/telegram/chat-publisher.test.ts b/test/adapters/telegram/chat-publisher.test.ts index 3d595c1..484e5e2 100644 --- a/test/adapters/telegram/chat-publisher.test.ts +++ b/test/adapters/telegram/chat-publisher.test.ts @@ -178,6 +178,40 @@ describe("createTelegramChatPublisher.pin / unpin", () => { }); }); +// R3-001: only a 400 whose description says the thread is gone is +// "thread-gone" (the deleted-topic signal). Every other 4xx, including a +// closed topic or missing rights on a LIVE topic, stays "rejected". +describe("createTelegramChatPublisher.post thread-gone classification", () => { + const cases = [ + ["400 message thread not found", 400, "Bad Request: message thread not found", "thread-gone"], + ["400 TOPIC_ID_INVALID", 400, "Bad Request: TOPIC_ID_INVALID", "thread-gone"], + ["400 TOPIC_DELETED", 400, "Bad Request: TOPIC_DELETED", "thread-gone"], + ["400 TOPIC_CLOSED (live, closed topic)", 400, "Bad Request: TOPIC_CLOSED", "rejected"], + ["403 not enough rights to send", 403, "Forbidden: not enough rights to send text messages to the chat", "rejected"], + ["403 with thread-not-found wording", 403, "Forbidden: message thread not found", "rejected"], + ] as const; + + for (const [label, code, description, failureClass] of cases) { + it(`${label} becomes PublishFailedError(${failureClass}) without leaking the description`, async () => { + stubTelegramApi(() => ({ ok: false, error_code: code, description })); + + const err = await makePublisher().post(555, 42, "hello").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(PublishFailedError); + expect((err as PublishFailedError).failureClass).toBe(failureClass); + expect((err as PublishFailedError).message).not.toContain(description); + }); + } + + it("pin keeps a 400 thread-not-found as rejected (only post signals a gone thread)", async () => { + stubTelegramApi(() => ({ ok: false, error_code: 400, description: "Bad Request: message thread not found" })); + + const err = await makePublisher().pin(555, 77).catch((e: unknown) => e); + + expect((err as PublishFailedError).failureClass).toBe("rejected"); + }); +}); + describe("createTelegramChatPublisher failures", () => { const operations = [ ["post", (p: ReturnType) => p.post(555, 42, "hello")], @@ -187,7 +221,7 @@ describe("createTelegramChatPublisher failures", () => { const failures = [ ["a 429", { ok: false, error_code: 429, description: "Too Many Requests: retry after 5" }, "rate-limited"], - ["a 400 (e.g. topic deleted)", { ok: false, error_code: 400, description: "Bad Request: message thread not found" }, "rejected"], + ["a 400 (e.g. chat not found)", { ok: false, error_code: 400, description: "Bad Request: chat not found" }, "rejected"], ["a 403 (bot lacks pin rights)", { ok: false, error_code: 403, description: "Forbidden: not enough rights to pin a message" }, "rejected"], ["a 5xx", { ok: false, error_code: 502, description: "Bad Gateway" }, "telegram-unavailable"], ] as const; diff --git a/test/domain/usecases/participate-in-hackathon.test.ts b/test/domain/usecases/participate-in-hackathon.test.ts index 0fabb3b..77a94fd 100644 --- a/test/domain/usecases/participate-in-hackathon.test.ts +++ b/test/domain/usecases/participate-in-hackathon.test.ts @@ -100,7 +100,7 @@ const input = (over: Partial<{ callbackMessageId: number | null; slug: string; c function rejectPostsTo(deps: ReturnType, deletedThreadId: number) { const realPost = deps.chatPublisher.post; deps.chatPublisher.post = async (chatId, threadId, text, options) => { - if (threadId === deletedThreadId) throw new PublishFailedError("sendMessage failed", "rejected"); + if (threadId === deletedThreadId) throw new PublishFailedError("sendMessage failed", "thread-gone"); return realPost(chatId, threadId, text, options); }; } @@ -204,12 +204,18 @@ describe("participateInHackathon: redelivery and live topics", () => { ]); }); - it.each(["telegram-unavailable", "rate-limited"] as const)( + const failures: Array<[string, () => Error, string]> = [ + ["rejected (closed topic / 403 on a live topic)", () => new PublishFailedError("sendMessage failed", "rejected"), "PublishFailedError"], + ["telegram-unavailable", () => new PublishFailedError("sendMessage failed", "telegram-unavailable"), "PublishFailedError"], + ["rate-limited", () => new PublishFailedError("sendMessage failed", "rate-limited"), "PublishFailedError"], + ["an unexpected non-PublishFailedError", () => new TypeError("boom"), "TypeError"], + ]; + it.each(failures)( "keeps the link and does not recreate when the verifying post fails as %s", - async (failureClass) => { + async (_label, makeError, errorName) => { const deps = setup({ row: { threadId: 40, generalMessageId: 900 } }); deps.chatPublisher.post = async () => { - throw new PublishFailedError("sendMessage failed", failureClass); + throw makeError(); }; const result = await participateInHackathon(input(), deps); expect(result).toEqual({ kind: "already", replyText: `Este hackathon ya tiene tema: ${LINK(40)}` }); @@ -217,6 +223,14 @@ describe("participateInHackathon: redelivery and live topics", () => { expect(deps.hackathonAnalysisRepo.claims.size).toBe(0); expect(row(deps).threadId).toBe(40); expect(deps.chatPublisher.cleared).toEqual([{ chatId: CHAT_ID, messageId: 900 }]); + expect(deps.logger.entries).toContainEqual( + expect.objectContaining({ + event: "hackathon-participate", + outcome: "error", + reason: "topic-check-failed", + errorCode: errorName, + }), + ); }, ); From 89b925dc4cafa911297dc5ed47c294d989676ec3 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:37:58 -0400 Subject: [PATCH 5/5] docs(hackathon-participation): document the thread-gone based topic check --- .../hackathon-participation/apply-progress.md | 12 ++++++++++++ openspec/changes/hackathon-participation/design.md | 10 +++++----- openspec/changes/hackathon-participation/proposal.md | 10 ++++++---- .../specs/hackathon-participation/spec.md | 4 ++-- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md index e1e3fbb..e240c15 100644 --- a/openspec/changes/hackathon-participation/apply-progress.md +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -147,3 +147,15 @@ Production evidence: after the user deleted topic 262, `/hackathon join` still r - TDD: RED first (4 failing tests: live post, deleted via rejected post, unavailable and rate-limited without recreate, redelivery after recreation), then GREEN. Focused file 28/28, `npm run typecheck` clean, full suite 76 files, 1018/1018 (was 1027; the 10 probe tests were removed, 1 net new use-case test). - Known limit: a live re-check re-posts the analysis and re-pins it; the previous pinned message stays pinned (Telegram allows several pins). - Commits: `3e648d6` fix(participation) code and tests, `65c2fac` docs (design decision 2, spec scenario, proposal). + +## Fix: recreate only on a thread-gone post (review R3-001..R3-004) + +Review found that treating any `rejected` post as "deleted" would recreate a LIVE topic that answers 400 TOPIC_CLOSED or 403, creating a duplicate and moving the link. + +- New `PublishFailureClass = AlertSendFailureClass | "thread-gone"` in `errors.ts`; `PublishFailedError` carries it. `AlertSendFailureClass` and `classifyTelegramFailure` are unchanged, so the alert sender behaves as before. +- New `classifyPublishFailure` in `send-failure.ts`: a 400 whose description matches `/message thread not found|topic_id_invalid|topic_deleted/i` is `thread-gone`; everything else defers to `classifyTelegramFailure`. Only `ChatPublisher.post` uses it (pin, unpin and clearButtons keep `rejected`). +- `participateInHackathon` recreates only on `thread-gone`. `rejected`, `unavailable`, `rate-limited` and unexpected errors log `topic-check-failed` and return `already(link)`. +- `run-hackathon-job` treats `thread-gone` like `rejected` (`publish:rejected`, permanent), so a post to a deleted thread in the job path is not retried. +- TDD: RED first (7 failing: 3 adapter thread-gone cases, the use-case rejected-no-recreate case and 3 recreate tests moved to thread-gone), then GREEN. Added adapter cases (400 thread not found, TOPIC_ID_INVALID, TOPIC_DELETED to thread-gone; TOPIC_CLOSED, 403 and a 403 with thread wording to rejected; pin stays rejected) and use-case cases (rejected, unexpected TypeError, with the topic-check-failed log asserted). +- R3-003 and R3-004: proposal, design and spec now describe the post-based check and its residual risk (a different Telegram description would not recreate: the safe side, checked by the smoke test); the `claimTopicCreation` comment no longer mentions probe. +- Full suite 76 files, 1027/1027; `npm run typecheck` clean. diff --git a/openspec/changes/hackathon-participation/design.md b/openspec/changes/hackathon-participation/design.md index 78c4ad2..45fcc40 100644 --- a/openspec/changes/hackathon-participation/design.md +++ b/openspec/changes/hackathon-participation/design.md @@ -13,7 +13,7 @@ The use case owns five things: the admin gate, the slug lookup, the existing-top | # | Topic | Choice | Rejected (tradeoff) | |---|---|---|---| | 1 | Ports | New ISP port `ForumTopicManager { create }`. `ChatPublisher.post` gains an optional `PostOptions { participateSlug?: string }`, which is semantic, not a keyboard. It also gains `clearButtons(chatId, messageId)`. The adapter owns the label, the `hp:` encoding and `editMessageReplyMarkup` (omitting `reply_markup` removes the keyboard). | Extending `ChatPublisher` with topic methods: the consumer would see topic rights it never needs. A domain-built `{label,data}` keyboard would leak the Telegram payload format into the domain. | -| 2 | Existing-topic check | Do the real action: `postAnalysisAndLinkTopic` posts (and re-pins) the analysis into the linked thread. **Post succeeds ⇒ the topic is live**: reply `already(link)`; the analysis is re-posted there. A `PublishFailedError` classified `rejected` (a 4xx such as 400 "message thread not found") ⇒ the topic is deleted: `expected = threadId`, then claim → create → link → post/pin → confirm. `telegram-unavailable` and `rate-limited` (5xx, network, 429) ⇒ unknown: never recreate on ambiguity, reply `already(link)`. | `sendChatAction(chat, "typing", {message_thread_id})`: production showed Telegram accepts a chat action for a deleted thread, so it never detected the deletion. `editForumTopic`, `closeForumTopic`/`reopenForumTopic`: they need `can_manage_topics` and change topic state. The `PublishFailedError.failureClass` already carries the classification, so the domain stays free of grammY and Telegram error strings. | +| 2 | Existing-topic check | Do the real action: `postAnalysisAndLinkTopic` posts (and re-pins) the analysis into the linked thread. **Post succeeds ⇒ the topic is live**: reply `already(link)`; the analysis is re-posted there. A `PublishFailedError` classified `thread-gone` (the adapter assigns it only to a 400 whose description matches `message thread not found`, `TOPIC_ID_INVALID` or `TOPIC_DELETED`) ⇒ the topic is deleted: `expected = threadId`, then claim → create → link → post/pin → confirm. `rejected` (any other 4xx: a closed topic or missing rights on a live topic), `telegram-unavailable` and `rate-limited` (5xx, network, 429) ⇒ unknown: never recreate on ambiguity, reply `already(link)`. | `sendChatAction(chat, "typing", {message_thread_id})`: production showed Telegram accepts a chat action for a deleted thread, so it never detected the deletion. `editForumTopic`, `closeForumTopic`/`reopenForumTopic`: they need `can_manage_topics` and change topic state. Treating every `rejected` as deleted: a live topic can return 400 TOPIC_CLOSED or 403, so it would create a duplicate topic. The `PublishFailedError.failureClass` carries the classification, so grammY and description matching stay in the adapter and the domain stays free of Telegram error strings. | | 3 | Concurrency and redelivery | **(a) Migration 0004 with a claim.** `topic_claim_until` is set by a conditional `UPDATE … WHERE team_id=? AND id=? AND thread_id IS ?expected AND topic_claim_until <= ?now` (CAS on the observed `thread_id`: null, or the stale id). TTL is 60 s. | (b) Accepting the race. Webhook `max_connections` defaults to 40, so parallel taps are real, and the cost (a duplicate public topic) is visible and manual to undo. | | 4 | Button and message id | Migration 0004 adds `general_message_id`. A helper `postToGeneral` in `run-hackathon-job` (used by both General sites) posts with `participateSlug`. It then calls `setGeneralMessageId`, best-effort: catch and log, never retry, because a retry would repost. Each repost overwrites the id; old buttons stay idempotent. `/hackathon join` clears the stored id's button. Old analyses have null, so nothing is cleared. | Not storing the id: `join` could never remove the button. | | 5 | Callback handling | `bot.callbackQuery(/^hp:([a-z0-9]+(?:-[a-z0-9]+)*)$/)`. A private chat or a missing chat is ignored. The new `callbackCallerLocation(ctx)` reads `ctx.chat.id`, `ctx.from.id`, `ctx.msg?.message_thread_id` and `ctx.callbackQuery.message?.message_id`. The team comes from `teamRepo.findByChatId` and the role from `ctx.from.id` (`resolveGroupMembership`). Non-members and non-admins get the same alert (`show_alert`). The handler answers the callback early and best-effort. | Changing `callerLocation` to `ctx.msg`: it would widen every command to edited or channel messages. Trusting a team id in the payload is ruled out by the spec. | @@ -24,7 +24,7 @@ The use case owns five things: the admin gate, the slug lookup, the existing-top ## Use Case Step Order (`participateInHackathon`) 1. Membership role check: not an admin ⇒ `UnauthorizedError`. `findBySlug` finds nothing ⇒ `AnalysisNotFoundError`. -2. `threadId != null` ⇒ post the analysis into that thread. On success or an unknown failure (`telegram-unavailable`, `rate-limited`): clear the buttons (best-effort) and return `already(link)`. On a `rejected` post: set `expected = threadId`. +2. `threadId != null` ⇒ post the analysis into that thread. On success or an unknown failure (`rejected`, `telegram-unavailable`, `rate-limited`, unexpected errors; logged as `topic-check-failed`): clear the buttons (best-effort) and return `already(link)`. On a `thread-gone` post: set `expected = threadId`. 3. `claimTopicCreation(expected, now, 60s)`. If it returns false, re-read the analysis. Linked ⇒ `already(link)`. Otherwise `busy`, a neutral no-op: the callback is answered silently and nothing is posted. 4. `create(chat, topicNameFor(analysis))` can fail as follows: @@ -110,7 +110,7 @@ or record `size:exception`. | Layer | Cases | Approach | |---|---|---| | Pure | `topicNameFor` (control and bidi characters, whitespace, empty ⇒ slug, 128-unit cap, no split surrogate); `topicLink` (`-100` stripping, non-`-100` ⇒ null); `parseJoinArgument` (bare, non-slug, three tokens) | Vitest | -| Use case | Non-admin or non-member changes nothing. A live topic (the post succeeds) gets `already` and nothing is created. An unavailable or rate-limited post is treated as unknown: no recreate. A **deleted** topic (the post is `rejected`) is recreated and the stale id replaced. **Redelivery:** a second call after success returns `already`. **Concurrent taps:** a claim loss followed by a re-read gives `already` or `busy`, with exactly one `create`. Missing rights or not a forum: the claim is released and nothing is linked. `unavailable` keeps the claim. **Link failure / post failure after creation**: never throws. **Pin failure** adds a note. A button-clear failure is ignored. | `FakeForumTopicManager` (scripted outcomes, call log), plus the fake publisher and repo | +| Use case | Non-admin or non-member changes nothing. A live topic (the post succeeds) gets `already` and nothing is created. A rejected (closed topic, no rights), unavailable or rate-limited post, or an unexpected error, is treated as unknown: no recreate, logged as `topic-check-failed`. A **deleted** topic (the post is `thread-gone`) is recreated and the stale id replaced. **Redelivery:** a second call after success returns `already`. **Concurrent taps:** a claim loss followed by a re-read gives `already` or `busy`, with exactly one `create`. Missing rights or not a forum: the claim is released and nothing is linked. `unavailable` keeps the claim. **Link failure / post failure after creation**: never throws. **Pin failure** adds a note. A button-clear failure is ignored. | `FakeForumTopicManager` (scripted outcomes, call log), plus the fake publisher and repo | | D1 | Claim CAS on `IS NULL` and `IS stale`, TTL expiry, release, `setGeneralMessageId`; `save`/`persistAnalysis` leave the new columns alone | vitest-pool-workers | | Adapters | Create-error classification table; `clearButtons` sends `editMessageReplyMarkup` without markup; the keyboard carries `hp:` of at most 64 bytes | Injected `Api` stub | | Handlers | The `hp:` alert for non-admins; a private chat is ignored; the join usage; a failing safe General post never produces a 500; the consumer General post has the button, a topic post has none, and a `setGeneralMessageId` failure still acks | `telegram-stub.ts`, callback fixtures from `commands.test.ts` | @@ -130,12 +130,12 @@ The shell, VCS and PR rows are N/A: this change has no shell, subprocess, VCS or Both are additive. Operator steps: 1. Apply 0004 remotely, then deploy. 2. Grant "Administrar temas". -3. Smoke test: tap the button; delete the topic and tap again (validates that the rejected post recreates the topic, and the link format); run a join on an old analysis. +3. Smoke test: tap the button; delete the topic and tap again (validates that a thread-gone post recreates the topic and that the description matches, and the link format); run a join on an old analysis. Rollback: redeploy the previous Worker. The columns are ignored. ## Open Questions - [ ] Can the spec adopt the three design-proposed strings (`createFailed`, `createUncertain`, `linkFailed`)? -- [ ] The `t.me/c` topic link and the rejected-post recreation are unverified against production (smoke step 3). +- [ ] The `t.me/c` topic link and the thread-gone recreation (the exact Telegram description) are unverified against production (smoke step 3). - [ ] Known race: a refresh job whose `persistAnalysis` upsert runs during participation can overwrite `thread_id` with its stale value. This is pre-existing behavior; recovery is `/hackathon ` in the topic. diff --git a/openspec/changes/hackathon-participation/proposal.md b/openspec/changes/hackathon-participation/proposal.md index 359bfd6..8a1c0d8 100644 --- a/openspec/changes/hackathon-participation/proposal.md +++ b/openspec/changes/hackathon-participation/proposal.md @@ -17,7 +17,8 @@ After a General-chat analysis, the team decides whether to join. Today an admin - the result stays within 1–128 characters. - Idempotency: - if the analysis has a live topic, create nothing and reply with the topic link; - - if the link is stale (the topic was deleted), posting the analysis into it detects it (a rejected post), the stale link is dropped, and the topic is recreated. + - if the link is stale (the topic was deleted), posting the analysis into it detects it (only a 400 that says the thread does not exist counts), the stale link is dropped, and the topic is recreated; + - any other failure of that post (closed topic, missing rights, 5xx, 429) is inconclusive: nothing is recreated and the link is kept. - On success: - post "✅ Participating in → " in General; - remove the button from the original message; @@ -50,7 +51,7 @@ After a General-chat analysis, the team decides whether to join. Today an admin ## Approach This follows the exploration's recommendation, D+A. -- A new ISP port, `ForumTopicManager`, provides create and probe, with rejections classified. +- A new ISP port, `ForumTopicManager`, provides create, with rejections classified. The existing-topic check reuses `ChatPublisher.post`, whose adapter reports a distinct `thread-gone` failure class. - `ChatPublisher.post` gets an optional inline keyboard, plus a way to remove it. - `callerLocation` gets a `callback_query` variant. - Once the topic exists, link it immediately and never rethrow. Partial failures reply with a recovery hint. @@ -75,7 +76,8 @@ Fetch, LLM and validation are untouched, so `npm run harness` is not required. | Redelivery creates a second topic | Med | Link right after creating; no rethrow after that | | Concurrent taps | Low | Claim migration, or accept the race (design) | | Bot lacks topic rights | Med | Operator reply; nothing persisted | -| The probe misreads a live topic as deleted | Low | Probe mechanism validated in the design | +| The check recreates a live topic | Low | Only a 400 whose description matches "message thread not found", `TOPIC_ID_INVALID` or `TOPIC_DELETED` is `thread-gone`; a closed topic or missing rights stay `rejected` and never recreate | +| A deleted topic is not recreated because Telegram words the error differently | Low | Safe side: the link is kept and `already` is returned; the Telegram smoke test verifies the wording | ## Rollback Plan @@ -104,7 +106,7 @@ About 350–500 lines, delivered as 2 chained PRs: ## Open questions (for design) 1. A claim migration versus accepting the concurrent-tap race. -2. The mechanism for the stale-topic probe. +2. The mechanism for the stale-topic check. 3. How the button is removed after a `/hackathon join`, when the General message id is unknown. ## Proposal question round diff --git a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md index f272aa6..bc83a92 100644 --- a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md +++ b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md @@ -86,7 +86,7 @@ The system MUST NOT create a topic when the analysis is linked to a live topic. ### Requirement: Deleted Topic Is Recreated -The system MUST detect a linked topic that no longer exists by posting the analysis into it (a rejected post means the topic is gone), drop the stale link, and create and link a new topic as for a fresh confirmation. A topic that still exists, or whose post fails for an unknown reason (unavailable, rate-limited), MUST NOT be treated as deleted. +The system MUST detect a linked topic that no longer exists by posting the analysis into it (only a post that fails as `thread-gone`, a 400 saying the thread does not exist, means the topic is gone), drop the stale link, and create and link a new topic as for a fresh confirmation. A topic that still exists, or whose post fails for any other reason (closed topic, missing rights or another rejection, unavailable, rate-limited), MUST NOT be treated as deleted. #### Scenario: Linked topic was deleted @@ -97,7 +97,7 @@ The system MUST detect a linked topic that no longer exists by posting the analy #### Scenario: Topic check is inconclusive -- GIVEN `meridian` is linked to a topic and posting the analysis into it fails as unavailable or rate-limited +- GIVEN `meridian` is linked to a topic and posting the analysis into it fails as rejected (closed topic, no rights), unavailable or rate-limited - WHEN an admin confirms participation - THEN no new topic is created and the link is kept - AND General replies with the existing topic link