From e8c66490619f15b47416829484be90751f3d5059 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 16:55:47 -0400 Subject: [PATCH 1/6] test(fakes): preserve generalMessageId and claim state on fake repo save --- .../domain/usecases/analyze-hackathon.test.ts | 25 ++++++ test/fakes/hackathon-analysis-repo.test.ts | 78 +++++++++++++++++++ test/fakes/index.ts | 10 ++- 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 test/fakes/hackathon-analysis-repo.test.ts diff --git a/test/domain/usecases/analyze-hackathon.test.ts b/test/domain/usecases/analyze-hackathon.test.ts index 314b44d..f13b4fe 100644 --- a/test/domain/usecases/analyze-hackathon.test.ts +++ b/test/domain/usecases/analyze-hackathon.test.ts @@ -545,4 +545,29 @@ describe("analyzeHackathon: builds (does not persist) and suggests", () => { // Only the pre-seeded row exists — analyzeHackathon does not write. expect(deps.hackathonAnalysisRepo.rows).toHaveLength(1); }); + + // hackathon-participation: the SQL upsert never writes general_message_id, so + // the refreshed entity must carry the stored (non-null) id over. + it("keeps a non-null generalMessageId from the existing row on refresh", async () => { + const deps = makeDeps(); + deps.hackathonAnalysisRepo.rows.push({ + id: "existing-1", + teamId: TEAM_ID, + slug: "meridian", + sourceUrl: SOURCE_URL, + normalizedUrl: NORMALIZED_URL, + fields: {} as never, + suggestedRepos: [], + threadId: null, + pinnedMessageId: null, + generalMessageId: 321, + createdAt: 10, + updatedAt: 10, + }); + + const result = await analyzeHackathon(makeInput(), deps); + + expect(result.id).toBe("existing-1"); + expect(result.generalMessageId).toBe(321); + }); }); diff --git a/test/fakes/hackathon-analysis-repo.test.ts b/test/fakes/hackathon-analysis-repo.test.ts new file mode 100644 index 0000000..1234d6a --- /dev/null +++ b/test/fakes/hackathon-analysis-repo.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { asTeamId } from "../../src/domain/ids"; +import type { HackathonAnalysis } from "../../src/domain/entities"; +import { fakeAnalysisJobRepo, fakeHackathonAnalysisRepo } from "./index"; + +const TEAM = asTeamId("team-1"); + +function analysis(overrides: Partial = {}): HackathonAnalysis { + return { + id: "a-1", + teamId: TEAM, + slug: "meridian", + sourceUrl: "https://example.com/e", + normalizedUrl: "https://example.com/e", + fields: {} as never, + suggestedRepos: [], + threadId: null, + pinnedMessageId: null, + generalMessageId: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +// The fake must mirror the D1 upsert: `save` never writes general_message_id +// or topic_claim_until, so a refresh cannot clobber them. +describe("fakeHackathonAnalysisRepo.save", () => { + it("preserves the stored generalMessageId across a save", async () => { + const repo = fakeHackathonAnalysisRepo(); + await repo.save(analysis()); + await repo.setGeneralMessageId(TEAM, "a-1", 555); + + await repo.save(analysis({ generalMessageId: null, updatedAt: 9 })); + + expect((await repo.findById(TEAM, "a-1"))?.generalMessageId).toBe(555); + expect((await repo.findById(TEAM, "a-1"))?.updatedAt).toBe(9); + }); + + it("preserves the live claim state across a save", async () => { + const repo = fakeHackathonAnalysisRepo(); + await repo.save(analysis()); + expect(await repo.claimTopicCreation(TEAM, "a-1", null, 100, 60_000)).toBe(true); + + await repo.save(analysis({ updatedAt: 5 })); + + expect(await repo.claimTopicCreation(TEAM, "a-1", null, 101, 60_000)).toBe(false); + }); + + it("does not keep the caller's object by reference", async () => { + const repo = fakeHackathonAnalysisRepo(); + const input = analysis(); + await repo.save(input); + + input.slug = "mutated"; + + expect((await repo.findById(TEAM, "a-1"))?.slug).toBe("meridian"); + }); + + it("stores a first insert as given", async () => { + const repo = fakeHackathonAnalysisRepo(); + await repo.save(analysis({ generalMessageId: 7 })); + expect((await repo.findById(TEAM, "a-1"))?.generalMessageId).toBe(7); + }); +}); + +describe("fakeAnalysisJobRepo.persistAnalysis", () => { + it("preserves the stored generalMessageId through the delegated save", async () => { + const repo = fakeHackathonAnalysisRepo(); + await repo.save(analysis()); + await repo.setGeneralMessageId(TEAM, "a-1", 555); + const jobs = fakeAnalysisJobRepo({ hackathonAnalysisRepo: repo }); + + await jobs.persistAnalysis("job-1", analysis({ generalMessageId: null })); + + expect((await repo.findById(TEAM, "a-1"))?.generalMessageId).toBe(555); + }); +}); diff --git a/test/fakes/index.ts b/test/fakes/index.ts index b3a617c..59af25b 100644 --- a/test/fakes/index.ts +++ b/test/fakes/index.ts @@ -415,9 +415,15 @@ export function fakeHackathonAnalysisRepo(): HackathonAnalysisRepo & { slugExists: async (teamId: TeamId, slug: string) => rows.some((r) => r.teamId === teamId && r.slug === slug), save: async (analysis: HackathonAnalysis) => { + // Mirrors the D1 upsert: it never writes general_message_id (nor + // topic_claim_until, which lives in `claims`), so an update keeps the + // stored value. The row is copied, never stored by reference. const idx = rows.findIndex((r) => r.id === analysis.id); - if (idx >= 0) rows[idx] = analysis; - else rows.push(analysis); + if (idx >= 0) { + rows[idx] = { ...analysis, generalMessageId: rows[idx]!.generalMessageId }; + } else { + rows.push({ ...analysis }); + } }, listByTeam: async (teamId: TeamId) => rows.filter((r) => r.teamId === teamId), moveTopicLink: async ( From ed5fa16ea809b0bbffc6d20cfd49b0d91f7c9d41 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 16:55:47 -0400 Subject: [PATCH 2/6] docs(ports): restore ChatPublisher contract comment and align claim expiry to <= --- openspec/changes/hackathon-participation/design.md | 2 +- src/domain/ports.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/changes/hackathon-participation/design.md b/openspec/changes/hackathon-participation/design.md index 7e4f4be..c21dc33 100644 --- a/openspec/changes/hackathon-participation/design.md +++ b/openspec/changes/hackathon-participation/design.md @@ -14,7 +14,7 @@ The use case owns five things: the admin gate, the slug lookup, the live/deleted |---|---|---|---| | 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. | -| 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. | +| 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. | | 6 | Argument parsing | A new pure `parseJoinArgument(arg)` in `domain/hackathon/argument.ts`, checked **before** the whitespace rule. It returns `join` + slug when the argument is `^join\s+(\S+)$` and the slug is slug-shaped. It returns `join-usage` for a bare `join`, for `join ` and for `join a b`. Otherwise it returns null, and the existing rules apply. | Loosening the whitespace rule globally. | diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 09ef513..60dd102 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -270,7 +270,7 @@ export interface HackathonAnalysisRepo { // 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 - // (`topic_claim_until < now`); a win stamps `now + ttlMs`. Returns false + // (`topic_claim_until <= now`); a win stamps `now + ttlMs`. Returns false // when the claim is lost. claimTopicCreation( teamId: TeamId, @@ -337,9 +337,6 @@ export interface RepoMetadataSource { fetchDescription(repo: RepoFullName): Promise; } -// design.md "Interfaces / Contracts". `post` returns the new message id -// (needed to `pin`/`unpin` it later) and throws PublishFailedError on -// failure, mirroring AlertSender. // 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. @@ -366,6 +363,9 @@ export interface PostOptions { participateSlug?: string; } +// design.md "Interfaces / Contracts". `post` returns the new message id +// (needed to `pin`/`unpin` it later) and throws PublishFailedError on +// failure, mirroring AlertSender. export interface ChatPublisher { post( chatId: number, From 09e9c7fee92f0a1cbfae4bcf1d3f1dc7091f7072 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 16:56:24 -0400 Subject: [PATCH 3/6] feat(hackathon): add topic name, topic link and join argument helpers --- src/domain/hackathon/argument.ts | 17 ++++++++ src/domain/hackathon/topic.ts | 41 ++++++++++++++++++ test/domain/hackathon/argument.test.ts | 29 ++++++++++++- test/domain/hackathon/topic.test.ts | 59 ++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/domain/hackathon/topic.ts create mode 100644 test/domain/hackathon/topic.test.ts diff --git a/src/domain/hackathon/argument.ts b/src/domain/hackathon/argument.ts index 048ad00..fa46c32 100644 --- a/src/domain/hackathon/argument.ts +++ b/src/domain/hackathon/argument.ts @@ -19,3 +19,20 @@ export function classifyHackathonArgument(raw: string): HackathonArgument { SLUG_PATTERN.test(raw) && !raw.includes(".") && !raw.includes(":"); return isSlugShaped ? { kind: "slug", value: raw } : { kind: "url", value: raw }; } + +export type JoinArgument = + | { kind: "join"; slug: string } + | { kind: "join-usage" }; + +// `/hackathon join ` (hackathon-participation design.md decision 6). +// Checked BEFORE the whitespace rule of the classic argument handling: a bare +// `join`, a non-slug target or extra tokens all yield the usage line; any +// other argument returns null so the existing rules apply unchanged. +export function parseJoinArgument(raw: string): JoinArgument | null { + const match = /^join(?:\s+(.*))?$/su.exec(raw.trim()); + if (!match) return null; + const rest = (match[1] ?? "").trim(); + if (rest === "" || /\s/u.test(rest)) return { kind: "join-usage" }; + const isSlug = SLUG_PATTERN.test(rest); + return isSlug ? { kind: "join", slug: rest } : { kind: "join-usage" }; +} diff --git a/src/domain/hackathon/topic.ts b/src/domain/hackathon/topic.ts new file mode 100644 index 0000000..f37f0a2 --- /dev/null +++ b/src/domain/hackathon/topic.ts @@ -0,0 +1,41 @@ +// Pure helpers for the participation topic (hackathon-participation design.md +// "Topic name" and decision 7 "Deep link"). + +const TOPIC_PREFIX = "🏆 "; +// Telegram's forum topic name limit, counted in UTF-16 code units. +const TOPIC_NAME_MAX = 128; +const ELLIPSIS = "…"; + +// The extracted name is untrusted page text: whitespace (newlines included) +// collapses to one space first, then control (\p{Cc}) and format (\p{Cf}, +// bidi spoofing) characters are dropped, and the result is trimmed. Falls +// back to the slug when nothing is left. +export function sanitizeTopicName(name: string | null | undefined, slug: string): string { + const cleaned = (name ?? "") + .replace(/\s+/gu, " ") + .replace(/[\p{Cc}\p{Cf}]/gu, "") + .replace(/\s+/gu, " ") + .trim(); + return cleaned === "" ? slug : cleaned; +} + +export function topicNameFor(name: string | null | undefined, slug: string): string { + const sanitized = sanitizeTopicName(name, slug); + const room = TOPIC_NAME_MAX - TOPIC_PREFIX.length; + if (sanitized.length <= room) return TOPIC_PREFIX + sanitized; + // Cut at code-point boundaries, leaving one unit for the ellipsis. + let cut = ""; + for (const ch of sanitized) { + if (cut.length + ch.length > room - ELLIPSIS.length) break; + cut += ch; + } + return TOPIC_PREFIX + cut + ELLIPSIS; +} + +// Telegram's "Copy link" format for a topic (a topic id is its creation +// message id). Only supergroup ids (`-100…`) map to a `t.me/c` link. +export function topicLink(chatId: number, threadId: number): string | null { + const id = String(chatId); + if (!id.startsWith("-100")) return null; + return `https://t.me/c/${id.slice(4)}/${threadId}`; +} diff --git a/test/domain/hackathon/argument.test.ts b/test/domain/hackathon/argument.test.ts index 0148394..5ab2051 100644 --- a/test/domain/hackathon/argument.test.ts +++ b/test/domain/hackathon/argument.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classifyHackathonArgument } from "../../../src/domain/hackathon/argument"; +import { classifyHackathonArgument, parseJoinArgument } from "../../../src/domain/hackathon/argument"; describe("classifyHackathonArgument", () => { it("classifies a slug-shaped argument as a slug (spec: Slug-shaped argument)", () => { @@ -34,3 +34,30 @@ describe("classifyHackathonArgument", () => { expect(classifyHackathonArgument("")).toEqual({ kind: "url", value: "" }); }); }); + +describe("parseJoinArgument", () => { + it("parses `join ` into a join with the slug", () => { + expect(parseJoinArgument("join meridian")).toEqual({ kind: "join", slug: "meridian" }); + expect(parseJoinArgument("join meridian-2")).toEqual({ kind: "join", slug: "meridian-2" }); + }); + + it("returns join-usage for a bare join", () => { + expect(parseJoinArgument("join")).toEqual({ kind: "join-usage" }); + }); + + it("returns join-usage for a non-slug target", () => { + expect(parseJoinArgument("join Not_Slug")).toEqual({ kind: "join-usage" }); + expect(parseJoinArgument("join https://x.com/a")).toEqual({ kind: "join-usage" }); + }); + + it("returns join-usage when more than one token follows join", () => { + expect(parseJoinArgument("join a b")).toEqual({ kind: "join-usage" }); + }); + + it("returns null for any other argument so the existing rules apply", () => { + expect(parseJoinArgument("meridian")).toBeNull(); + expect(parseJoinArgument("https://example.com/event")).toBeNull(); + expect(parseJoinArgument("joined meridian")).toBeNull(); + expect(parseJoinArgument("")).toBeNull(); + }); +}); diff --git a/test/domain/hackathon/topic.test.ts b/test/domain/hackathon/topic.test.ts new file mode 100644 index 0000000..975a92a --- /dev/null +++ b/test/domain/hackathon/topic.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { topicLink, topicNameFor } from "../../../src/domain/hackathon/topic"; + +describe("topicNameFor", () => { + it("prefixes the name with the trophy and collapses whitespace", () => { + expect(topicNameFor("Meridian Hack\n2026", "meridian")).toBe("🏆 Meridian Hack 2026"); + }); + + it("strips control and bidi format characters", () => { + expect(topicNameFor("Mer\u0000id‮ian​", "meridian")).toBe("🏆 Meridian"); + }); + + it("falls back to the slug when the name is missing or empty after sanitizing", () => { + expect(topicNameFor(undefined, "meridian")).toBe("🏆 meridian"); + expect(topicNameFor(null, "meridian")).toBe("🏆 meridian"); + expect(topicNameFor(" ​\n ", "meridian-2")).toBe("🏆 meridian-2"); + }); + + it("keeps a name that fits within 128 UTF-16 units untouched", () => { + const name = "a".repeat(128 - "🏆 ".length); + const out = topicNameFor(name, "s"); + expect(out).toBe(`🏆 ${name}`); + expect(out.length).toBe(128); + }); + + it("cuts an overlong name to at most 128 units and ends with an ellipsis", () => { + const out = topicNameFor("b".repeat(300), "s"); + expect(out.length).toBeLessThanOrEqual(128); + expect(out.startsWith("🏆 bbb")).toBe(true); + expect(out.endsWith("…")).toBe(true); + }); + + it("never splits a surrogate pair when cutting", () => { + // "🏆 " is 3 units; each 😀 is 2 units, so the cut lands mid-pair. + const out = topicNameFor("😀".repeat(100), "s"); + expect(out.length).toBeLessThanOrEqual(128); + expect(out.endsWith("…")).toBe(true); + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + it("is exported and returns the sanitized name without the emoji", async () => { + const { sanitizeTopicName } = await import("../../../src/domain/hackathon/topic"); + expect(sanitizeTopicName("Meridian Hack\n2026", "meridian")).toBe("Meridian Hack 2026"); + expect(sanitizeTopicName("", "meridian")).toBe("meridian"); + }); +}); + +describe("topicLink", () => { + it("strips the -100 prefix from a supergroup chat id", () => { + expect(topicLink(-1001234567890, 42)).toBe("https://t.me/c/1234567890/42"); + }); + + it("returns null when the chat id is not a -100 supergroup id", () => { + expect(topicLink(-555, 42)).toBeNull(); + expect(topicLink(123, 42)).toBeNull(); + }); +}); From 097db026c3697b0e78e80a2040538a6f794c66e5 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 16:58:51 -0400 Subject: [PATCH 4/6] feat(participation): add participateInHackathon use case, errors and copy --- src/domain/copy.ts | 22 + src/domain/errors.ts | 10 + .../usecases/participate-in-hackathon.ts | 209 +++++++++ .../usecases/participate-in-hackathon.test.ts | 410 ++++++++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 src/domain/usecases/participate-in-hackathon.ts create mode 100644 test/domain/usecases/participate-in-hackathon.test.ts diff --git a/src/domain/copy.ts b/src/domain/copy.ts index c42bed1..d6f96d6 100644 --- a/src/domain/copy.ts +++ b/src/domain/copy.ts @@ -61,6 +61,28 @@ export const analysisCopy = { pinFailed: "No se pudo fijar el mensaje; se publicó sin fijar.", }; +// hackathon-participation replies composed by the domain. A null link (chat +// ids without a t.me/c form) drops the link and its separator. +const withLink = (prefix: string, link: string | null): string => + link === null ? `${prefix}.` : `${prefix}: ${link}`; + +export const participationCopy = { + alreadyHasTopic: (link: string | null): string => + link === null ? "Este hackathon ya tiene tema." : `Este hackathon ya tiene tema: ${link}`, + confirmed: (name: string, link: string | null): string => + link === null ? `✅ Participamos en ${name}` : `✅ Participamos en ${name} → ${link}`, + postFailed: (slug: string, link: string | null): string => + withLink( + `Se creó el tema y se vinculó ${slug}, pero no se pudo publicar el análisis. Ejecuta /hackathon ${slug} dentro del tema`, + link, + ), + linkFailed: (slug: string, link: string | null): string => + withLink( + `Se creó el tema, pero no se pudo vincular ${slug}. Ejecuta /hackathon ${slug} dentro del tema`, + link, + ), +}; + // One header phrase per kind:action pair; the type makes a missing pair a // compile error. The mapper never produces `issues:merged` or // `issues:review_requested`, but the domain type allows them, so they carry diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 9c4b777..0173c56 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -225,3 +225,13 @@ export class ForumTopicCreateError extends DomainError { this.failure = failure; } } + +// hackathon-participation (design.md "Use Case Step Order" step 4): refusals +// raised before any topic exists. The first two are operator-fixable (the +// claim is released, nothing persisted); the last two follow the create +// classification: a rejected creation releases the claim, an uncertain one +// (timeout/5xx, the topic may exist) keeps it until its TTL expires. +export class TopicRightsMissingError extends DomainError {} +export class ChatNotForumError extends DomainError {} +export class TopicCreationFailedError extends DomainError {} +export class TopicCreationUncertainError extends DomainError {} diff --git a/src/domain/usecases/participate-in-hackathon.ts b/src/domain/usecases/participate-in-hackathon.ts new file mode 100644 index 0000000..9a1c387 --- /dev/null +++ b/src/domain/usecases/participate-in-hackathon.ts @@ -0,0 +1,209 @@ +import { participationCopy } from "../copy"; +import { + AnalysisNotFoundError, + ChatNotForumError, + ForumTopicCreateError, + TopicCreationFailedError, + TopicCreationUncertainError, + TopicRightsMissingError, + UnauthorizedError, +} from "../errors"; +import { sanitizeTopicName, topicLink, topicNameFor } from "../hackathon/topic"; +import type { HackathonAnalysis } from "../entities"; +import type { MembershipId, TeamId } from "../ids"; +import type { + ChatPublisher, + Clock, + ForumTopicManager, + HackathonAnalysisRepo, + Logger, + MembershipRepo, +} from "../ports"; +import { postAnalysisAndLinkTopic } from "./link-analysis-to-topic"; + +// design.md decision 3: the creation claim outlives a slow createForumTopic +// but not a stuck one. +const CLAIM_TTL_MS = 60_000; + +export interface ParticipateInHackathonInput { + teamId: TeamId; + actorMembershipId: MembershipId; + chatId: number; + slug: string; + // The message carrying the tapped button (callback trigger), or null for + // `/hackathon join`. + callbackMessageId: number | null; +} + +export interface ParticipateInHackathonDeps { + membershipRepo: MembershipRepo; + hackathonAnalysisRepo: HackathonAnalysisRepo; + chatPublisher: ChatPublisher; + forumTopicManager: ForumTopicManager; + clock: Clock; + logger: Logger; +} + +// `replyText` is what the adapter posts to General; null means a neutral +// no-op (a concurrent tap holds the claim, nothing to say). +export type ParticipateInHackathonResult = + | { kind: "created" | "already" | "postFailed" | "linkFailed"; replyText: string } + | { kind: "busy"; replyText: null }; + +// hackathon-participation design.md "Use Case Step Order". Everything up to +// and including `create` may throw a domain error (nothing exists yet, or +// the claim decides). From `moveTopicLink` on the topic exists, so this +// function NEVER throws again: a redelivery would create a second topic. +export async function participateInHackathon( + input: ParticipateInHackathonInput, + deps: ParticipateInHackathonDeps, +): Promise { + const { teamId, chatId, slug } = input; + + // 1. Admin gate, then the slug lookup. + const actor = await deps.membershipRepo.get(teamId, input.actorMembershipId); + if (!actor || actor.role !== "admin") { + throw new UnauthorizedError("Only a team admin may confirm participation"); + } + const analysis = await deps.hackathonAnalysisRepo.findBySlug(teamId, slug); + if (!analysis) { + 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. + let expected: number | null = null; + if (analysis.threadId !== null) { + const probe = await deps.forumTopicManager.probe(chatId, analysis.threadId); + if (probe !== "deleted") { + await clearButtons(input, analysis, deps); + return { + kind: "already", + replyText: participationCopy.alreadyHasTopic(topicLink(chatId, analysis.threadId)), + }; + } + expected = analysis.threadId; + } + + // 3. CAS claim. A lost claim re-reads: a link that differs from what we + // observed means the winner already finished. + const won = await deps.hackathonAnalysisRepo.claimTopicCreation( + teamId, + analysis.id, + expected, + deps.clock.now(), + CLAIM_TTL_MS, + ); + if (!won) { + const current = await deps.hackathonAnalysisRepo.findBySlug(teamId, slug); + if (current && current.threadId !== null && current.threadId !== expected) { + return { + kind: "already", + replyText: participationCopy.alreadyHasTopic(topicLink(chatId, current.threadId)), + }; + } + return { kind: "busy", replyText: null }; + } + + // 4. Create. Only a known-not-created failure releases the claim. + const name = analysis.fields.name?.value; + let threadId: number; + try { + threadId = await deps.forumTopicManager.create(chatId, topicNameFor(name, analysis.slug)); + } catch (err) { + if (err instanceof ForumTopicCreateError) { + if (err.failure === "unavailable") { + throw new TopicCreationUncertainError("Topic creation outcome is uncertain"); + } + await releaseClaim(analysis, deps); + throw refusalFor(err.failure); + } + // Unknown failure: the topic may exist, so the claim stays until its TTL. + throw err; + } + + // 5. No-throw zone. Link immediately, before anything else can fail. + const link = topicLink(chatId, threadId); + try { + await deps.hackathonAnalysisRepo.moveTopicLink(teamId, analysis.id, threadId, null); + } catch (err) { + logFailure(deps, teamId, "link-failed", err); + return { kind: "linkFailed", replyText: participationCopy.linkFailed(analysis.slug, link) }; + } + + // 6. Post and pin in the new topic; the threadId override avoids a spurious + // unpin and "moved" note for a recreated topic. + let notes: string[]; + try { + const posted = await postAnalysisAndLinkTopic( + { teamId, chatId, threadId, analysis: { ...analysis, threadId } }, + deps, + ); + notes = posted.notes; + } catch (err) { + logFailure(deps, teamId, "post-failed", err); + return { kind: "postFailed", replyText: participationCopy.postFailed(analysis.slug, link) }; + } + + // 7. Remove the button(s), best-effort, and confirm. + await clearButtons(input, analysis, deps); + const confirmed = participationCopy.confirmed(sanitizeTopicName(name, analysis.slug), link); + return { kind: "created", replyText: [confirmed, ...notes].join("\n") }; +} + +function refusalFor(failure: ForumTopicCreateError["failure"]): Error { + switch (failure) { + case "no-rights": + return new TopicRightsMissingError("Bot cannot manage topics"); + case "not-forum": + return new ChatNotForumError("Chat is not a forum"); + default: + return new TopicCreationFailedError("Telegram refused the topic creation"); + } +} + +async function releaseClaim( + analysis: HackathonAnalysis, + deps: ParticipateInHackathonDeps, +): Promise { + try { + await deps.hackathonAnalysisRepo.releaseTopicClaim(analysis.teamId, analysis.id); + } catch (err) { + // The claim just expires on its TTL; the caller still gets its refusal. + logFailure(deps, analysis.teamId, "claim-release-failed", err); + } +} + +// The deduped set {callback message, stored General message}; every removal +// is best-effort (a message may be gone or unknown). +async function clearButtons( + input: ParticipateInHackathonInput, + analysis: HackathonAnalysis, + deps: ParticipateInHackathonDeps, +): Promise { + const ids = new Set(); + if (input.callbackMessageId !== null) ids.add(input.callbackMessageId); + if (analysis.generalMessageId !== null) ids.add(analysis.generalMessageId); + for (const messageId of ids) { + try { + await deps.chatPublisher.clearButtons(input.chatId, messageId); + } catch (err) { + logFailure(deps, input.teamId, "clear-buttons-failed", err); + } + } +} + +function logFailure( + deps: Pick, + teamId: TeamId, + reason: string, + err: unknown, +): void { + deps.logger.log({ + event: "hackathon-participate", + teamId, + outcome: "error", + errorCode: err instanceof Error ? err.name : "UnknownError", + reason, + }); +} diff --git a/test/domain/usecases/participate-in-hackathon.test.ts b/test/domain/usecases/participate-in-hackathon.test.ts new file mode 100644 index 0000000..7ec6c01 --- /dev/null +++ b/test/domain/usecases/participate-in-hackathon.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it } from "vitest"; +import { + AnalysisNotFoundError, + ChatNotForumError, + PublishFailedError, + TopicCreationFailedError, + TopicCreationUncertainError, + TopicRightsMissingError, + UnauthorizedError, +} 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, + fakeClock, + fakeForumTopicManager, + fakeHackathonAnalysisRepo, + fakeLogger, + fakeMemberRepo, + fakeMembershipRepo, +} from "../../fakes"; +import type { TopicCreateStep } from "../../fakes"; + +const teamId = asTeamId("team-1"); +const CHAT_ID = -1001234567890; +const LINK = (threadId: number) => `https://t.me/c/1234567890/${threadId}`; +const ADMIN = asMembershipId("m-admin"); + +function analysis(overrides: Partial = {}): HackathonAnalysis { + return { + id: "a-1", + teamId, + slug: "meridian", + sourceUrl: "https://example.com/meridian", + normalizedUrl: "https://example.com/meridian", + fields: { + name: { value: "Meridian Hack\n2026", 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, + ...overrides, + }; +} + +function setup( + opts: { + row?: Partial; + probe?: TopicProbe[]; + create?: TopicCreateStep[]; + role?: "admin" | "member"; + } = {}, +) { + const memberRepo = fakeMemberRepo(); + const membershipRepo = fakeMembershipRepo(memberRepo); + membershipRepo.rows.push({ + id: ADMIN, + teamId, + memberId: asMemberId("u-admin"), + role: opts.role ?? "admin", + joinedAt: 0, + }); + const hackathonAnalysisRepo = fakeHackathonAnalysisRepo(); + hackathonAnalysisRepo.rows.push(analysis(opts.row ?? {})); + return { + membershipRepo, + hackathonAnalysisRepo, + chatPublisher: fakeChatPublisher(), + forumTopicManager: fakeForumTopicManager({ + ...(opts.probe ? { probe: opts.probe } : {}), + ...(opts.create ? { create: opts.create } : {}), + }), + clock: fakeClock(), + logger: fakeLogger(), + }; +} + +const input = (over: Partial<{ callbackMessageId: number | null; slug: string; chatId: number }> = {}) => ({ + teamId, + actorMembershipId: ADMIN, + chatId: CHAT_ID, + slug: "meridian", + callbackMessageId: null, + ...over, +}); + +const row = (deps: ReturnType) => deps.hackathonAnalysisRepo.rows[0]!; + +describe("participateInHackathon: gate and lookup", () => { + it("refuses a non-admin with UnauthorizedError and changes nothing", async () => { + const deps = setup({ role: "member" }); + await expect(participateInHackathon(input(), deps)).rejects.toBeInstanceOf(UnauthorizedError); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(row(deps).threadId).toBeNull(); + expect(deps.hackathonAnalysisRepo.claims.size).toBe(0); + }); + + it("refuses a caller with no membership with UnauthorizedError", async () => { + const deps = setup(); + await expect( + participateInHackathon(input(), { + ...deps, + membershipRepo: fakeMembershipRepo(fakeMemberRepo()), + }), + ).rejects.toBeInstanceOf(UnauthorizedError); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("throws AnalysisNotFoundError for an unknown slug and creates nothing", async () => { + const deps = setup(); + await expect(participateInHackathon(input({ slug: "nope" }), deps)).rejects.toBeInstanceOf( + AnalysisNotFoundError, + ); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.chatPublisher.posted).toHaveLength(0); + }); +}); + +describe("participateInHackathon: happy path", () => { + it("creates, links, pins in the topic and returns the General confirmation", async () => { + const deps = setup({ create: [{ threadId: 77 }] }); + + const result = await participateInHackathon(input(), deps); + + expect(deps.forumTopicManager.created).toEqual([ + { chatId: CHAT_ID, name: "🏆 Meridian Hack 2026" }, + ]); + expect(row(deps).threadId).toBe(77); + expect(deps.chatPublisher.posted).toHaveLength(1); + expect(deps.chatPublisher.posted[0]?.threadId).toBe(77); + expect(deps.chatPublisher.pinned).toHaveLength(1); + expect(row(deps).pinnedMessageId).toBe(1); + expect(result.kind).toBe("created"); + expect(result.replyText).toBe(`✅ Participamos en Meridian Hack 2026 → ${LINK(77)}`); + }); + + it("names the topic after the slug when the analysis has no name", async () => { + const deps = setup({ + row: { fields: { ...analysis().fields, name: null } }, + create: [{ threadId: 5 }], + }); + const result = await participateInHackathon(input(), deps); + expect(deps.forumTopicManager.created[0]?.name).toBe("🏆 meridian"); + expect(result.replyText).toBe(`✅ Participamos en meridian → ${LINK(5)}`); + }); + + it("omits the link when the chat id has no t.me/c form", async () => { + const deps = setup({ create: [{ threadId: 5 }] }); + const result = await participateInHackathon(input({ chatId: -555 }), deps); + expect(result.replyText).toBe("✅ Participamos en Meridian Hack 2026"); + }); +}); + +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 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(77)}`, + }); + 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); + 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.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 }, + ]); + }); +}); + +describe("participateInHackathon: deleted topic", () => { + it("detects the deleted topic, recreates it, replaces the stale id, no moved note", async () => { + const deps = setup({ + row: { threadId: 40, pinnedMessageId: 8 }, + probe: ["deleted"], + create: [{ threadId: 41 }], + }); + + const result = await participateInHackathon(input(), deps); + + expect(deps.forumTopicManager.created).toHaveLength(1); + expect(row(deps).threadId).toBe(41); + expect(result.kind).toBe("created"); + expect(result.replyText).toBe(`✅ Participamos en Meridian Hack 2026 → ${LINK(41)}`); + expect(deps.chatPublisher.unpinned).toEqual([]); + }); + + 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 realFind = deps.hackathonAnalysisRepo.findBySlug; + let reads = 0; + // First read still shows the stale id; then another tap relinks it to 50. + deps.hackathonAnalysisRepo.findBySlug = async (t, s) => { + const found = await realFind(t, s); + reads += 1; + if (reads === 1 && found) { + const snapshot = { ...found }; + found.threadId = 50; + return snapshot; + } + return found; + }; + + const result = await participateInHackathon(input(), deps); + + expect(result).toEqual({ kind: "already", replyText: `Este hackathon ya tiene tema: ${LINK(50)}` }); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); +}); + +describe("participateInHackathon: concurrent claim", () => { + it("returns busy (neutral no-op) when another live claim holds the row", async () => { + const deps = setup(); + deps.hackathonAnalysisRepo.claims.set("a-1", deps.clock.now() + 60_000); + + const result = await participateInHackathon(input(), deps); + + expect(result).toEqual({ kind: "busy", replyText: null }); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.chatPublisher.posted).toHaveLength(0); + }); + + it("returns already(link) when the claim is lost and the re-read shows a link", async () => { + const deps = setup(); + const realFind = deps.hackathonAnalysisRepo.findBySlug; + let reads = 0; + // The winner links between our first read and our re-read. + deps.hackathonAnalysisRepo.findBySlug = async (t, s) => { + const found = await realFind(t, s); + reads += 1; + if (reads === 1 && found) { + const snapshot = { ...found }; + found.threadId = 55; + return snapshot; + } + return found; + }; + deps.hackathonAnalysisRepo.claims.set("a-1", deps.clock.now() + 60_000); + + const result = await participateInHackathon(input(), deps); + + expect(result).toEqual({ kind: "already", replyText: `Este hackathon ya tiene tema: ${LINK(55)}` }); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("creates exactly one topic when the second call arrives while the first claim is live", async () => { + const deps = setup({ create: [{ threadId: 60 }, { threadId: 61 }] }); + // Two callers race: both read the unlinked row, only one wins the claim. + const [a, b] = await Promise.all([ + participateInHackathon(input(), deps), + participateInHackathon(input(), deps), + ]); + expect(deps.forumTopicManager.created).toHaveLength(1); + // Depending on interleaving the loser sees the link already stored + // ("already") or not yet ("busy"); either way it must not create. + const kinds = [a.kind, b.kind]; + expect(kinds.filter((k) => k === "created")).toHaveLength(1); + expect(kinds.filter((k) => k === "busy" || k === "already")).toHaveLength(1); + }); +}); + +describe("participateInHackathon: creation failures", () => { + it.each([ + ["no-rights", TopicRightsMissingError], + ["not-forum", ChatNotForumError], + ["rate-limited", TopicCreationFailedError], + ["rejected", TopicCreationFailedError], + ] as const)("%s persists nothing and releases the claim", async (failure, ErrorClass) => { + const deps = setup({ create: [{ fails: failure }] }); + + await expect(participateInHackathon(input(), deps)).rejects.toBeInstanceOf(ErrorClass); + + expect(row(deps).threadId).toBeNull(); + expect(deps.chatPublisher.posted).toHaveLength(0); + expect(deps.hackathonAnalysisRepo.claims.get("a-1")).toBe(0); + }); + + it("keeps the claim on an uncertain outcome so a retry cannot duplicate", async () => { + const deps = setup({ create: [{ fails: "unavailable" }, { threadId: 9 }] }); + + await expect(participateInHackathon(input(), deps)).rejects.toBeInstanceOf( + TopicCreationUncertainError, + ); + expect(deps.hackathonAnalysisRepo.claims.get("a-1")).toBeGreaterThan(deps.clock.now()); + expect(row(deps).threadId).toBeNull(); + + const retry = await participateInHackathon(input(), deps); + expect(retry.kind).toBe("busy"); + expect(deps.forumTopicManager.created).toHaveLength(1); + }); +}); + +describe("participateInHackathon: after the topic exists (never rethrows)", () => { + it("reports a pin failure as a note; posted unpinned and the link persists", async () => { + const deps = setup({ create: [{ threadId: 77 }] }); + deps.chatPublisher.pin = async () => { + throw new PublishFailedError("pin", "rejected"); + }; + + const result = await participateInHackathon(input(), deps); + + expect(result.kind).toBe("created"); + expect(result.replyText).toBe( + `✅ Participamos en Meridian Hack 2026 → ${LINK(77)}\nNo se pudo fijar el mensaje; se publicó sin fijar.`, + ); + expect(row(deps).threadId).toBe(77); + expect(row(deps).pinnedMessageId).toBeNull(); + expect(deps.chatPublisher.posted).toHaveLength(1); + }); + + it("returns postFailed with the link when posting the analysis fails", async () => { + const deps = setup({ create: [{ threadId: 77 }] }); + deps.chatPublisher.post = async () => { + throw new PublishFailedError("post", "telegram-unavailable"); + }; + + const result = await participateInHackathon(input(), deps); + + expect(result).toEqual({ + kind: "postFailed", + replyText: `Se creó el tema y se vinculó meridian, pero no se pudo publicar el análisis. Ejecuta /hackathon meridian dentro del tema: ${LINK(77)}`, + }); + expect(row(deps).threadId).toBe(77); + }); + + it("returns linkFailed with the link when storing the link fails", async () => { + const deps = setup({ create: [{ threadId: 77 }] }); + deps.hackathonAnalysisRepo.moveTopicLink = async () => { + throw new Error("D1 unavailable"); + }; + + const result = await participateInHackathon(input(), deps); + + expect(result).toEqual({ + kind: "linkFailed", + replyText: `Se creó el tema, pero no se pudo vincular meridian. Ejecuta /hackathon meridian dentro del tema: ${LINK(77)}`, + }); + expect(deps.chatPublisher.posted).toHaveLength(0); + expect(deps.forumTopicManager.created).toHaveLength(1); + }); + + it("ignores a clearButtons failure and still confirms", async () => { + const deps = setup({ row: { generalMessageId: 900 }, create: [{ threadId: 77 }] }); + deps.chatPublisher.clearButtons = async () => { + throw new PublishFailedError("clear", "rejected"); + }; + + const result = await participateInHackathon(input({ callbackMessageId: 901 }), deps); + + expect(result.kind).toBe("created"); + expect(row(deps).threadId).toBe(77); + }); + + it("clears the deduped set of callback and stored General message ids", async () => { + const same = setup({ row: { generalMessageId: 900 }, create: [{ threadId: 77 }] }); + await participateInHackathon(input({ callbackMessageId: 900 }), same); + expect(same.chatPublisher.cleared).toEqual([{ chatId: CHAT_ID, messageId: 900 }]); + + const other = setup({ row: { generalMessageId: 900 }, create: [{ threadId: 77 }] }); + await participateInHackathon(input({ callbackMessageId: 901 }), other); + expect(other.chatPublisher.cleared).toEqual([ + { chatId: CHAT_ID, messageId: 901 }, + { chatId: CHAT_ID, messageId: 900 }, + ]); + }); + + it("works on an old analysis with a null generalMessageId and clears nothing for it", async () => { + const deps = setup({ row: { generalMessageId: null }, create: [{ threadId: 77 }] }); + + const result = await participateInHackathon(input(), deps); + + expect(result.kind).toBe("created"); + expect(deps.chatPublisher.cleared).toEqual([]); + }); +}); From ed3169419f7a5e4a54752289951ddac9bd270064 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:02:05 -0400 Subject: [PATCH 5/6] feat(telegram): add /hackathon join and runParticipation with safe General post --- src/adapters/telegram/copy.ts | 14 ++ src/adapters/telegram/hackathon-commands.ts | 47 ++++- src/adapters/telegram/participation.ts | 80 ++++++++ src/composition.ts | 2 + test/adapters/telegram/commands.test.ts | 132 +++++++++++++ test/adapters/telegram/participation.test.ts | 188 +++++++++++++++++++ test/copy/catalog-language.test.ts | 30 +++ test/http/hackathon-command-e2e.test.ts | 119 ++++++++++++ 8 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 src/adapters/telegram/participation.ts create mode 100644 test/adapters/telegram/participation.test.ts diff --git a/src/adapters/telegram/copy.ts b/src/adapters/telegram/copy.ts index b02bf9a..0730ba1 100644 --- a/src/adapters/telegram/copy.ts +++ b/src/adapters/telegram/copy.ts @@ -113,3 +113,17 @@ export const pickerCopy = { notMember: "No eres miembro de ese equipo.", selected: "Equipo seleccionado. Vuelve a ejecutar tu comando para continuar.", }; + +// hackathon-participation adapter replies (design.md "Copy Table"). +export const participateCopy = { + adminOnly: "Solo un administrador del equipo puede confirmar la participación.", + joinUsage: "Uso: /hackathon join ", + noAnalysis: (slug: string) => `No se encontró ningún análisis con el slug ${slug}.`, + noRights: + "No puedo crear temas: concede al bot el permiso «Administrar temas» y vuelve a intentarlo.", + notForum: + "Este grupo no tiene los temas activados. Actívalos en la configuración del grupo y vuelve a intentarlo.", + createFailed: "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.", + createUncertain: + "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo.", +}; diff --git a/src/adapters/telegram/hackathon-commands.ts b/src/adapters/telegram/hackathon-commands.ts index 231aa14..e0c9cef 100644 --- a/src/adapters/telegram/hackathon-commands.ts +++ b/src/adapters/telegram/hackathon-commands.ts @@ -1,6 +1,6 @@ import type { Bot, Context } from "grammy"; import { NotFoundError, UnsafeUrlError } from "../../domain/errors"; -import { classifyHackathonArgument } from "../../domain/hackathon/argument"; +import { classifyHackathonArgument, parseJoinArgument } from "../../domain/hackathon/argument"; import { assertSafeUrl } from "../../domain/hackathon/url"; import type { AnalysisJobQueue, @@ -8,6 +8,7 @@ import type { AnalysisQuota, ChatPublisher, Clock, + ForumTopicManager, HackathonAnalysisRepo, IdGen, Logger, @@ -20,11 +21,12 @@ import { listAnalyses } from "../../domain/usecases/list-analyses"; import { requestHackathonAnalysis } from "../../domain/usecases/request-hackathon-analysis"; import { showAnalysis } from "../../domain/usecases/show-analysis"; import { showTopicAnalysis } from "../../domain/usecases/show-topic-analysis"; -import { commonCopy, hackathonCopy } from "./copy"; +import { commonCopy, hackathonCopy, participateCopy } from "./copy"; 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 { isPrivateChat } from "./team-picker"; // `/hackathon` and `/hackathons` (design.md "Data Flow", "Error Taxonomy" — @@ -42,6 +44,7 @@ export interface HackathonCommandDeps { analysisJobQueue: AnalysisJobQueue; analysisJobRepo: AnalysisJobRepo; chatPublisher: ChatPublisher; + forumTopicManager: ForumTopicManager; clock: Clock; idGen: IdGen; logger: Logger; @@ -110,6 +113,17 @@ export function registerHackathonCommands(bot: Bot, deps: HackathonCommandDeps): await showLinkedAnalysis(ctx, loc, deps); return; } + // `join` is checked before the whitespace rule (design.md decision 6). + const join = parseJoinArgument(argument); + if (join?.kind === "join-usage") { + deps.logger.log({ event: "hackathon-join", outcome: "refused", errorCode: "BadArgument" }); + await ctx.reply(participateCopy.joinUsage); + return; + } + if (join) { + await joinHackathon(ctx, loc, join.slug, deps); + return; + } if (/\s/.test(argument)) { deps.logger.log({ event: "hackathon", outcome: "refused", errorCode: "BadArgument" }); await ctx.reply(hackathonCopy.usage); @@ -253,3 +267,32 @@ async function requestFresh( }, ); } + +// `/hackathon join `: the same behavior as the participation button, +// from General or any topic. The team comes from the chat and the role from +// the caller; a non-member gets the same refusal as a non-admin. +async function joinHackathon( + ctx: Context, + loc: CallerLocation, + slug: string, + deps: HackathonCommandDeps, +): Promise { + const resolved = await resolveGroupMembership(deps, loc.chatId, loc.userId); + if (!resolved) { + deps.logger.log({ event: "hackathon-join", outcome: "refused", errorCode: "NotFoundError" }); + await ctx.reply(participateCopy.adminOnly); + return; + } + await runParticipation( + { + event: "hackathon-join", + teamId: resolved.team.id, + membershipId: resolved.membership.id, + chatId: loc.chatId, + slug, + callbackMessageId: null, + reply: (text) => ctx.reply(text), + }, + deps, + ); +} diff --git a/src/adapters/telegram/participation.ts b/src/adapters/telegram/participation.ts new file mode 100644 index 0000000..b956a1c --- /dev/null +++ b/src/adapters/telegram/participation.ts @@ -0,0 +1,80 @@ +import type { TeamId, MembershipId } from "../../domain/ids"; +import { participateInHackathon } from "../../domain/usecases/participate-in-hackathon"; +import type { ParticipateInHackathonDeps } from "../../domain/usecases/participate-in-hackathon"; +import { participateCopy } from "./copy"; + +export interface ParticipationParams { + event: string; + teamId: TeamId; + membershipId: MembershipId; + chatId: number; + slug: string; + // The message that carried the tapped button, or null for the command. + callbackMessageId: number | null; + // Where a refusal goes (the command's reply, or a callback alert). + reply: (text: string) => Promise; +} + +// Recognized refusals only (same convention as command-outcome.ts: keyed by +// the error's name; anything else is logged and rethrown to the single +// top-level boundary). A non-member is told the same as a non-admin. +function refusalReplies(slug: string): Record { + return { + UnauthorizedError: participateCopy.adminOnly, + NotFoundError: participateCopy.adminOnly, + AnalysisNotFoundError: participateCopy.noAnalysis(slug), + TopicRightsMissingError: participateCopy.noRights, + ChatNotForumError: participateCopy.notForum, + TopicCreationFailedError: participateCopy.createFailed, + TopicCreationUncertainError: participateCopy.createUncertain, + }; +} + +// design.md decision 8: a pre-creation refusal is a reply; everything the use +// case returns is delivered to General through a safe post (catch and log, +// never rethrow) so a failed reply cannot turn into a 500 and a redelivery. +export async function runParticipation( + params: ParticipationParams, + deps: ParticipateInHackathonDeps, +): Promise { + const { event, teamId } = params; + let replyText: string | null; + try { + replyText = ( + await participateInHackathon( + { + teamId, + actorMembershipId: params.membershipId, + chatId: params.chatId, + slug: params.slug, + callbackMessageId: params.callbackMessageId, + }, + deps, + ) + ).replyText; + } catch (err) { + const errorCode = err instanceof Error ? err.name : "UnknownError"; + const refusal = refusalReplies(params.slug)[errorCode]; + if (refusal === undefined) { + deps.logger.log({ event, teamId, outcome: "error", errorCode }); + throw err; + } + deps.logger.log({ event, teamId, outcome: "refused", errorCode }); + await params.reply(refusal); + return; + } + + deps.logger.log({ event, teamId, outcome: "ok" }); + if (replyText === null) return; + try { + await deps.chatPublisher.post(params.chatId, null, replyText); + } catch (err) { + deps.logger.log({ + event, + teamId, + outcome: "error", + errorCode: err instanceof Error ? err.name : "UnknownError", + reason: "general-post-failed", + }); + } +} diff --git a/src/composition.ts b/src/composition.ts index feaeed6..32eb081 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -20,6 +20,7 @@ import { createSafeLogger } from "./adapters/log/safe-logger"; import { createTelegramAlertSender } from "./adapters/telegram/alert-sender"; import { createBot } from "./adapters/telegram/bot"; import { createTelegramChatPublisher } from "./adapters/telegram/chat-publisher"; +import { createTelegramForumTopicManager } from "./adapters/telegram/forum-topic-manager"; import { createChatAdminChecker } from "./adapters/telegram/chat-admin-checker"; import { registerCommands } from "./adapters/telegram/commands"; import { ConfigError } from "./config-error"; @@ -115,6 +116,7 @@ export function buildBot(env: Env) { analysisJobRepo: createD1AnalysisJobRepo(env.DB, clock), analysisJobQueue, chatPublisher: createTelegramChatPublisher(bot.api), + forumTopicManager: createTelegramForumTopicManager(bot.api), clock, idGen, logger, diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index e48106f..6061c6f 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -17,6 +17,7 @@ import { fakeHackathonAnalysisRepo, fakeIdGen, fakeMemberRepo, + fakeForumTopicManager, fakeMembershipRepo, fakeProfileRepo, fakeRepoTopicLinkRepo, @@ -91,6 +92,7 @@ function makeBot( const analysisJobQueue = fakeAnalysisJobQueue({ throws: hackathon.queueThrows ?? false }); const analysisJobRepo = fakeAnalysisJobRepo(); const chatPublisher = fakeChatPublisher(); + const forumTopicManager = fakeForumTopicManager(); const deps = { teamRepo, memberRepo, @@ -105,6 +107,7 @@ function makeBot( analysisJobQueue, analysisJobRepo, chatPublisher, + forumTopicManager, clock: fakeClock(), idGen: fakeIdGen(), logger: createSafeLogger(), @@ -1433,3 +1436,132 @@ describe("registerCommands — remaining Spanish replies", () => { expect(buttons.map((b) => b.text)).toEqual(deps.teamRepo.rows.map((t) => `Equipo ${t.id}`)); }); }); + +// hackathon-participation (PR1b) — /hackathon join . +describe("registerCommands — /hackathon join (hackathon-participation spec: Two Triggers)", () => { + it("an admin in General creates the topic, posts and pins the analysis, and confirms in General", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join meridian" })); + + expect(deps.forumTopicManager.created).toEqual([{ chatId: 10, name: "🏆 Hack meridian" }]); + const threadId = deps.hackathonAnalysisRepo.rows[0]?.threadId; + expect(threadId).toBe(1000); + expect(deps.chatPublisher.posted).toMatchObject([ + { chatId: 10, threadId: 1000 }, + { chatId: 10, threadId: null, text: "✅ Participamos en Hack meridian" }, + ]); + expect(deps.chatPublisher.pinned).toHaveLength(1); + // No fetch, LLM or cap involved. + expect(deps.analysisQuota.reserved).toHaveLength(0); + expect(deps.analysisJobQueue.sent).toHaveLength(0); + }); + + it("works from inside a topic too", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { threadId: 5, args: "join meridian" })); + + expect(deps.forumTopicManager.created).toHaveLength(1); + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBe(1000); + }); + + it("a bare join replies with the join usage line and does nothing", async () => { + const { bot, replies, deps } = await hackathonTeam(); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join" })); + + expect(replies.at(-1)?.text).toBe("Uso: /hackathon join "); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.analysisJobQueue.sent).toHaveLength(0); + }); + + it("join with a non-slug target replies with the join usage line", async () => { + const { bot, replies } = await hackathonTeam(); + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join Not_Slug" })); + expect(replies.at(-1)?.text).toBe("Uso: /hackathon join "); + }); + + it("a non-admin gets the admin-only reply and nothing changes", async () => { + const { bot, replies, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 3, { args: "join meridian" })); + + expect(replies.at(-1)?.text).toBe( + "Solo un administrador del equipo puede confirmar la participación.", + ); + expect(deps.forumTopicManager.created).toHaveLength(0); + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBeNull(); + }); + + it("a stranger with no membership gets the same admin-only reply", async () => { + const { bot, replies, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 999, { args: "join meridian" })); + + expect(replies.at(-1)?.text).toBe( + "Solo un administrador del equipo puede confirmar la participación.", + ); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("an unknown slug replies with the slug-specific not-found text", async () => { + const { bot, replies, deps } = await hackathonTeam(); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join nope" })); + + expect(replies.at(-1)?.text).toBe("No se encontró ningún análisis con el slug nope."); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("an old analysis (null generalMessageId) joins fine and removes no button", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian", { generalMessageId: null })); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join meridian" })); + + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBe(1000); + expect(deps.chatPublisher.cleared).toEqual([]); + }); + + it("clears the stored General message's button when it is known", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian", { generalMessageId: 321 })); + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join meridian" })); + + expect(deps.chatPublisher.cleared).toEqual([{ chatId: 10, messageId: 321 }]); + }); + + it("replies with the linkFailed text when storing the link fails, without failing the update", async () => { + const { bot, deps, teamId } = await hackathonTeam(); + deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); + deps.hackathonAnalysisRepo.moveTopicLink = async () => { + throw new Error("D1 unavailable"); + }; + + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join meridian" })); + + expect(deps.chatPublisher.posted.at(-1)).toMatchObject({ + chatId: 10, + threadId: null, + text: "Se creó el tema, pero no se pudo vincular meridian. Ejecuta /hackathon meridian dentro del tema.", + }); + }); + + it("replies in a private chat with the group-only line", async () => { + const { bot, replies } = await hackathonTeam(); + await bot.handleUpdate(commandUpdate("hackathon", 20, 1, { chatType: "private", args: "join meridian" })); + expect(replies.at(-1)?.text).toBe("Ejecuta este comando dentro del chat grupal de tu equipo."); + }); + + it("leaves the existing whitespace rule intact for other multi-token arguments", async () => { + const { bot, replies } = await hackathonTeam(); + await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "not join" })); + expect(replies.at(-1)?.text).toBe("Uso: /hackathon "); + }); +}); diff --git a/test/adapters/telegram/participation.test.ts b/test/adapters/telegram/participation.test.ts new file mode 100644 index 0000000..6926d0e --- /dev/null +++ b/test/adapters/telegram/participation.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { participateCopy } from "../../../src/adapters/telegram/copy"; +import { runParticipation } from "../../../src/adapters/telegram/participation"; +import { PublishFailedError } from "../../../src/domain/errors"; +import type { HackathonAnalysis } from "../../../src/domain/entities"; +import { asMemberId, asMembershipId, asTeamId } from "../../../src/domain/ids"; +import { + fakeChatPublisher, + fakeClock, + fakeForumTopicManager, + fakeHackathonAnalysisRepo, + fakeLogger, + fakeMemberRepo, + fakeMembershipRepo, +} from "../../fakes"; +import type { TopicCreateStep } from "../../fakes"; + +const teamId = asTeamId("team-1"); +const CHAT_ID = -1001234567890; +const ADMIN = asMembershipId("m-admin"); + +function analysis(): HackathonAnalysis { + return { + id: "a-1", + teamId, + slug: "meridian", + sourceUrl: "https://example.com/meridian", + normalizedUrl: "https://example.com/meridian", + 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, + }; +} + +function setup(opts: { role?: "admin" | "member"; create?: TopicCreateStep[]; seed?: boolean } = {}) { + const memberRepo = fakeMemberRepo(); + const membershipRepo = fakeMembershipRepo(memberRepo); + membershipRepo.rows.push({ + id: ADMIN, + teamId, + memberId: asMemberId("u-admin"), + role: opts.role ?? "admin", + joinedAt: 0, + }); + const hackathonAnalysisRepo = fakeHackathonAnalysisRepo(); + if (opts.seed !== false) hackathonAnalysisRepo.rows.push(analysis()); + const deps = { + membershipRepo, + hackathonAnalysisRepo, + chatPublisher: fakeChatPublisher(), + forumTopicManager: fakeForumTopicManager(opts.create ? { create: opts.create } : {}), + clock: fakeClock(), + logger: fakeLogger(), + }; + const replies: string[] = []; + const params = (slug = "meridian") => ({ + event: "hackathon-join", + teamId, + membershipId: ADMIN, + chatId: CHAT_ID, + slug, + callbackMessageId: null, + reply: async (text: string) => { + replies.push(text); + }, + }); + return { deps, replies, params }; +} + +describe("runParticipation: domain errors map to their Spanish reply", () => { + it("non-admin: adminOnly, nothing created", async () => { + const { deps, replies, params } = setup({ role: "member" }); + await runParticipation(params(), deps); + expect(replies).toEqual(["Solo un administrador del equipo puede confirmar la participación."]); + expect(deps.forumTopicManager.created).toHaveLength(0); + }); + + it("unknown slug: noAnalysis(slug)", async () => { + const { deps, replies, params } = setup(); + await runParticipation(params("nope"), deps); + expect(replies).toEqual(["No se encontró ningún análisis con el slug nope."]); + }); + + it.each([ + ["no-rights", "No puedo crear temas: concede al bot el permiso «Administrar temas» y vuelve a intentarlo."], + [ + "not-forum", + "Este grupo no tiene los temas activados. Actívalos en la configuración del grupo y vuelve a intentarlo.", + ], + ["rate-limited", "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto."], + ["rejected", "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto."], + [ + "unavailable", + "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo.", + ], + ] as const)("create failure %s replies with the design copy", async (failure, text) => { + const { deps, replies, params } = setup({ create: [{ fails: failure }] }); + await runParticipation(params(), deps); + expect(replies).toEqual([text]); + expect(deps.chatPublisher.posted).toHaveLength(0); + }); + + it("logs a refusal with the error name and no reply to General", async () => { + const { deps, params } = setup({ role: "member" }); + await runParticipation(params(), deps); + expect(deps.logger.entries).toContainEqual({ + event: "hackathon-join", + teamId, + outcome: "refused", + errorCode: "UnauthorizedError", + }); + expect(deps.chatPublisher.posted).toHaveLength(0); + }); + + it("rethrows an unrecognized error after logging it", async () => { + const { deps, params } = setup(); + deps.hackathonAnalysisRepo.findBySlug = async () => { + throw new Error("D1 unavailable"); + }; + await expect(runParticipation(params(), deps)).rejects.toThrow("D1 unavailable"); + expect(deps.logger.entries.at(-1)).toMatchObject({ outcome: "error", errorCode: "Error" }); + }); +}); + +describe("runParticipation: replies go to General through a safe post", () => { + it("posts the confirmation to General (null thread)", async () => { + const { deps, replies, params } = setup({ create: [{ threadId: 77 }] }); + await runParticipation(params(), deps); + const general = deps.chatPublisher.posted.find((p) => p.threadId === null); + expect(general).toEqual({ + chatId: CHAT_ID, + threadId: null, + text: "✅ Participamos en Meridian → https://t.me/c/1234567890/77", + }); + expect(replies).toEqual([]); + }); + + it("a failing General post is caught and logged, never rethrown", async () => { + const { deps, params } = setup({ create: [{ threadId: 77 }] }); + const realPost = deps.chatPublisher.post; + deps.chatPublisher.post = async (chatId, threadId, text, options) => { + if (threadId === null) throw new PublishFailedError("general", "telegram-unavailable"); + return realPost(chatId, threadId, text, options); + }; + + await expect(runParticipation(params(), deps)).resolves.toBeUndefined(); + + expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBe(77); + expect(deps.logger.entries).toContainEqual({ + event: "hackathon-join", + teamId, + outcome: "error", + errorCode: "PublishFailedError", + reason: "general-post-failed", + }); + }); + + it("posts nothing for a neutral busy no-op", async () => { + const { deps, replies, params } = setup(); + deps.hackathonAnalysisRepo.claims.set("a-1", deps.clock.now() + 60_000); + await runParticipation(params(), deps); + expect(deps.chatPublisher.posted).toHaveLength(0); + expect(replies).toEqual([]); + }); +}); + +describe("participateCopy", () => { + it("exposes the design strings", () => { + expect(participateCopy.joinUsage).toBe("Uso: /hackathon join "); + expect(participateCopy.noAnalysis("x")).toBe("No se encontró ningún análisis con el slug x."); + }); +}); diff --git a/test/copy/catalog-language.test.ts b/test/copy/catalog-language.test.ts index 2f64547..1b58470 100644 --- a/test/copy/catalog-language.test.ts +++ b/test/copy/catalog-language.test.ts @@ -6,12 +6,15 @@ import { GITHUB_ALERT_HEADERS, githubCopy, moreItems, + participationCopy, } from "../../src/domain/copy"; import { commonCopy, dataChannelCopy, hackathonCopy, joinCopy, + participateButton, + participateCopy, pickerCopy, profileCopy, repoCopy, @@ -70,6 +73,9 @@ const CATALOGS: Array<[string, Catalog]> = [ ["ROLE_LABELS", ROLE_LABELS as unknown as Catalog], ["GITHUB_ALERT_HEADERS", GITHUB_ALERT_HEADERS as unknown as Catalog], ["githubCopy", githubCopy as unknown as Catalog], + ["participationCopy", participationCopy as unknown as Catalog], + ["participateCopy", participateCopy as unknown as Catalog], + ["participateButton", { participateButton } as unknown as Catalog], ]; describe("copy catalogs are Spanish", () => { @@ -154,3 +160,27 @@ describe("copy catalogs are Spanish", () => { expect(githubCopy).toEqual({ reviewerLabel: "Revisor:", byLabel: "Por:" }); }); }); + +describe("participation copy uses the neutral tú form", () => { + const VOSEO_OR_USTED = /\b(vos|usted|ustedes|tenés|podés|intentá|revisá|concedé|activá)\b/i; + + it("every participation string is free of voseo and usted", () => { + const all = [ + ...strings(participationCopy as unknown as Catalog, "participationCopy"), + ...strings(participateCopy as unknown as Catalog, "participateCopy"), + ]; + expect(all.length).toBeGreaterThan(10); + for (const [path, text] of all) { + expect(VOSEO_OR_USTED.test(text), `${path}: ${text}`).toBe(false); + } + }); + + it("the design strings render verbatim", () => { + expect(participateButton).toBe("✅ Participamos"); + expect(participationCopy.confirmed("N", "L")).toBe("✅ Participamos en N → L"); + expect(participationCopy.alreadyHasTopic("L")).toBe("Este hackathon ya tiene tema: L"); + expect(participateCopy.createFailed).toBe( + "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.", + ); + }); +}); diff --git a/test/http/hackathon-command-e2e.test.ts b/test/http/hackathon-command-e2e.test.ts index 94eaff3..4232602 100644 --- a/test/http/hackathon-command-e2e.test.ts +++ b/test/http/hackathon-command-e2e.test.ts @@ -2,7 +2,9 @@ import { env } from "cloudflare:test"; import { afterEach, describe, expect, it, vi } from "vitest"; import { app } from "../../src/index"; import type { Env } from "../../src/index"; +import { createD1HackathonAnalysisRepo } from "../../src/adapters/d1/hackathon-analysis-repo"; import type { AnalysisJobMessage } from "../../src/domain/entities"; +import { asTeamId } from "../../src/domain/ids"; import { stubTelegramApi } from "../support/telegram-stub"; // task 10.3/10.4: `/hackathon ` driven through the real Hono route, @@ -135,3 +137,120 @@ describe("POST /telegram/webhook — /hackathon through real composition", expect(queue.sent).toHaveLength(0); }); }); + +// hackathon-participation: `/hackathon join ` through the real composition +// root (D1 repos, ForumTopicManager and ChatPublisher adapters). +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; + }); + + async function seedAnalysis(chatId: number, slug: string, generalMessageId: number | null) { + const team = await env.DB.prepare("SELECT id FROM teams WHERE telegram_chat_id = ?") + .bind(chatId) + .first<{ id: string }>(); + const repo = createD1HackathonAnalysisRepo(env.DB); + await repo.save({ + id: `e2e-${chatId}-${slug}`, + teamId: asTeamId(team!.id), + slug, + sourceUrl: `https://example.com/${slug}`, + normalizedUrl: `https://example.com/${slug}`, + 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(asTeamId(team!.id), `e2e-${chatId}-${slug}`, generalMessageId); + } + } + + it("creates the topic, links it in D1, clears the stored button and confirms in General", async () => { + const calls = stubForum(); + const queue = fakeQueue(); + const chatId = -1_005_551_041; + const userId = 900_141; + + await post(commandUpdate("setup", chatId, userId), queue.binding); + await seedAnalysis(chatId, "meridian", 321); + const res = await post(commandUpdate("hackathon", chatId, userId, "join meridian"), queue.binding); + + expect(res.status).toBe(200); + const row = await env.DB.prepare( + "SELECT thread_id, general_message_id FROM hackathon_analyses WHERE id = ?", + ) + .bind(`e2e-${chatId}-meridian`) + .first(); + expect(row).toEqual({ thread_id: 4242, general_message_id: 321 }); + expect(calls.filter((c) => c.method === "createForumTopic")).toHaveLength(1); + expect(calls.find((c) => c.method === "createForumTopic")?.body).toMatchObject({ + name: "🏆 Meridian Hack", + }); + const clear = calls.find((c) => c.method === "editMessageReplyMarkup"); + expect(clear?.body).toMatchObject({ chat_id: chatId, message_id: 321 }); + const texts = calls.filter((c) => c.method === "sendMessage").map((c) => c.body as { text: string; message_thread_id?: number }); + expect(texts.at(-1)?.text).toBe("✅ Participamos en Meridian Hack → https://t.me/c/5551041/4242"); + expect(texts.at(-1)?.message_thread_id).toBeUndefined(); + expect(queue.sent).toHaveLength(0); + }); + + it("a redelivered join creates no second topic and replies 'ya tiene tema'", async () => { + const calls = stubForum(); + const queue = fakeQueue(); + const chatId = -1_005_551_042; + const userId = 900_142; + + await post(commandUpdate("setup", chatId, userId), queue.binding); + await seedAnalysis(chatId, "meridian", null); + await post(commandUpdate("hackathon", chatId, userId, "join meridian"), queue.binding); + await post(commandUpdate("hackathon", chatId, userId, "join meridian"), 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/5551042/4242"); + }); + + it("replies with the missing-rights text and stores nothing when Telegram refuses", async () => { + const calls = stubTelegramApi((method) => + method === "createForumTopic" + ? { ok: false, error_code: 400, description: "Bad Request: not enough rights to create a topic" } + : undefined, + ); + const queue = fakeQueue(); + const chatId = -1_005_551_043; + const userId = 900_143; + + await post(commandUpdate("setup", chatId, userId), queue.binding); + await seedAnalysis(chatId, "meridian", null); + const res = await post(commandUpdate("hackathon", chatId, userId, "join meridian"), queue.binding); + + expect(res.status).toBe(200); + const texts = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); + expect(texts.at(-1)).toBe( + "No puedo crear temas: concede al bot el permiso «Administrar temas» y vuelve a intentarlo.", + ); + const row = await env.DB.prepare("SELECT thread_id, topic_claim_until FROM hackathon_analyses WHERE id = ?") + .bind(`e2e-${chatId}-meridian`) + .first(); + expect(row).toEqual({ thread_id: null, topic_claim_until: 0 }); + }); +}); From e4e472222b81d611b1a9d236f298ac51ed4f0972 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 17:02:24 -0400 Subject: [PATCH 6/6] docs(hackathon-participation): mark phase 2 tasks done and record apply progress --- .../hackathon-participation/apply-progress.md | 51 +++++++++++++++++++ .../changes/hackathon-participation/tasks.md | 38 +++++++------- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md index 7c50c47..b6e3036 100644 --- a/openspec/changes/hackathon-participation/apply-progress.md +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -1,5 +1,7 @@ # 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. + ## Batch 1 — Phase 1 Infrastructure (PR1a) — branch `feat/participation-infra` Mode: Strict TDD. Delivery: stacked-to-main, PR1a (infrastructure unused until PR1b). @@ -38,3 +40,52 @@ Completed: 1.1–1.12 (12/12 of Phase 1). Phases 2–4 pending. - Claim uses `topic_claim_until <= now` (an expired-at-now claim can be retaken). - Fake publisher keeps `posted` shape unchanged; options are logged in a parallel `postOptions` array. - Fakes (1.11) have no direct tests; they are exercised by the PR1b use-case tests. + +## Batch 2 — Phase 2 Use Case and Join (PR1b) — branch `feat/participation-usecase` + +Mode: Strict TDD. Base: main bfec91d (PR1a merged). Completed: Step 0 (PR1a review warnings) + 2.1–2.11 (all of Phase 2). + +### Step 0 (PR1a advisory warnings) + +| Finding | Resolution | +|---|---| +| R3-002 fake repo `save` replaced the whole row | `fakeHackathonAnalysisRepo.save` now copies the row and keeps the stored `generalMessageId` (claims live in a separate map, so they were already preserved); `fakeAnalysisJobRepo.persistAnalysis` inherits it. Tests in `test/fakes/hackathon-analysis-repo.test.ts` (RED: 3 failed, then 5/5) | +| R3-001 refresh keeps non-null `generalMessageId` | Test added in `analyze-hackathon.test.ts`. It passed on first run because the carry-over line already existed (regression guard, not a true RED) | +| R2-001 detached `ChatPublisher.post` comment | Moved back above `ChatPublisher` | +| R2-002 `<` vs `<=` | design.md decision 3 and the ports.ts comment now say `<=` | + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 2.1/2.2 | `test/domain/hackathon/topic.test.ts` | Unit (pure) | N/A (new) | module missing | 9 tests pass | name, control/bidi, empty→slug, 128 cap, surrogate, ellipsis, link -100/non--100 | None needed | +| 2.3/2.4 | `test/domain/hackathon/argument.test.ts` | Unit (pure) | 5/5 | 5 failed (not a function) | 10/10 | join, bare, non-slug, 3 tokens, other→null | None needed | +| 2.5–2.5h/2.6 | `test/domain/usecases/participate-in-hackathon.test.ts` | Unit (fakes) | N/A (new) | module missing | 25/25 | happy, slug fallback, no link, redelivery, live, unknown, deleted, lost claim, busy, 4 refusals, uncertain keeps claim, pin/post/link failures, clear dedupe, null id | Concurrent test relaxed to accept busy or already (interleaving) | +| 2.7/2.9 | `test/adapters/telegram/participation.test.ts` | Unit (fakes) | N/A (new) | module missing | 13/13 | 7 refusal maps, safe General post failure, busy no-op, unrecognized rethrow | None needed | +| 2.8/2.9 | `test/adapters/telegram/commands.test.ts` (+12 join cases), `test/http/hackathon-command-e2e.test.ts` (+3) | Integration | 90+/all | 13 failed | all pass | admin/topic/General, usage x2, non-admin, stranger, unknown slug, old analysis, button clear, linkFailed, private chat, other multi-token unchanged; e2e create+clear, redelivery, no-rights | e2e ids made per-chat | +| 2.10 | `test/copy/catalog-language.test.ts` | Unit | pass | new catalogs failed to import | pass | denylist, non-empty, no voseo/usted | None needed | +| 2.11 | full suite | — | — | — | 997/997, typecheck clean | — | — | + +### Work Unit Evidence + +| Evidence | Value | +|---|---| +| Focused test command and result | `npx vitest run test/domain test/adapters/telegram test/http/hackathon-command-e2e.test.ts test/copy test/fakes`: all green; full suite 76 files, 997/997 | +| Runtime harness | N/A: no fetch/LLM path; e2e drives the real Hono route, composition, D1 and adapters with stubbed Telegram HTTP | +| Rollback boundary | `participate-in-hackathon.ts`, `hackathon/topic.ts`, `participation.ts`, join branch in `hackathon-commands.ts`, `forumTopicManager` wiring in `composition.ts` | + +### Commits +- `e8c6649` test(fakes): preserve generalMessageId and claim state on fake repo save +- `ed5fa16` docs(ports): restore ChatPublisher contract comment and align claim expiry to <= +- `09e9c7f` feat(hackathon): topic name, topic link and join argument helpers +- `097db02` feat(participation): use case, errors, domain copy +- `ed31694` feat(telegram): /hackathon join and runParticipation + +### Deviations / notes +- Domain copy is one `participationCopy` object (`alreadyHasTopic`, `confirmed`, `postFailed`, `linkFailed`); adapter copy is `participateCopy`. A null link (chat id without `-100`) drops the link and separator. +- Use case result is `{kind, replyText}` with `busy` carrying `replyText: null`. `already` and `created` are delivered to General through the safe post (design decision 8), not `ctx.reply`; only pre-creation refusals use the command reply. +- `sanitizeTopicName` is exported from `topic.ts` (the confirmation shows the name without the emoji). +- A non-member is refused with the same `adminOnly` text: `UnauthorizedError` in the use case, `NotFoundError` mapped in `runParticipation`, and a direct reply in the join handler when membership cannot be resolved. +- 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. diff --git a/openspec/changes/hackathon-participation/tasks.md b/openspec/changes/hackathon-participation/tasks.md index 9a87eb3..f0ed3f5 100644 --- a/openspec/changes/hackathon-participation/tasks.md +++ b/openspec/changes/hackathon-participation/tasks.md @@ -45,25 +45,25 @@ Each PR is independently deployable and keeps `npm test` green. PR1a adds unused ## Phase 2: Use Case and Join Command (PR1b) -- [ ] 2.1 RED: `test/domain/hackathon/topic.test.ts` — `topicNameFor` (control/bidi chars stripped, whitespace collapsed, empty → slug, `🏆 ` prefix, ≤128 UTF-16 units, no split surrogate, `…` when cut); `topicLink` (`-100` stripped, non-`-100` → null). -- [ ] 2.2 GREEN: create `src/domain/hackathon/topic.ts`. -- [ ] 2.3 RED: `test/domain/hackathon/argument.test.ts` — `parseJoinArgument`: `join meridian` → join+slug; bare `join`, `join Not_Slug`, `join a b` → `join-usage`; other arguments → null (existing rules unchanged). -- [ ] 2.4 GREEN: `src/domain/hackathon/argument.ts` (`parseJoinArgument`, checked before the whitespace rule). -- [ ] 2.5 RED: create `test/domain/usecases/participate-in-hackathon.test.ts` — non-admin/non-member → `UnauthorizedError`, unknown slug → `AnalysisNotFoundError`, nothing changes; happy path fresh topic created, linked, pinned, General text `confirmed(name, link)`. -- [ ] 2.5a RED: same file — **redelivery never creates a second topic**: second call after success returns `already(link)`, exactly one `create`. -- [ ] 2.5b RED: same file — live topic (`live`) → `already`, no create; **ambiguous probe error treated as live (no recreate)**: `unknown` → `already`, no create, buttons cleared best-effort. -- [ ] 2.5c RED: same file — **deleted topic detected and recreated**: `deleted` → claim with `expected = stale id`, new topic created, stale id replaced, no spurious unpin/"moved" note. -- [ ] 2.5d RED: same file — **concurrent taps**: claim loss then re-read linked → `already`; not linked → `busy` (neutral no-op, nothing posted); exactly one `create` across two calls. -- [ ] 2.5e RED: same file — **missing Manage Topics / not a forum (nothing persisted)**: `no-rights` → `TopicRightsMissingError`, `not-forum` → `ChatNotForumError`; claim released, no link stored. `rate-limited`/`rejected` → `TopicCreationFailedError`, claim released. `unavailable` → `TopicCreationUncertainError`, claim kept. -- [ ] 2.5f RED: same file — **pin failure**: analysis posted unpinned, link persists, `pinFailed` note in result. -- [ ] 2.5g RED: same file — **post failure after topic creation (never rethrows)**: link persists, result `postFailed(slug, link)`; `moveTopicLink` failure → `linkFailed(slug, link)`; `clearButtons` failure ignored; clears the deduped set {callback message id, `generalMessageId`}. -- [ ] 2.5h RED: same file — **old analyses without a message id**: `generalMessageId: null` → join works, nothing to clear, no `clearButtons` call for it. -- [ ] 2.6 GREEN: `src/domain/errors.ts` (`TopicRightsMissingError`, `ChatNotForumError`, `TopicCreationFailedError`, `TopicCreationUncertainError`), `src/domain/copy.ts` (`alreadyHasTopic`, `confirmed`, `postFailed`, `linkFailed`), create `src/domain/usecases/participate-in-hackathon.ts` with the design step order (no-throw zone from `moveTopicLink`). -- [ ] 2.7 RED: `test/adapters/telegram/participation.test.ts` — `runParticipation` maps each domain error by name to its Spanish reply (`adminOnly`, `noAnalysis(slug)`, `noRights`, `notForum`, `createFailed` "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.", `createUncertain` "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo."); a failing safe General post is caught and logged, never rethrown (no 500). -- [ ] 2.8 RED: `test/adapters/telegram/commands.test.ts` (hackathon cases) and `test/http/hackathon-command-e2e.test.ts` — `/hackathon join ` runs participation with no fetch/cap; `join` usage line "Uso: /hackathon join "; non-admin reply; unknown-slug reply; `join ` on an old analysis works; `linkFailed` reply text. -- [ ] 2.9 GREEN: create `src/adapters/telegram/participation.ts` (`runParticipation`, safe General post); `src/adapters/telegram/copy.ts` (all adapter strings from the design copy table); `src/adapters/telegram/hackathon-commands.ts` (join branch); `src/composition.ts` (wire use case, `ForumTopicManager`). -- [ ] 2.10 Extend `test/copy/catalog-language.test.ts` to the new catalog entries (non-empty, Spanish, no English denylist match, neutral "tú"); make it pass. -- [ ] 2.11 Run `npm test` and `npm run typecheck`; join works, button not yet rendered. +- [x] 2.1 RED: `test/domain/hackathon/topic.test.ts` — `topicNameFor` (control/bidi chars stripped, whitespace collapsed, empty → slug, `🏆 ` prefix, ≤128 UTF-16 units, no split surrogate, `…` when cut); `topicLink` (`-100` stripped, non-`-100` → null). +- [x] 2.2 GREEN: create `src/domain/hackathon/topic.ts`. +- [x] 2.3 RED: `test/domain/hackathon/argument.test.ts` — `parseJoinArgument`: `join meridian` → join+slug; bare `join`, `join Not_Slug`, `join a b` → `join-usage`; other arguments → null (existing rules unchanged). +- [x] 2.4 GREEN: `src/domain/hackathon/argument.ts` (`parseJoinArgument`, checked before the whitespace rule). +- [x] 2.5 RED: create `test/domain/usecases/participate-in-hackathon.test.ts` — non-admin/non-member → `UnauthorizedError`, unknown slug → `AnalysisNotFoundError`, nothing changes; happy path fresh topic created, linked, pinned, General text `confirmed(name, link)`. +- [x] 2.5a RED: same file — **redelivery never creates a second topic**: second call after success returns `already(link)`, exactly one `create`. +- [x] 2.5b RED: same file — live topic (`live`) → `already`, no create; **ambiguous probe error treated as live (no recreate)**: `unknown` → `already`, no create, buttons cleared best-effort. +- [x] 2.5c RED: same file — **deleted topic detected and recreated**: `deleted` → claim with `expected = stale id`, new topic created, stale id replaced, no spurious unpin/"moved" note. +- [x] 2.5d RED: same file — **concurrent taps**: claim loss then re-read linked → `already`; not linked → `busy` (neutral no-op, nothing posted); exactly one `create` across two calls. +- [x] 2.5e RED: same file — **missing Manage Topics / not a forum (nothing persisted)**: `no-rights` → `TopicRightsMissingError`, `not-forum` → `ChatNotForumError`; claim released, no link stored. `rate-limited`/`rejected` → `TopicCreationFailedError`, claim released. `unavailable` → `TopicCreationUncertainError`, claim kept. +- [x] 2.5f RED: same file — **pin failure**: analysis posted unpinned, link persists, `pinFailed` note in result. +- [x] 2.5g RED: same file — **post failure after topic creation (never rethrows)**: link persists, result `postFailed(slug, link)`; `moveTopicLink` failure → `linkFailed(slug, link)`; `clearButtons` failure ignored; clears the deduped set {callback message id, `generalMessageId`}. +- [x] 2.5h RED: same file — **old analyses without a message id**: `generalMessageId: null` → join works, nothing to clear, no `clearButtons` call for it. +- [x] 2.6 GREEN: `src/domain/errors.ts` (`TopicRightsMissingError`, `ChatNotForumError`, `TopicCreationFailedError`, `TopicCreationUncertainError`), `src/domain/copy.ts` (`alreadyHasTopic`, `confirmed`, `postFailed`, `linkFailed`), create `src/domain/usecases/participate-in-hackathon.ts` with the design step order (no-throw zone from `moveTopicLink`). +- [x] 2.7 RED: `test/adapters/telegram/participation.test.ts` — `runParticipation` maps each domain error by name to its Spanish reply (`adminOnly`, `noAnalysis(slug)`, `noRights`, `notForum`, `createFailed` "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.", `createUncertain` "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo."); a failing safe General post is caught and logged, never rethrown (no 500). +- [x] 2.8 RED: `test/adapters/telegram/commands.test.ts` (hackathon cases) and `test/http/hackathon-command-e2e.test.ts` — `/hackathon join ` runs participation with no fetch/cap; `join` usage line "Uso: /hackathon join "; non-admin reply; unknown-slug reply; `join ` on an old analysis works; `linkFailed` reply text. +- [x] 2.9 GREEN: create `src/adapters/telegram/participation.ts` (`runParticipation`, safe General post); `src/adapters/telegram/copy.ts` (all adapter strings from the design copy table); `src/adapters/telegram/hackathon-commands.ts` (join branch); `src/composition.ts` (wire use case, `ForumTopicManager`). +- [x] 2.10 Extend `test/copy/catalog-language.test.ts` to the new catalog entries (non-empty, Spanish, no English denylist match, neutral "tú"); make it pass. +- [x] 2.11 Run `npm test` and `npm run typecheck`; join works, button not yet rendered. ## Phase 3: Button, Callback, Consumer (PR2)