diff --git a/migrations/0004_hackathon_participation.sql b/migrations/0004_hackathon_participation.sql new file mode 100644 index 0000000..0919684 --- /dev/null +++ b/migrations/0004_hackathon_participation.sql @@ -0,0 +1,11 @@ +-- Hackathon participation (design.md "Migration / Rollout"). Additive only: +-- old Workers ignore both columns, so rollback is a plain redeploy. + +-- Message id of the General analysis post that carries the "Participamos" +-- button, so `/hackathon join` can remove it. Null for analyses posted before +-- this migration or when storing the id failed (best-effort). +ALTER TABLE hackathon_analyses ADD COLUMN general_message_id INTEGER; + +-- Epoch-ms until which a topic-creation claim is held (compare-and-set lease, +-- design.md decision 3). 0 = no claim; expired claims can be re-taken. +ALTER TABLE hackathon_analyses ADD COLUMN topic_claim_until INTEGER NOT NULL DEFAULT 0; diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md new file mode 100644 index 0000000..7c50c47 --- /dev/null +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -0,0 +1,40 @@ +# Apply Progress: hackathon-participation + +## Batch 1 — Phase 1 Infrastructure (PR1a) — branch `feat/participation-infra` + +Mode: Strict TDD. Delivery: stacked-to-main, PR1a (infrastructure unused until PR1b). + +Completed: 1.1–1.12 (12/12 of Phase 1). Phases 2–4 pending. + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 1.1/1.2 | `test/adapters/migrations.test.ts` | Integration (D1) | 20/20 | 3 failed (no such column) | 23/23 | 3 cases (nullable id, NOT NULL DEFAULT 0, defaults on insert) | None needed | +| 1.3/1.4/1.5 | `test/adapters/d1/hackathon-analysis-repo.test.ts` | Integration (D1) | 13/13 | 13 failed (missing methods/field) | 26/26 | null/stale/mismatch, TTL boundary, race, tenant scope, release, save + persistAnalysis non-clobber | Test ids made per-team (id is the upsert key) | +| 1.6/1.7/1.8 | `test/adapters/telegram/forum-topic-manager.test.ts` | Unit (Api stub) | N/A (new) | module missing | 25/25 | 10 create classes + network/timeout/abort signal; 4 deleted strings, 5 ambiguous incl. non-400 with deleted text, network | None needed | +| 1.9/1.10 | `test/adapters/telegram/chat-publisher.test.ts` | Unit (Api stub) | 21/21 | 5 failed | 26/26 | no-option, empty options, button, 40-char slug ≤64 B, clear ok/400/502 | None needed | +| 1.11 | `test/fakes/index.ts` | Test support | n/a | exercised by PR1b tests | typecheck green | ➖ Triangulation skipped: fakes | ➖ | +| 1.12 | full suite | — | — | — | 916/916, typecheck clean | — | — | + +### Work Unit Evidence + +| Evidence | Value | +|---|---| +| Focused test command and result | `npx vitest run test/adapters` files above: all green (migrations 23, repo 26, topic manager 25, publisher 26) | +| Runtime harness | N/A: infrastructure unused until PR1b; no fetch/LLM path | +| Rollback boundary | new files + additive columns (ignored on revert); `HackathonAnalysis.generalMessageId` and `ChatPublisher` additions | + +### Commits +- `2a2d4c1` docs: exploration, proposal, specs, design, tasks +- `9f2c910` feat(db): migration 0004 +- `142b2d7` feat(d1): claim CAS, release, general message id +- `7f7860f` feat(telegram): ForumTopicManager port and adapter +- `27b496a` feat(telegram): clearButtons and participation button + +### Deviations / notes +- `HackathonAnalysis.generalMessageId` is a required field, so 12 existing test fixtures gained `generalMessageId: null` (mechanical) and `analyzeHackathon` carries `existing?.generalMessageId ?? null` (SQL upsert never writes it). +- `PARTICIPATE_CALLBACK_PREFIX = "hp:"` is exported from `chat-publisher.ts` so the PR2 handler reuses it. +- 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. diff --git a/openspec/changes/hackathon-participation/design.md b/openspec/changes/hackathon-participation/design.md new file mode 100644 index 0000000..7e4f4be --- /dev/null +++ b/openspec/changes/hackathon-participation/design.md @@ -0,0 +1,143 @@ +# Design: Confirm Hackathon Participation and Create Its Topic + +## Technical Approach + +Hexagonal, same as change 3. There is one pure use case, `participateInHackathon`, with two thin grammY triggers: +- 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`. + +## 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. | +| 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. | +| 7 | Deep link | A pure `topicLink(chatId, threadId)`: `https://t.me/c/${String(chatId).slice(4)}/${threadId}` when the chat id starts with `-100`, otherwise null (the reply then omits the link). This is the format Telegram's "Copy link" produces for a topic, since a topic id is its creation message id. It opens only for members. **Verify in the smoke test.** | A `t.me/` link (most team groups are private). | +| 8 | Error-to-reply | Pre-creation refusals are domain errors mapped by name (`runCommand` pattern). Everything after a successful `create` returns a result and never throws. The adapter delivers `replyText` to General with a safe post (catch and log, no rethrow) so a failed reply cannot produce a 500 and a redelivery. | `ctx.reply` inside `runCommand`: its failure rethrows and causes a 500. | + +## 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`. +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: + + | Failure | Claim | Error | Rationale | + |---|---|---|---| + | `no-rights` | Released | `TopicRightsMissingError` | Net state unchanged ("persist nothing") | + | `not-forum` | Released | `ChatNotForumError` | Same | + | `rate-limited` or `rejected` | Released | `TopicCreationFailedError` | Same | + | `unavailable` (timeout or 5xx: the topic may exist) | **Kept** | `TopicCreationUncertainError` | The TTL blocks an immediate duplicate | + +5. **The no-throw zone starts here.** Call `moveTopicLink(T, pinned=null)`, which also replaces a stale id. On failure, return `linkFailed(link)`. +6. Call `postAnalysisAndLinkTopic({threadId: T, analysis: {...analysis, threadId: T}})`. The threadId override avoids a spurious unpin and a "moved" note. If the post fails, return `postFailed(link)`. A pin failure adds the existing `pinFailed` note. +7. Clear the buttons, best-effort, for the deduped set of {callback message id, `generalMessageId`}. Return `created(name, link, notes)`. + +## Data Flow + +``` +tap hp: ─ callbackCallerLocation ─ member+admin? ─no→ alert +/hackathon join ─ parseJoinArgument ─┐ │yes + └─ participateInHackathon + probe ─ claim(CAS) ─ create ─ moveTopicLink ─ post+pin (existing) ─ clearButtons + └→ replyText ─ safe post to General (threadId null) +consumer: postToGeneral(text, participateSlug) ─ setGeneralMessageId (best-effort) +``` + +## 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; } +// HackathonAnalysisRepo additions; HackathonAnalysis gains generalMessageId: number | null +claimTopicCreation(team, id, expectedThreadId: number | null, now: number, ttlMs: number): Promise; +releaseTopicClaim(team, id): Promise; +setGeneralMessageId(team, id, messageId: number): Promise; +``` + +`save` and `persistAnalysis` keep their explicit column lists, so the new columns take their defaults and are never clobbered. Adapter classification of `createForumTopic` (a `GrammyError` description, lowercased): +- `not enough rights` or `chat_admin_required` ⇒ `no-rights`; +- `not a forum` or `channel_forum_missing` ⇒ `not-forum`; +- 429 ⇒ `rate-limited`; +- other 4xx ⇒ `rejected`; +- `HttpError`, 5xx or a timeout ⇒ `unavailable`. + +Topic name: strip `\p{Cc}\p{Cf}` (bidi spoofing too), collapse whitespace and trim; fall back to the slug; the result is `🏆 ` + the name, cut at code-point boundaries to at most 128 UTF-16 units, with `…` when cut. The General confirmation shows the sanitized name without the emoji. + +## Copy Table (spec strings are authoritative) + +| Key | Catalog | Text | +|---|---|---| +| `participateButton` | adapter | ✅ Participamos | +| `adminOnly` (alert + reply) | adapter | Solo un administrador del equipo puede confirmar la participación. | +| `joinUsage` | adapter | Uso: /hackathon join | +| `noAnalysis(slug)` | adapter | No se encontró ningún análisis con el slug . | +| `noRights` | adapter | No puedo crear temas: concede al bot el permiso «Administrar temas» y vuelve a intentarlo. | +| `notForum` | adapter | Este grupo no tiene los temas activados. Actívalos en la configuración del grupo y vuelve a intentarlo. | +| `createFailed` | adapter | *(design-proposed)* Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto. | +| `createUncertain` | adapter | *(design-proposed)* No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo. | +| `alreadyHasTopic(link)` | domain | Este hackathon ya tiene tema: | +| `confirmed(name, link)` | domain | ✅ Participamos en → | +| `postFailed(slug, link)` | domain | Se creó el tema y se vinculó , pero no se pudo publicar el análisis. Ejecuta /hackathon dentro del tema: | +| `linkFailed(slug, link)` | domain | *(design-proposed)* Se creó el tema, pero no se pudo vincular . Ejecuta /hackathon dentro del tema: | +| `pinFailed` | domain (existing) | No se pudo fijar el mensaje; se publicó sin fijar. | + +## File Changes and PR Split + +| PR | Files | Est. lines (src + tests) | +|---|---|---| +| 1: port, adapter, use case, join | `migrations/0004_hackathon_participation.sql` (new); `src/domain/{entities,ports,errors,copy}.ts`; `src/domain/hackathon/{topic.ts (new), argument.ts}`; `src/domain/usecases/participate-in-hackathon.ts` (new); `src/adapters/telegram/forum-topic-manager.ts` (new); `chat-publisher.ts` (`clearButtons`); `src/adapters/d1/hackathon-analysis-repo.ts`; `src/adapters/telegram/participation.ts` (new: `runParticipation`, safe General post); `hackathon-commands.ts` (join branch); `telegram/copy.ts`; `composition.ts`; `test/fakes/index.ts`; tests | ~380 + ~420 ≈ **800** | +| 2: button, callback, consumer | `ports.ts` + `chat-publisher.ts` (`PostOptions` + keyboard); `context.ts` (`callbackCallerLocation`); `participation.ts` (`hp:` handler, registered from `registerHackathonCommands`); `run-hackathon-job.ts` (`postToGeneral` at both General sites); `telegram/copy.ts` (button); fakes; tests | ~135 + ~270 ≈ **400** | + +PR1 exceeds the 400-line budget. `sdd-tasks` should split it: +- 1a: migration, D1 repo, topic adapter, `clearButtons`, fakes (~350); +- 1b: pure helpers, use case, join (~450); + +or record `size:exception`. + +## Testing Strategy (Strict TDD) + +| 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 | +| 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 | +| 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. + +## Threat Matrix + +The shell, VCS and PR rows are N/A: this change has no shell, subprocess, VCS or PR automation. The only routing change is the Telegram `hp:` callback. Its `callback_data` is untrusted: a strict slug regex, the team from the chat and never from the payload, and the admin check from `ctx.from.id`. RED tests are listed under Handlers. + +## Migration / Rollout + +`0004`: +- `ADD COLUMN general_message_id INTEGER` +- `ADD COLUMN topic_claim_until INTEGER NOT NULL DEFAULT 0` + +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. + +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). +- [ ] 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/explore.md b/openspec/changes/hackathon-participation/explore.md new file mode 100644 index 0000000..7ecc46c --- /dev/null +++ b/openspec/changes/hackathon-participation/explore.md @@ -0,0 +1,123 @@ +# Exploration: hackathon-participation (roadmap change 4) + +Mirror of engram `sdd/hackathon-participation/explore` (hybrid store). + +## Current State + +- `/hackathon ` (admin, General): the producer enqueues. In General (`threadId` null), the consumer `runHackathonJob` persists the analysis and then calls `chatPublisher.post(chat, null, text)`. It keeps no message id, creates no link and pins nothing. In a topic, the consumer calls `postAnalysisAndLinkTopic` instead, which posts, pins best-effort, and links through `moveTopicLink` in one atomic D1 batch. +- `/hackathon ` from an admin inside a topic calls `linkAnalysisToTopic` (admin gate, `findBySlug`, `postAnalysisAndLinkTopic`). Any member anywhere else gets `showAnalysis`, which is plain text with no side effects. +- `hackathon_analyses` has `thread_id` and `pinned_message_id`, with a partial UNIQUE index on `(team_id, thread_id) WHERE thread_id IS NOT NULL`. `thread_id` alone is enough to know that an analysis already has a topic, so the basic flow needs no migration. +- The `ChatPublisher` port only has `post`, `pin` and `unpin`. Nothing calls `createForumTopic`, and nothing supports `reply_markup`. The only `callbackQuery` handler is the team picker `sel:`, which is DM only. +- `callerLocation()` reads `ctx.message`, so for a `callback_query` it returns `threadId` null. It needs a variant based on `ctx.msg`. +- `/hackathon` refuses any argument containing whitespace with a BadArgument usage reply. `/hackathon join ` is therefore refused today: the syntax is free, but the whitespace check must change. A bare `join` is a valid slug shape. +- Webhook policy: an unrecognized error is rethrown, the webhook returns 500, and Telegram redelivers the same update. That is dangerous after a side effect such as creating a topic. +- `openspec/specs/telegram-webhook` says the bot only sees commands under default privacy. `BOT_INFO.can_read_all_group_messages=false` is only a cached `getMe` var; grammY uses only its id and username. + +## Key finding: privacy mode vs admin + +The Telegram Bots FAQ says: "Bot admins and bots with privacy mode disabled will receive all messages except messages sent by other bots." The bot is a group admin, so it very likely ALREADY receives plain free text in General, whatever the BotFather privacy setting says. The premise that plain text will not reach it is probably wrong for this deployment. + +This is UNVERIFIED in production. The cheap test is to post plain text in General and check Worker observability for an update that no handler processed. + +Under true privacy mode, these are the documented deliveries: +- commands (`/cmd@bot`) +- general commands, when the bot was the last sender +- replies to the bot's messages +- service messages +- inline-via-bot messages + +@mention delivery is NOT in the official list, and community reports conflict, so do not rely on it. Callback queries are always delivered. + +## Approaches + +| | A `/hackathon join ` | B reply-to-bot text | C NL classifier | D inline button | +|---|---|---|---|---| +| Delivery | Command, always delivered | Documented for replies to the bot; works under privacy | Needs all messages: the admin bot probably gets them already (verify), or privacy must be off | `callback_query`, always delivered | +| Neurons | 0 | 0 with keywords; with the LLM, ~10 per reply (estimate) | One LLM call per candidate message. Needs a regex prefilter: unfiltered, ~500 msgs × ~10 is half the free 10k/day | 0 | +| Security | Admin gate, deterministic | Ambiguity, spoofed phrasing, and an LLM misfire creates a topic | Reads all team chat (privacy and PII posture), prompt injection, false positives; a wrong topic is hard to undo | Admin gate on the tapper; the payload only carries the slug; the team comes from the chat id | +| Testability | Trivial (fake ports) | Medium: multilingual keywords are brittle | Hard: needs the real-model harness (project rule) | Good: callback fixtures exist in `commands.test.ts` | +| Complexity | Low | Medium: the slug comes from the reply's `Slug:` line or from a stored message id | High | Low to medium | +| Matches "we say we'll participate" | No | Partly | Yes | No (a tap, not text) | + +## Recommendation + +Change 4 is D + A, both built on ONE use case, `participateInHackathon`. It checks the admin gate, looks up `findBySlug`, checks idempotency, then calls `createForumTopic`, links, posts and pins. + +- The General analysis gets a "Participamos" button (`callback_data` `hp:`, ≤64 bytes). +- `/hackathon join ` is the fallback for old messages and for when the button fails. + +This is deterministic, costs zero neurons, is testable, and needs no privacy change. + +Defer NL (C) to a follow-up that REUSES the same use case. The flow would be: +1. A regex prefilter (es/en). +2. An optional LLM enum classifier. +3. The bot asks for confirmation with the D button, so the LLM never triggers side effects directly. + +B is a subset of C's UX, so skip it as a standalone option. + +## Design notes for the proposal + +- **Topic name:** `fields.name.value`, falling back to the slug. + - Strip control characters, collapse whitespace, and cap at ~100 characters (Telegram allows 1–128). + - An optional leading emoji can go in the name text. + - `icon_color` is optional. The classic six values are 7322096, 16766590, 13338331, 9367192, 16749490 and 16478047; re-verify them at design time. + - Page-derived text is untrusted and stays plain text only. +- **Ports:** add a new port (ISP), `ForumTopicManager.create(chatId, name) -> threadId`, or extend `ChatPublisher`. Classify Telegram rejections (not enough rights, not a forum) into distinct replies. Extend `post()` with an optional inline keyboard. +- **Idempotency:** if `analysis.threadId != null`, create nothing. Reply "already has a topic" with a `t.me/c//` link. + - Concurrent taps: the recommended option is an optional migration 0004 that adds `topic_claim_at` plus a conditional UPDATE claim. The alternative is to accept the small race. +- **After `createForumTopic` succeeds:** + - Link immediately (`moveTopicLink(thread, pinned null)`), then call `postAnalysisAndLinkTopic`, which is idempotent for the same id. + - Never rethrow after the topic is created: a 500 means redelivery, which means a second topic. + - On partial failure, reply with a recovery hint. +- **Failures:** + - Missing `can_manage_topics` or not a forum: give an operator instruction and persist nothing. + - Pin fails: use the existing note; the link persists. + - Post fails: the topic exists and is linked; send a recovery reply. + - Topic deleted later: `thread_id` goes stale. `/hackathon ` in a new topic already moves the link. +- **Permissions:** admin only. A non-admin tap gets an `answerCallbackQuery` alert. Membership comes from the chat id plus `ctx.from.id`. Answering the callback and dropping the button are best-effort. +- **Cost:** ~3 Telegram calls and 2 D1 operations, synchronous, 0 neurons, no queue. + +## Affected Areas + +- `src/domain/ports.ts` +- `src/adapters/telegram/chat-publisher.ts` +- `src/domain/usecases/participate-in-hackathon.ts` (new) +- `src/domain/usecases/run-hackathon-job.ts` (the two General post sites) +- `src/domain/usecases/show-analysis.ts` +- `src/adapters/telegram/hackathon-commands.ts` (join subcommand, `callbackQuery`, ERROR_REPLIES) +- `src/adapters/telegram/context.ts` +- `src/composition.ts` +- `src/domain/errors.ts` +- The openspec spec delta +- An optional `migrations/0004` +- Tests + +## Review size + +Medium: ~350–500 changed lines including tests, which is borderline against the 400-line limit. Suggest 2 PRs: +1. Port, adapter, use case and `/hackathon join`. +2. Button, callback handler and consumer wiring. + +Review lenses: reliability, plus resilience. + +## Risks + +- That an admin bot receives all messages is documented but unverified in production. +- A double topic on redelivery or on concurrent taps. +- The bot may lack `can_manage_topics`. This is an operator step and needs docs. +- Old analyses have no button, and the General message id is not stored. +- Any change to the LLM path needs `npm run harness`. + +## Open product questions + +1. Who may confirm: admins only, or any member? +2. Button plus `/hackathon join` now, with free-text NL later as a confirm-first change? +3. Topic name, emoji and icon colour? +4. Recreate the topic if it was deleted manually? +5. Post a confirmation with the topic link in General? +6. Accept a 5-minute production test of plain-message delivery? +7. Follow-ups on participation (suggest `/linkrepo`, reminders)? + +## Ready for Proposal + +Yes: D + A. diff --git a/openspec/changes/hackathon-participation/proposal.md b/openspec/changes/hackathon-participation/proposal.md new file mode 100644 index 0000000..0a642ea --- /dev/null +++ b/openspec/changes/hackathon-participation/proposal.md @@ -0,0 +1,112 @@ +# Proposal: Confirm Hackathon Participation and Create Its Topic + +## Intent + +After a General-chat analysis, the team decides whether to join. Today an admin has to create the forum topic by hand and then run `/hackathon ` inside it. This change turns that decision into one admin action: the bot creates the topic, links the analysis, and pins it. It costs 0 neurons and uses the Workers Free plan only. Roadmap change 4. + +## Scope + +### In Scope +- A single use case, `participateInHackathon`, with two triggers: + - an inline "✅ Participamos" button on the General analysis post (`callback_data` `hp:`, at most 64 bytes); + - `/hackathon join ` as the fallback for old analyses that have no button. +- Admins only. A non-admin tap gets a callback alert ("Only an admin can confirm participation"). The team is resolved from the chat id. +- Topic name `🏆 `: + - built from `fields.name.value`, falling back to the slug; + - the name is untrusted page text, so strip control characters and collapse whitespace; + - 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. +- On success: + - post "✅ Participating in → " in General; + - remove the button from the original message; + - post and pin the analysis in the topic through the existing link+pin flow. +- Missing `can_manage_topics`, or a chat that is not a forum, gets an explicit operator reply and persists nothing. +- The General analysis post keeps its message id so the button can be attached and later removed. +- Replies are in English and plain text. + +### Out of Scope +- Natural-language detection. It comes in a later change that must be confirm-first: the LLM proposes and this button confirms. +- Key-date reminders. +- A `/linkrepo` suggestion. +- Several hackathons per topic. +- Any LLM or neuron use. + +## Capabilities + +### New Capabilities +- `hackathon-participation`: triggers, admin gate, topic naming and sanitizing, idempotency and stale recreation, post-confirm effects, rights failures, redelivery safety. + +### Modified Capabilities +- `hackathon-analysis`: + - the General post carries the button; + - the argument rule accepts `join `; + - `/hackathons` output is unchanged. +- `telegram-webhook`: routes `hp:` callback queries in groups, beyond command-only routing. + +`repo-topic-links` is not changed. + +## Approach + +This follows the exploration's recommendation, D+A. +- A new ISP port, `ForumTopicManager`, provides create and probe, with rejections classified. +- `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. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `src/domain/ports.ts`, `src/domain/errors.ts` | Modified | Port and errors | +| `src/domain/usecases/participate-in-hackathon.ts` | New | Use case | +| `src/domain/usecases/run-hackathon-job.ts` | Modified | General post with the button | +| `src/adapters/telegram/{chat-publisher,hackathon-commands,context}.ts` | Modified | Adapter, join, callback | +| `src/composition.ts` | Modified | Wiring | +| `migrations/0004_*.sql` | Maybe | Claim column (design decides) | + +Fetch, LLM and validation are untouched, so `npm run harness` is not required. + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| 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 | + +## Rollback Plan + +Redeploy the previous Worker. Old buttons then do nothing, and topics that were created stay in place. Any migration only adds a column. + +## Dependencies + +- The bot is a group admin with "Manage Topics" and "Pin Messages" (operator step). +- Change `spanish-copy` lands first. All user-facing bot text is Spanish (neutral/professional), so this change's button ("✅ Participamos"), alerts, confirmations and error replies are written in Spanish from the start. Extracted analysis field values stay in the page's language. + +## Success Criteria + +- [ ] An admin tap creates one topic, links and pins the analysis, posts the confirmation, and removes the button +- [ ] A non-admin gets the alert and nothing changes +- [ ] A live topic gets a link reply; a deleted topic is recreated +- [ ] A webhook redelivery never creates a second topic +- [ ] Missing rights or a non-forum chat persist nothing +- [ ] The domain has no grammY imports + +## Delivery + +About 350–500 lines, delivered as 2 chained PRs: +1. port + adapter + use case + `/hackathon join`; +2. button + callback + consumer wiring. + +## Open questions (for design) + +1. A claim migration versus accepting the concurrent-tap race. +2. The mechanism for the stale-topic probe. +3. How the button is removed after a `/hackathon join`, when the General message id is unknown. + +## Proposal question round + +All product decisions were resolved with the user. None are open. diff --git a/openspec/changes/hackathon-participation/specs/hackathon-analysis/spec.md b/openspec/changes/hackathon-participation/specs/hackathon-analysis/spec.md new file mode 100644 index 0000000..1b99f62 --- /dev/null +++ b/openspec/changes/hackathon-participation/specs/hackathon-analysis/spec.md @@ -0,0 +1,55 @@ +# Delta for Hackathon Analysis + +The General analysis post carries the participation button, and the argument rule accepts `join `. See `hackathon-participation` for what the button does. + +## MODIFIED Requirements + +### Requirement: Admin-Only Fresh Analysis, Capped + +The system MUST allow only a team admin to run `/hackathon `, in general chat or inside a topic. The command reserves the cap slot and lease, enqueues an analysis job, and immediately acknowledges the request; the page fetch and LLM extraction run asynchronously on a queue consumer. Each such run MUST count against the team's daily cap. The analysis posted in general chat MUST carry an inline "✅ Participamos" button (callback data `hp:`); the message id of that post MUST be retained so the button can be removed later. Analyses posted inside a topic carry no button. + +#### Scenario: Admin runs a fresh analysis in general chat + +- GIVEN the caller is a team admin and today's run count is below the cap +- WHEN they run `/hackathon ` in the group's general chat +- THEN the system immediately replies "Analizando … el resultado se publicará aquí." and returns +- AND the queue consumer later fetches the page, extracts fields, and stores the analysis under a new slug +- AND posts the analysis and its slug as a separate message, unpinned, with the "✅ Participamos" button + +#### Scenario: Analysis run inside a topic has no button + +- GIVEN the caller is a team admin +- WHEN they run `/hackathon ` inside a forum topic +- THEN the analysis posted in that topic carries no participation button + +#### Scenario: Non-admin attempts a fresh analysis + +- GIVEN the caller is not a team admin +- WHEN they run `/hackathon ` +- THEN the system MUST refuse with "Solo un administrador del equipo puede analizar o vincular un hackathon." +- AND MUST NOT fetch the page, call the LLM, or count against the cap + +### Requirement: Argument Classified as Slug or URL + +The system MUST classify a bare `/hackathon` argument as a slug when it matches `^[a-z0-9]+(-[a-z0-9]+)*$` and contains neither `.` nor `:`, and as a URL otherwise. The two-token form `join ` MUST be accepted as the participation command and MUST NOT be classified as a slug or URL. A bare `join` with no slug MUST be answered with the join usage line rather than a slug lookup. + +#### Scenario: Slug-shaped argument + +- WHEN `/hackathon meridian-2` is run +- THEN the system treats `meridian-2` as a slug lookup, not a URL fetch + +#### Scenario: URL-shaped argument + +- WHEN `/hackathon https://example.com/event` is run +- THEN the system treats it as a URL for fresh analysis + +#### Scenario: Join form + +- WHEN `/hackathon join meridian-2` is run +- THEN the system treats it as a participation confirmation for `meridian-2` +- AND does not fetch a page or count against the cap + +#### Scenario: Bare join shows usage + +- WHEN `/hackathon join` is run +- THEN the system replies "Uso: /hackathon join " diff --git a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md new file mode 100644 index 0000000..7c76e81 --- /dev/null +++ b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md @@ -0,0 +1,189 @@ +# Hackathon Participation Specification + +## Purpose + +Lets a team admin confirm, in one action, that the team participates in an analyzed hackathon: the bot creates a forum topic, links and pins the analysis there, and announces it in General. Bot copy is Spanish per `bot-copy`; extracted values stay verbatim. + +## Requirements + +### Requirement: Two Triggers, One Behavior + +The system MUST let an admin confirm participation by tapping the "✅ Participamos" inline button (callback data `hp:`, at most 64 bytes) or by running `/hackathon join `. Both triggers MUST produce identical effects. The team MUST be resolved from the chat id, never from the payload. + +#### Scenario: Admin taps the button + +- GIVEN an analysis `meridian` without a topic and the caller is a team admin +- WHEN they tap "✅ Participamos" (`hp:meridian`) +- THEN a topic is created, linked and pinned as specified below + +#### Scenario: Admin uses the command for an old analysis + +- GIVEN an analysis `meridian` posted before buttons existed +- WHEN an admin runs `/hackathon join meridian` +- THEN the outcome is identical to tapping the button +- AND the removal of the original message's button is best-effort (the message may be unknown) + +#### Scenario: Unknown slug + +- GIVEN no analysis `nope` exists for the team +- WHEN an admin runs `/hackathon join nope` +- THEN the system replies "No se encontró ningún análisis con el slug nope." and creates nothing + +#### Scenario: Join without slug + +- WHEN an admin runs `/hackathon join` +- THEN the system replies "Uso: /hackathon join " + +### Requirement: Admins Only + +The system MUST allow only a team admin to confirm participation and MUST change nothing for anyone else. + +#### Scenario: Non-admin taps the button + +- GIVEN the caller is not a team admin +- WHEN they tap "✅ Participamos" +- THEN a callback alert reads "Solo un administrador del equipo puede confirmar la participación." +- AND no topic is created, nothing is persisted and the button stays + +#### Scenario: Non-admin runs the command + +- GIVEN the caller is not a team admin +- WHEN they run `/hackathon join ` +- THEN the system replies "Solo un administrador del equipo puede confirmar la participación." and changes nothing + +### Requirement: Topic Creation and Naming + +The system MUST create the topic named `🏆 `, where `` is the extracted `fields.name.value`, or the slug when there is no name. The name is untrusted: control characters MUST be removed, whitespace collapsed, and the full topic name MUST be 1–128 characters. The name is otherwise verbatim. + +#### Scenario: Name from the page + +- GIVEN `fields.name.value` is "Meridian Hack\n2026" +- WHEN participation is confirmed +- THEN the topic is named "🏆 Meridian Hack 2026" + +#### Scenario: Missing or empty name + +- GIVEN `fields.name.value` is absent or empty after sanitizing +- WHEN participation is confirmed +- THEN the topic is named "🏆 " + +#### Scenario: Overlong name + +- GIVEN the sanitized name would exceed the limit +- WHEN participation is confirmed +- THEN the topic name is truncated to at most 128 characters + +### Requirement: Live Topic Is Idempotent + +The system MUST NOT create a topic when the analysis is linked to a live topic. It MUST reply with the topic link. + +#### Scenario: Second confirmation + +- GIVEN `meridian` is linked to a live topic +- WHEN an admin confirms participation again +- THEN the system replies "Este hackathon ya tiene tema: " +- AND creates nothing + +### 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. + +#### Scenario: Linked topic was deleted + +- GIVEN `meridian` is linked to a topic deleted in Telegram +- WHEN an admin confirms participation +- THEN the stale link is dropped and a new topic is created, linked and pinned +- AND General shows the confirmation with the new 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. + +#### Scenario: Webhook redelivery + +- GIVEN a confirmation already created and linked a topic +- WHEN Telegram redelivers the same update +- THEN no second topic is created + +#### Scenario: Concurrent taps + +- GIVEN two admins tap the button for the same analysis at the same time +- WHEN both updates are processed +- THEN exactly one topic exists for the analysis +- AND the other tap receives "Este hackathon ya tiene tema: " or a neutral no-op + +### Requirement: Confirmation, Pin and Button Removal + +After creating a topic the system MUST post "✅ Participamos en → " in General, remove the button from the analysis message, and post and pin the analysis in the new topic. `` is the sanitized hackathon name. Removing the button MUST be best-effort. + +#### Scenario: Successful confirmation + +- GIVEN an admin taps the button +- WHEN the topic is created +- THEN General receives "✅ Participamos en Meridian Hack 2026 → " +- AND the button is removed from the analysis message +- AND the analysis is posted and pinned in the topic + +#### Scenario: Pin failure + +- GIVEN the bot lacks "Pin Messages" +- WHEN participation is confirmed +- THEN the analysis is posted unpinned and the link persists +- AND the reply states "No se pudo fijar el mensaje; se publicó sin fijar." + +### Requirement: Missing Rights or Non-Forum Chat + +The system MUST reply with an operator-facing message and MUST persist nothing when the bot cannot create topics or the chat is not a forum. + +#### Scenario: Bot lacks Manage Topics + +- GIVEN the bot does not have `can_manage_topics` +- WHEN an admin confirms participation +- THEN the system replies "No puedo crear temas: concede al bot el permiso «Administrar temas» y vuelve a intentarlo." +- AND no link is stored + +#### Scenario: Chat is not a forum + +- GIVEN the group has topics disabled +- WHEN an admin confirms participation +- THEN the system replies "Este grupo no tiene los temas activados. Actívalos en la configuración del grupo y vuelve a intentarlo." +- AND no link is stored + +#### Scenario: Telegram rejects topic creation + +- GIVEN Telegram refuses to create the topic (rate limit or another rejection) +- WHEN an admin confirms participation +- THEN the system replies "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto." +- AND no link is stored and the creation claim is released + +#### Scenario: Topic creation outcome is uncertain + +- GIVEN Telegram times out or fails with a server error while creating the topic, so the topic may exist +- WHEN an admin confirms participation +- THEN the system replies "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo." +- AND no link is stored +- AND the creation claim is kept until it expires, so an immediate retry cannot create a duplicate topic + +### Requirement: Partial Failure After Topic Creation + +The system MUST keep the created topic and its link when a later step fails, MUST NOT fail the update, and MUST reply with a recovery hint. + +#### Scenario: Posting the analysis in the topic fails + +- GIVEN the topic was created and linked +- WHEN posting the analysis in it fails +- THEN the link persists +- AND the system replies "Se creó el tema y se vinculó , pero no se pudo publicar el análisis. Ejecuta /hackathon dentro del tema: " + +#### Scenario: Linking the topic fails + +- GIVEN the topic was created +- WHEN storing the link to it fails +- THEN the topic remains and the update does not fail +- AND the system replies "Se creó el tema, pero no se pudo vincular . Ejecuta /hackathon dentro del tema: " + +#### Scenario: Confirmation or button removal fails + +- GIVEN the topic was created, linked and pinned +- WHEN the General confirmation or the button removal fails +- THEN the topic and link remain and no second topic is created diff --git a/openspec/changes/hackathon-participation/specs/telegram-webhook/spec.md b/openspec/changes/hackathon-participation/specs/telegram-webhook/spec.md new file mode 100644 index 0000000..ffecb05 --- /dev/null +++ b/openspec/changes/hackathon-participation/specs/telegram-webhook/spec.md @@ -0,0 +1,25 @@ +# Delta for Telegram Webhook + +## MODIFIED Requirements + +### Requirement: Command-Only Routing + +The system MUST route only recognized bot commands (e.g. `/setup`, `/join`, `/datachannel`) and callback queries carrying the `hp:` prefix (participation confirmation) to their handlers, under Telegram's default privacy mode where the bot receives commands, replies to its own messages, service messages, and callback queries. Callback data with any other unrecognized prefix MUST be ignored. + +#### Scenario: Recognized command routed + +- GIVEN a validated webhook update +- WHEN the update is a message starting with a recognized command +- THEN the system dispatches it to the matching handler + +#### Scenario: Participation callback routed + +- GIVEN a validated webhook update +- WHEN the update is a callback query whose data starts with `hp:` from a group chat +- THEN the system dispatches it to the participation handler + +#### Scenario: Unrecognized update ignored + +- GIVEN a validated webhook update +- WHEN the update is not a recognized command, not a recognized callback, and not a reply to the bot +- THEN the system MUST ignore it without error and MUST NOT invoke any handler diff --git a/openspec/changes/hackathon-participation/tasks.md b/openspec/changes/hackathon-participation/tasks.md new file mode 100644 index 0000000..9a87eb3 --- /dev/null +++ b/openspec/changes/hackathon-participation/tasks.md @@ -0,0 +1,85 @@ +# Tasks: Confirm Hackathon Participation and Create Its Topic + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~1200 (PR1a ~350, PR1b ~450, PR2 ~400) | +| 400-line budget risk | High (aggregate); PR1a Low, PR1b/PR2 Medium (likely `size:exception` if over 400) | +| Chained PRs recommended | Yes | +| Suggested split | PR1a infrastructure → PR1b use case + join → PR2 button + callback | +| Delivery strategy | 3 chained PRs (decided; split recommended by design) | +| Chain strategy | stacked-to-main (each PR targets `main` after the previous merges) | + +Decision needed before apply: No +Chained PRs recommended: Yes +Chain strategy: stacked-to-main +400-line budget risk: High + +Mark PR1b and PR2 as likely `size:exception` if they exceed 400 lines. +No `npm run harness` needed: no fetch, LLM or validation path is touched. +Each PR is independently deployable and keeps `npm test` green. PR1a adds unused-but-tested infrastructure; migration 0004 is additive. + +### Suggested Work Units + +| Unit | Goal | Likely PR | Focused test command | Runtime harness | Rollback boundary | +|------|------|-----------|----------------------|-----------------|-------------------| +| 1 | Migration 0004, D1 claim/release/message-id, `ForumTopicManager` + adapter, `clearButtons`, fakes | PR1a (~350) | `npm test -- test/adapters` | N/A: infrastructure unused until PR1b | new files + additive columns (ignored on revert) | +| 2 | Pure helpers, `participateInHackathon`, `/hackathon join`, copy | PR1b (~450) | `npm test -- test/domain test/adapters/telegram/commands.test.ts` | N/A: no fetch/LLM path | `participate-in-hackathon.ts`, `participation.ts`, join branch | +| 3 | General-post button, `hp:` callback, routing, wiring | PR2 (~400) | `npm test -- test/adapters/telegram test/domain/usecases/run-hackathon-job.test.ts test/http` | N/A: no fetch/LLM path | `postToGeneral`, `hp:` handler, `callbackCallerLocation` | + +## Phase 1: Infrastructure (PR1a) + +- [x] 1.1 RED: `test/adapters/migrations.test.ts` — 0004 adds `general_message_id` (nullable) and `topic_claim_until` (NOT NULL DEFAULT 0); existing rows keep defaults. +- [x] 1.2 GREEN: create `migrations/0004_hackathon_participation.sql` (two additive `ADD COLUMN`). +- [x] 1.3 RED: `test/adapters/d1/hackathon-analysis-repo.test.ts` — **concurrent taps (the claim)**: `claimTopicCreation` CAS wins on `thread_id IS NULL`, wins on `IS stale`, loses when another claim is live, wins after TTL expiry; two claims race and exactly one wins. +- [x] 1.4 RED: same file — `releaseTopicClaim` reopens the claim; `setGeneralMessageId` stores the id; `save`/`persistAnalysis` never clobber `general_message_id`/`topic_claim_until`; rows without an id read `generalMessageId: null`. +- [x] 1.5 GREEN: `src/domain/entities.ts` (`generalMessageId`), `src/domain/ports.ts` (`HackathonAnalysisRepo` additions), `src/adapters/d1/hackathon-analysis-repo.ts` (CAS `UPDATE`, release, set id, row mapping). +- [x] 1.6 RED: create `test/adapters/telegram/forum-topic-manager.test.ts` (injected `Api` stub) — `create` classification: `not enough rights`/`chat_admin_required` → `no-rights`; `not a forum`/`channel_forum_missing` → `not-forum`; 429 → `rate-limited`; other 4xx → `rejected`; `HttpError`/5xx/timeout → `unavailable`; returns `message_thread_id` on success. +- [x] 1.7 RED: same file — **deleted topic detected**: `probe` via `sendChatAction("typing", {message_thread_id})`: ok → `live`; 400 `message thread not found`/`TOPIC_ID_INVALID`/`TOPIC_DELETED` → `deleted`. **Ambiguous probe error treated as live**: other 400, 403, 429, 5xx, timeout → `unknown`; never throws. +- [x] 1.8 GREEN: `src/domain/ports.ts` (`ForumTopicManager`, `TopicProbe`, `TopicCreateFailure`, `PostOptions`), `src/domain/errors.ts` (`ForumTopicCreateError`), create `src/adapters/telegram/forum-topic-manager.ts`. +- [x] 1.9 RED: `test/adapters/telegram/chat-publisher.test.ts` — `clearButtons` calls `editMessageReplyMarkup` with no `reply_markup`; `post` accepts optional `{ participateSlug }` without altering the no-option payload; when set, the keyboard carries `hp:` (≤ 64 bytes) and label "✅ Participamos". +- [x] 1.10 GREEN: `src/adapters/telegram/chat-publisher.ts` (`clearButtons`, `PostOptions` keyboard); `src/adapters/telegram/copy.ts` (`participateButton`). +- [x] 1.11 GREEN: `test/fakes/index.ts` — `FakeForumTopicManager` (scripted outcomes, call log), fake repo claim/release/message-id, fake publisher `clearButtons` and options log. +- [x] 1.12 Run `npm test` and `npm run typecheck`; green with infrastructure 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. + +## Phase 3: Button, Callback, Consumer (PR2) + +- [ ] 3.1 RED: `test/domain/usecases/run-hackathon-job.test.ts` — General post is sent with `participateSlug` and `setGeneralMessageId` stores the returned id at both General sites; a topic post has no button; a `setGeneralMessageId` failure is logged, the job still acks, and there is no retry/repost. +- [ ] 3.2 GREEN: `src/domain/usecases/run-hackathon-job.ts` — `postToGeneral(text, participateSlug)` helper used by both General sites; best-effort id store. +- [ ] 3.3 RED: `test/adapters/telegram/commands.test.ts` — `callbackCallerLocation(ctx)` reads `ctx.chat.id`, `ctx.from.id`, `ctx.msg?.message_thread_id`, `ctx.callbackQuery.message?.message_id`; `callerLocation` behavior for commands unchanged. +- [ ] 3.4 GREEN: `src/adapters/telegram/context.ts` (`callbackCallerLocation`). +- [ ] 3.5 RED: `test/adapters/telegram/participation.test.ts` — **non-admin alert**: `hp:` from a non-admin (and non-member) → `answerCallbackQuery` with `show_alert` "Solo un administrador del equipo puede confirmar la participación.", nothing created, button stays; private/missing chat ignored; team taken from chat id, never the payload; malformed data (`hp:Bad_Slug`, over-long) ignored; admin tap creates the topic. +- [ ] 3.6 RED: same file — **button removed after confirmation**: admin tap → `clearButtons` for the callback message id and the stored `generalMessageId` (deduped); button-clear failure ignored; callback answered early, best-effort. +- [ ] 3.7 GREEN: `src/adapters/telegram/participation.ts` (`bot.callbackQuery(/^hp:(slug)$/)` handler, registered from `registerHackathonCommands`); `src/adapters/telegram/hackathon-commands.ts`. +- [ ] 3.8 RED: `test/http/webhook-e2e.test.ts` and `test/http/hackathon-command-e2e.test.ts` — validated webhook `hp:` callback from a group is routed to the participation handler; callback with any other prefix is ignored without error; `/hackathon join` clears the stored message's button (old analysis with null id: join works, nothing removed). +- [ ] 3.9 GREEN: `src/composition.ts` wiring (consumer uses `postToGeneral`, handler gets `ForumTopicManager`); `test/fakes/index.ts` and `test/support/telegram-stub.ts` callback fixtures/`answerCallbackQuery`/`editMessageReplyMarkup` recording. +- [ ] 3.10 Run `npm test` and `npm run typecheck`. + +## Phase 4: Operator Step and Final Verification (after PR2) + +- [ ] 4.1 Operator: apply migration 0004 remotely, deploy, then grant the bot "Manage Topics" (Administrar temas) in the group. +- [ ] 4.2 Operator smoke test in Telegram: tap "✅ Participamos" (topic created, pinned, General confirmation, button removed); delete the topic and tap again on a fresh analysis or `join` (recreated, validates probe strings); check the `t.me/c/...` link opens the topic; run `/hackathon join ` on an old analysis. +- [ ] 4.3 Run the full suite (`npm test`) and typecheck (`npm run typecheck`), both green. diff --git a/src/adapters/d1/hackathon-analysis-repo.ts b/src/adapters/d1/hackathon-analysis-repo.ts index 03c8262..4dd51fb 100644 --- a/src/adapters/d1/hackathon-analysis-repo.ts +++ b/src/adapters/d1/hackathon-analysis-repo.ts @@ -15,6 +15,7 @@ interface AnalysisRow { suggested_repos: string; thread_id: number | null; pinned_message_id: number | null; + general_message_id: number | null; created_at: number; updated_at: number; } @@ -30,6 +31,7 @@ function rowToAnalysis(row: AnalysisRow): HackathonAnalysis { suggestedRepos: JSON.parse(row.suggested_repos) as RepoFullName[], threadId: row.thread_id, pinnedMessageId: row.pinned_message_id, + generalMessageId: row.general_message_id, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -157,5 +159,48 @@ export function createD1HackathonAnalysisRepo(db: D1Database): HackathonAnalysis .bind(threadId, pinnedMessageId, teamId, analysisId), ]); }, + + // hackathon-participation: single conditional UPDATE, so of two racing + // claims exactly one changes a row. `thread_id IS ?` matches null as + // well as a concrete stale id. + async claimTopicCreation( + teamId: TeamId, + analysisId: string, + expectedThreadId: number | null, + now: number, + ttlMs: number, + ): Promise { + const result = await db + .prepare( + `UPDATE hackathon_analyses + SET topic_claim_until = ? + WHERE team_id = ? AND id = ? AND thread_id IS ? AND topic_claim_until <= ?`, + ) + .bind(now + ttlMs, teamId, analysisId, expectedThreadId, now) + .run(); + return result.meta.changes > 0; + }, + + async releaseTopicClaim(teamId: TeamId, analysisId: string): Promise { + await db + .prepare( + "UPDATE hackathon_analyses SET topic_claim_until = 0 WHERE team_id = ? AND id = ?", + ) + .bind(teamId, analysisId) + .run(); + }, + + async setGeneralMessageId( + teamId: TeamId, + analysisId: string, + messageId: number, + ): Promise { + await db + .prepare( + "UPDATE hackathon_analyses SET general_message_id = ? WHERE team_id = ? AND id = ?", + ) + .bind(messageId, teamId, analysisId) + .run(); + }, }; } diff --git a/src/adapters/telegram/chat-publisher.ts b/src/adapters/telegram/chat-publisher.ts index 3de59f6..0a5c25d 100644 --- a/src/adapters/telegram/chat-publisher.ts +++ b/src/adapters/telegram/chat-publisher.ts @@ -1,8 +1,14 @@ 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"; +// 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 +// encoded: it is derived from the chat when the callback arrives. +export const PARTICIPATE_CALLBACK_PREFIX = "hp:"; + // design.md "Time budget": Telegram publish 10 s. grammY has no default // timeout, so each call carries its own abort signal; a timeout surfaces as // an HttpError and is classified `telegram-unavailable` (transient). @@ -24,7 +30,7 @@ const publishSignal = (): GrammySignal => // over. Mirrors `createTelegramAlertSender`. export function createTelegramChatPublisher(api: Api): ChatPublisher { return { - async post(chatId, threadId, text) { + async post(chatId, threadId, text, options) { try { const sent = await api.sendMessage( chatId, @@ -34,6 +40,20 @@ export function createTelegramChatPublisher(api: Api): ChatPublisher { // than sent as null, which Telegram would reject. ...(threadId !== null ? { message_thread_id: threadId } : {}), link_preview_options: { is_disabled: true }, + ...(options?.participateSlug !== undefined + ? { + reply_markup: { + inline_keyboard: [ + [ + { + text: participateButton, + callback_data: `${PARTICIPATE_CALLBACK_PREFIX}${options.participateSlug}`, + }, + ], + ], + }, + } + : {}), }, publishSignal(), ); @@ -56,6 +76,15 @@ export function createTelegramChatPublisher(api: Api): ChatPublisher { } }, + // Omitting `reply_markup` from editMessageReplyMarkup removes the keyboard. + async clearButtons(chatId, messageId) { + try { + await api.editMessageReplyMarkup(chatId, messageId, undefined, publishSignal()); + } catch (err) { + throw new PublishFailedError("editMessageReplyMarkup failed", classifyTelegramFailure(err)); + } + }, + async unpin(chatId, messageId) { try { await api.unpinChatMessage(chatId, messageId, undefined, publishSignal()); diff --git a/src/adapters/telegram/copy.ts b/src/adapters/telegram/copy.ts index 84d7a1b..b02bf9a 100644 --- a/src/adapters/telegram/copy.ts +++ b/src/adapters/telegram/copy.ts @@ -13,6 +13,9 @@ export const commonCopy = { linkedHere: (name: string) => `Se vinculó ${name} a este tema.`, }; +// Label of the participation button attached to the General analysis post. +export const participateButton = "✅ Participamos"; + export const hackathonCopy = { usage: "Uso: /hackathon ", groupOnly: "Ejecuta este comando dentro del chat grupal de tu equipo.", diff --git a/src/adapters/telegram/forum-topic-manager.ts b/src/adapters/telegram/forum-topic-manager.ts new file mode 100644 index 0000000..c666d82 --- /dev/null +++ b/src/adapters/telegram/forum-topic-manager.ts @@ -0,0 +1,63 @@ +import { GrammyError } from "grammy"; +import type { Api } from "grammy"; +import { ForumTopicCreateError } from "../../domain/errors"; +import type { ForumTopicManager, TopicCreateFailure, TopicProbe } 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). +const TOPIC_TIMEOUT_MS = 10_000; + +// grammY types its `signal` parameter with the abort-controller shim's +// AbortSignal; both are the same object at runtime (see chat-publisher.ts). +type GrammySignal = Parameters[0]; +const topicSignal = (): GrammySignal => + AbortSignal.timeout(TOPIC_TIMEOUT_MS) as unknown as 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"; + if (NO_RIGHTS.test(err.description)) return "no-rights"; + if (NOT_FORUM.test(err.description)) return "not-forum"; + if (err.error_code === 429) return "rate-limited"; + if (err.error_code >= 500) return "unavailable"; + 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) { + try { + const topic = await api.createForumTopic(chatId, name, undefined, topicSignal()); + return topic.message_thread_id; + } catch (err) { + // Fixed message: Telegram's description is never carried over. + 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/entities.ts b/src/domain/entities.ts index 000f3d0..7687dc1 100644 --- a/src/domain/entities.ts +++ b/src/domain/entities.ts @@ -113,6 +113,10 @@ export interface HackathonAnalysis { // whenever `threadId` is null, or when it is set but the pin attempt // failed (link-analysis-to-topic.ts, PR4). pinnedMessageId: number | null; + // Message id of the General analysis post carrying the participation + // button (hackathon-participation), so it can be removed later. Null for + // analyses posted before it was tracked or when storing it failed. + generalMessageId: number | null; createdAt: number; updatedAt: number; } diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 289047d..9c4b777 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -5,6 +5,7 @@ // forking two classes with the same name (design.md "Config errors"). export { ConfigError } from "../config-error"; import type { FieldRejection } from "./hackathon/extraction"; +import type { TopicCreateFailure } from "./ports"; export class DomainError extends Error { constructor(message: string) { @@ -211,3 +212,16 @@ export class PublishFailedError extends DomainError { this.failureClass = failureClass; } } + +// `ForumTopicManager.create` failed (hackathon-participation design.md +// "Interfaces / Contracts"). `failure` is a fixed classification; Telegram's +// description is never carried over. The use case maps it to a user-facing +// domain error and decides whether to keep or release the claim. +export class ForumTopicCreateError extends DomainError { + readonly failure: TopicCreateFailure; + + constructor(message: string, failure: TopicCreateFailure) { + super(message); + this.failure = failure; + } +} diff --git a/src/domain/ports.ts b/src/domain/ports.ts index abef6cf..09ef513 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -267,6 +267,20 @@ export interface HackathonAnalysisRepo { threadId: number, pinnedMessageId: number | null, ): 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 + // (`topic_claim_until < now`); a win stamps `now + ttlMs`. Returns false + // when the claim is lost. + claimTopicCreation( + teamId: TeamId, + analysisId: string, + expectedThreadId: number | null, + now: number, + ttlMs: number, + ): Promise; + releaseTopicClaim(teamId: TeamId, analysisId: string): Promise; + setGeneralMessageId(teamId: TeamId, analysisId: string, messageId: number): Promise; } // design.md "reserve": one atomic batch reserves the cap slot and the @@ -326,8 +340,41 @@ export interface RepoMetadataSource { // 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. +export type TopicProbe = "live" | "deleted" | "unknown"; +export type TopicCreateFailure = + | "no-rights" + | "not-forum" + | "rate-limited" + | "rejected" + | "unavailable"; + +// ISP port: only the use case that creates topics sees topic rights. +// `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 +// domain never builds a keyboard. `participateSlug` asks the adapter to +// attach the participation button for that analysis; the label and the +// callback encoding stay in the adapter. +export interface PostOptions { + participateSlug?: string; +} + export interface ChatPublisher { - post(chatId: number, threadId: number | null, text: string): Promise; + post( + chatId: number, + threadId: number | null, + text: string, + options?: PostOptions, + ): Promise; pin(chatId: number, messageId: number): Promise; unpin(chatId: number, messageId: number): Promise; + // Removes the inline keyboard from a message (throws PublishFailedError). + clearButtons(chatId: number, messageId: number): Promise; } diff --git a/src/domain/usecases/analyze-hackathon.ts b/src/domain/usecases/analyze-hackathon.ts index 82dff0c..cc88150 100644 --- a/src/domain/usecases/analyze-hackathon.ts +++ b/src/domain/usecases/analyze-hackathon.ts @@ -126,6 +126,7 @@ export async function analyzeHackathon( suggestedRepos, threadId: existing?.threadId ?? null, pinnedMessageId: existing?.pinnedMessageId ?? null, + generalMessageId: existing?.generalMessageId ?? null, createdAt: existing?.createdAt ?? now, updatedAt: now, }; diff --git a/test/adapters/d1/analysis-job-repo.test.ts b/test/adapters/d1/analysis-job-repo.test.ts index b3790e1..b860f83 100644 --- a/test/adapters/d1/analysis-job-repo.test.ts +++ b/test/adapters/d1/analysis-job-repo.test.ts @@ -170,6 +170,7 @@ describe("createD1AnalysisJobRepo", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }; diff --git a/test/adapters/d1/hackathon-analysis-repo.test.ts b/test/adapters/d1/hackathon-analysis-repo.test.ts index e183e6b..98d856a 100644 --- a/test/adapters/d1/hackathon-analysis-repo.test.ts +++ b/test/adapters/d1/hackathon-analysis-repo.test.ts @@ -1,5 +1,6 @@ import { env } from "cloudflare:test"; import { describe, expect, it } from "vitest"; +import { createD1AnalysisJobRepo } from "../../../src/adapters/d1/analysis-job-repo"; import { createD1HackathonAnalysisRepo } from "../../../src/adapters/d1/hackathon-analysis-repo"; import type { ExtractedFields } from "../../../src/domain/hackathon/extraction"; import type { HackathonAnalysis } from "../../../src/domain/entities"; @@ -42,6 +43,7 @@ function analysis( suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, ...rest, @@ -293,6 +295,157 @@ describe("createD1HackathonAnalysisRepo", () => { }); }); + describe("claimTopicCreation / releaseTopicClaim (participation claim, CAS)", () => { + const TTL = 60_000; + + async function seedAnalysis( + teamId: string, + chatId: number, + threadId: number | null = null, + ) { + await seedTeam(teamId, chatId); + const repo = createD1HackathonAnalysisRepo(env.DB); + await repo.save(analysis({ id: `a-${teamId}`, teamId, slug: "claim", threadId })); + return repo; + } + + it("wins when the analysis has no topic (expected null) and stamps the claim", async () => { + const repo = await seedAnalysis("team-claim-null", 920); + + const won = await repo.claimTopicCreation(asTeamId("team-claim-null"), "a-team-claim-null", null, 1_000, TTL); + + const row = await env.DB.prepare("SELECT topic_claim_until FROM hackathon_analyses WHERE id = ?") + .bind("a-team-claim-null") + .first<{ topic_claim_until: number }>(); + expect(won).toBe(true); + expect(row?.topic_claim_until).toBe(1_000 + TTL); + }); + + it("wins on the observed stale thread id (expected = stale)", async () => { + const repo = await seedAnalysis("team-claim-stale", 921, 77); + + const won = await repo.claimTopicCreation(asTeamId("team-claim-stale"), "a-team-claim-stale", 77, 1_000, TTL); + + expect(won).toBe(true); + }); + + it("loses when the observed thread id no longer matches (another tap already linked a topic)", async () => { + const repo = await seedAnalysis("team-claim-moved", 922, 88); + + expect(await repo.claimTopicCreation(asTeamId("team-claim-moved"), "a-team-claim-moved", null, 1_000, TTL)).toBe(false); + expect(await repo.claimTopicCreation(asTeamId("team-claim-moved"), "a-team-claim-moved", 77, 1_000, TTL)).toBe(false); + }); + + it("loses while another claim is live, and wins once the TTL has expired", async () => { + const repo = await seedAnalysis("team-claim-ttl", 923); + const team = asTeamId("team-claim-ttl"); + expect(await repo.claimTopicCreation(team, "a-team-claim-ttl", null, 1_000, TTL)).toBe(true); + + expect(await repo.claimTopicCreation(team, "a-team-claim-ttl", null, 1_000 + TTL - 1, TTL)).toBe(false); + expect(await repo.claimTopicCreation(team, "a-team-claim-ttl", null, 1_000 + TTL, TTL)).toBe(true); + }); + + it("lets exactly one of two racing claims win", async () => { + const repo = await seedAnalysis("team-claim-race", 924); + const team = asTeamId("team-claim-race"); + + const results = await Promise.all([ + repo.claimTopicCreation(team, "a-team-claim-race", null, 5_000, TTL), + repo.claimTopicCreation(team, "a-team-claim-race", null, 5_000, TTL), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + }); + + it("is tenant-scoped: another team's claim call never wins on the row", async () => { + const repo = await seedAnalysis("team-claim-owner", 925); + await seedTeam("team-claim-intruder", 926); + + expect(await repo.claimTopicCreation(asTeamId("team-claim-intruder"), "a-team-claim-intruder", null, 1_000, TTL)).toBe(false); + expect(await repo.claimTopicCreation(asTeamId("team-claim-owner"), "a-team-claim-owner", null, 1_000, TTL)).toBe(true); + }); + + it("releaseTopicClaim reopens the claim immediately", async () => { + const repo = await seedAnalysis("team-claim-release", 927); + const team = asTeamId("team-claim-release"); + expect(await repo.claimTopicCreation(team, "a-team-claim-release", null, 1_000, TTL)).toBe(true); + expect(await repo.claimTopicCreation(team, "a-team-claim-release", null, 1_001, TTL)).toBe(false); + + await repo.releaseTopicClaim(team, "a-team-claim-release"); + + expect(await repo.claimTopicCreation(team, "a-team-claim-release", null, 1_002, TTL)).toBe(true); + }); + }); + + describe("general message id and column preservation", () => { + it("rows without an id read generalMessageId: null", async () => { + await seedTeam("team-gm-null", 930); + const repo = createD1HackathonAnalysisRepo(env.DB); + await repo.save(analysis({ id: "a-gm-null", teamId: "team-gm-null", slug: "gm-null" })); + + const found = await repo.findById(asTeamId("team-gm-null"), "a-gm-null"); + + expect(found?.generalMessageId).toBeNull(); + }); + + it("setGeneralMessageId stores the id, and a later call overwrites it", async () => { + await seedTeam("team-gm-set", 931); + const repo = createD1HackathonAnalysisRepo(env.DB); + await repo.save(analysis({ id: "a-gm-set", teamId: "team-gm-set", slug: "gm-set" })); + + await repo.setGeneralMessageId(asTeamId("team-gm-set"), "a-gm-set", 4242); + expect((await repo.findById(asTeamId("team-gm-set"), "a-gm-set"))?.generalMessageId).toBe(4242); + + await repo.setGeneralMessageId(asTeamId("team-gm-set"), "a-gm-set", 5151); + expect((await repo.findBySlug(asTeamId("team-gm-set"), "gm-set"))?.generalMessageId).toBe(5151); + }); + + it("save never clobbers general_message_id or topic_claim_until", async () => { + await seedTeam("team-gm-save", 932); + const repo = createD1HackathonAnalysisRepo(env.DB); + const original = analysis({ id: "a-gm-save", teamId: "team-gm-save", slug: "gm-save" }); + await repo.save(original); + await repo.setGeneralMessageId(asTeamId("team-gm-save"), "a-gm-save", 99); + await repo.claimTopicCreation(asTeamId("team-gm-save"), "a-gm-save", null, 1_000, 60_000); + + await repo.save({ ...original, updatedAt: 777, generalMessageId: null }); + + const row = await env.DB.prepare( + "SELECT general_message_id, topic_claim_until FROM hackathon_analyses WHERE id = ?", + ) + .bind("a-gm-save") + .first<{ general_message_id: number | null; topic_claim_until: number }>(); + expect(row).toEqual({ general_message_id: 99, topic_claim_until: 61_000 }); + }); + + it("persistAnalysis (job repo upsert) never clobbers general_message_id or topic_claim_until", async () => { + await seedTeam("team-gm-persist", 933); + const repo = createD1HackathonAnalysisRepo(env.DB); + const original = analysis({ id: "a-gm-persist", teamId: "team-gm-persist", slug: "gm-persist" }); + await repo.save(original); + await repo.setGeneralMessageId(asTeamId("team-gm-persist"), "a-gm-persist", 123); + await repo.claimTopicCreation(asTeamId("team-gm-persist"), "a-gm-persist", null, 2_000, 60_000); + await env.DB.prepare( + `INSERT INTO hackathon_analysis_jobs + (id, team_id, chat_id, utc_day, fetch_url, status, created_at, updated_at) + VALUES ('job-gm', 'team-gm-persist', 1, '2026-01-01', 'https://x', 'running', 0, 0)`, + ).run(); + + const ok = await createD1AnalysisJobRepo(env.DB, { now: () => 0 }).persistAnalysis("job-gm", { + ...original, + updatedAt: 888, + }); + + const row = await env.DB.prepare( + "SELECT general_message_id, topic_claim_until, updated_at FROM hackathon_analyses WHERE id = ?", + ) + .bind("a-gm-persist") + .first<{ general_message_id: number | null; topic_claim_until: number; updated_at: number }>(); + expect(ok).toBe(true); + expect(row).toEqual({ general_message_id: 123, topic_claim_until: 62_000, updated_at: 888 }); + }); + }); + it("propagates (rejects) when the D1 query fails, instead of swallowing the error", async () => { const err = new Error("D1_ERROR: simulated D1 outage"); const statement = { diff --git a/test/adapters/migrations.test.ts b/test/adapters/migrations.test.ts index 6888313..d59ac3c 100644 --- a/test/adapters/migrations.test.ts +++ b/test/adapters/migrations.test.ts @@ -423,3 +423,55 @@ describe("migrations/0003_hackathon_analysis.sql", () => { ).rejects.toThrow(/FOREIGN KEY constraint failed/i); }); }); + +// migrations/0004_hackathon_participation.sql (design.md "Migration / +// Rollout"): two additive columns on hackathon_analyses. +describe("migrations/0004_hackathon_participation.sql", () => { + interface ColumnInfo { + name: string; + type: string; + notnull: number; + dflt_value: string | null; + } + + async function column(name: string): Promise { + const info = await env.DB.prepare("PRAGMA table_info(hackathon_analyses)").all(); + return info.results.find((c) => c.name === name); + } + + it("adds general_message_id as a nullable INTEGER", async () => { + const col = await column("general_message_id"); + expect(col?.type).toBe("INTEGER"); + expect(col?.notnull).toBe(0); + }); + + it("adds topic_claim_until as INTEGER NOT NULL DEFAULT 0", async () => { + const col = await column("topic_claim_until"); + expect(col?.type).toBe("INTEGER"); + expect(col?.notnull).toBe(1); + expect(col?.dflt_value).toBe("0"); + }); + + it("gives a row inserted without the new columns the defaults (null id, claim 0)", async () => { + await env.DB.prepare( + "INSERT INTO teams (id, telegram_chat_id, created_at) VALUES (?, ?, ?)", + ) + .bind("team-hp-defaults", 720, 0) + .run(); + await env.DB.prepare( + `INSERT INTO hackathon_analyses + (id, team_id, slug, source_url, normalized_url, fields, suggested_repos, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind("a-hp-def", "team-hp-defaults", "def", "https://d", "https://d", "{}", "[]", 0, 0) + .run(); + + const row = await env.DB.prepare( + "SELECT general_message_id, topic_claim_until FROM hackathon_analyses WHERE id = ?", + ) + .bind("a-hp-def") + .first<{ general_message_id: number | null; topic_claim_until: number }>(); + + expect(row).toEqual({ general_message_id: null, topic_claim_until: 0 }); + }); +}); diff --git a/test/adapters/telegram/chat-publisher.test.ts b/test/adapters/telegram/chat-publisher.test.ts index 075078d..3d595c1 100644 --- a/test/adapters/telegram/chat-publisher.test.ts +++ b/test/adapters/telegram/chat-publisher.test.ts @@ -71,6 +71,88 @@ describe("createTelegramChatPublisher.post", () => { }); }); +describe("createTelegramChatPublisher.post options (participation button)", () => { + it("keeps the no-option payload free of any reply_markup", async () => { + const calls = stubTelegramApi(); + + await makePublisher().post(555, null, "hello"); + + expect(calls.find((c) => c.method === "sendMessage")?.body).not.toHaveProperty("reply_markup"); + }); + + it("does not add a keyboard when the options carry no participateSlug", async () => { + const calls = stubTelegramApi(); + + await makePublisher().post(555, null, "hello", {}); + + expect(calls.find((c) => c.method === "sendMessage")?.body).not.toHaveProperty("reply_markup"); + }); + + it("adds one button labelled \"✅ Participamos\" carrying hp: when participateSlug is set", async () => { + const calls = stubTelegramApi(); + + await makePublisher().post(555, null, "hello", { participateSlug: "meridian-hacks" }); + + const body = calls.find((c) => c.method === "sendMessage")?.body as { + reply_markup: { inline_keyboard: Array> }; + }; + expect(body).toMatchObject({ chat_id: 555, text: "hello" }); + expect(body.reply_markup.inline_keyboard).toEqual([ + [{ text: "✅ Participamos", callback_data: "hp:meridian-hacks" }], + ]); + }); + + it("keeps callback_data within Telegram's 64-byte limit for the longest slug", async () => { + const calls = stubTelegramApi(); + const slug = "a".repeat(40); + + await makePublisher().post(555, 42, "hello", { participateSlug: slug }); + + const body = calls.find((c) => c.method === "sendMessage")?.body as { + message_thread_id: number; + reply_markup: { inline_keyboard: Array> }; + }; + const data = body.reply_markup.inline_keyboard[0]?.[0]?.callback_data ?? ""; + expect(data).toBe(`hp:${slug}`); + expect(new TextEncoder().encode(data).length).toBeLessThanOrEqual(64); + expect(body.message_thread_id).toBe(42); + }); +}); + +describe("createTelegramChatPublisher.clearButtons", () => { + it("edits the message's reply markup without sending a reply_markup (removes the keyboard)", async () => { + const calls = stubTelegramApi(); + + await makePublisher().clearButtons(555, 901); + + const call = calls.find((c) => c.method === "editMessageReplyMarkup"); + expect(call?.body).toMatchObject({ chat_id: 555, message_id: 901 }); + expect(call?.body).not.toHaveProperty("reply_markup"); + }); + + it("becomes PublishFailedError on a Telegram rejection without leaking its description", async () => { + stubTelegramApi(() => ({ + ok: false, + error_code: 400, + description: "Bad Request: message to edit not found", + })); + + const err = await makePublisher().clearButtons(555, 901).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(PublishFailedError); + expect((err as PublishFailedError).failureClass).toBe("rejected"); + expect((err as PublishFailedError).message).not.toContain("message to edit not found"); + }); + + it("classifies a 5xx as telegram-unavailable", async () => { + stubTelegramApi(() => ({ ok: false, error_code: 502, description: "Bad Gateway" })); + + const err = await makePublisher().clearButtons(555, 901).catch((e: unknown) => e); + + expect((err as PublishFailedError).failureClass).toBe("telegram-unavailable"); + }); +}); + describe("createTelegramChatPublisher.pin / unpin", () => { it("pins the message without notifying the chat", async () => { const calls = stubTelegramApi(); diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index 92b0af6..e48106f 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -953,6 +953,7 @@ function storedAnalysis( suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, ...overrides, diff --git a/test/adapters/telegram/forum-topic-manager.test.ts b/test/adapters/telegram/forum-topic-manager.test.ts new file mode 100644 index 0000000..50a4ae8 --- /dev/null +++ b/test/adapters/telegram/forum-topic-manager.test.ts @@ -0,0 +1,158 @@ +import { Api } from "grammy"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ForumTopicCreateError } from "../../../src/domain/errors"; +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). + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeManager() { + return createTelegramForumTopicManager(new Api("000000000:TEST-TOKEN-NOT-REAL")); +} + +function fail(code: number, description: string) { + return { ok: false, error_code: code, description }; +} + +describe("createTelegramForumTopicManager.create", () => { + it("creates the topic and returns its message_thread_id", async () => { + const calls = stubTelegramApi((method) => + method === "createForumTopic" + ? { message_thread_id: 4321, name: "🏆 Meridian", icon_color: 7322096 } + : undefined, + ); + + const threadId = await makeManager().create(-1001234, "🏆 Meridian"); + + expect(threadId).toBe(4321); + expect(calls.find((c) => c.method === "createForumTopic")?.body).toMatchObject({ + chat_id: -1001234, + name: "🏆 Meridian", + }); + }); + + const failures: Array<[string, unknown, TopicCreateFailure]> = [ + ["not enough rights", fail(400, "Bad Request: not enough rights to create a topic"), "no-rights"], + ["CHAT_ADMIN_REQUIRED", fail(400, "Bad Request: CHAT_ADMIN_REQUIRED"), "no-rights"], + ["chat_admin_required (lowercase)", fail(400, "Bad Request: chat_admin_required"), "no-rights"], + ["the chat is not a forum", fail(400, "Bad Request: the chat is not a forum"), "not-forum"], + ["CHANNEL_FORUM_MISSING", fail(400, "Bad Request: CHANNEL_FORUM_MISSING"), "not-forum"], + ["a 429", fail(429, "Too Many Requests: retry after 5"), "rate-limited"], + ["another 400", fail(400, "Bad Request: TOPIC_NAME_INVALID"), "rejected"], + ["a 403", fail(403, "Forbidden: bot was kicked from the supergroup chat"), "rejected"], + ["a 502", fail(502, "Bad Gateway"), "unavailable"], + ["a 500", fail(500, "Internal Server Error"), "unavailable"], + ]; + + for (const [label, response, expected] of failures) { + it(`classifies ${label} as ${expected} without leaking Telegram's description`, async () => { + stubTelegramApi(() => response); + + const err = await makeManager().create(-1001234, "x").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ForumTopicCreateError); + expect((err as ForumTopicCreateError).failure).toBe(expected); + expect((err as ForumTopicCreateError).message).not.toContain( + (response as { description: string }).description, + ); + }); + } + + it("classifies a network failure (HttpError) as unavailable", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + + const err = await makeManager().create(-1001234, "x").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ForumTopicCreateError); + expect((err as ForumTopicCreateError).failure).toBe("unavailable"); + }); + + it("classifies an aborted call (timeout) as unavailable", async () => { + vi.stubGlobal("fetch", async () => { + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }); + + const err = await makeManager().create(-1001234, "x").catch((e: unknown) => e); + + expect((err as ForumTopicCreateError).failure).toBe("unavailable"); + }); + + it("bounds the call with an abort signal", async () => { + let signal: AbortSignal | null | undefined; + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + signal = init?.signal; + return new Response( + JSON.stringify({ ok: true, result: { message_thread_id: 1, name: "x", icon_color: 1 } }), + { headers: { "content-type": "application/json" } }, + ); + }); + + await makeManager().create(-1001234, "x"); + + expect(signal).toBeInstanceOf(AbortSignal); + 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/analyze-hackathon.test.ts b/test/domain/usecases/analyze-hackathon.test.ts index b1f4f34..314b44d 100644 --- a/test/domain/usecases/analyze-hackathon.test.ts +++ b/test/domain/usecases/analyze-hackathon.test.ts @@ -509,6 +509,7 @@ describe("analyzeHackathon: builds (does not persist) and suggests", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); @@ -530,6 +531,7 @@ describe("analyzeHackathon: builds (does not persist) and suggests", () => { suggestedRepos: [], threadId: 7, pinnedMessageId: 42, + generalMessageId: null, createdAt: 10, updatedAt: 10, }); diff --git a/test/domain/usecases/link-analysis-to-topic.test.ts b/test/domain/usecases/link-analysis-to-topic.test.ts index 89d933b..78bd6ef 100644 --- a/test/domain/usecases/link-analysis-to-topic.test.ts +++ b/test/domain/usecases/link-analysis-to-topic.test.ts @@ -63,6 +63,7 @@ function analysis(overrides: Partial & { id: string; slug: st suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, ...overrides, diff --git a/test/domain/usecases/list-analyses.test.ts b/test/domain/usecases/list-analyses.test.ts index 23496fc..d04ced0 100644 --- a/test/domain/usecases/list-analyses.test.ts +++ b/test/domain/usecases/list-analyses.test.ts @@ -53,6 +53,7 @@ function analysis(overrides: Partial & { id: string; slug: st suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, ...overrides, diff --git a/test/domain/usecases/run-hackathon-job.test.ts b/test/domain/usecases/run-hackathon-job.test.ts index 8e52c1c..dd6a119 100644 --- a/test/domain/usecases/run-hackathon-job.test.ts +++ b/test/domain/usecases/run-hackathon-job.test.ts @@ -138,6 +138,7 @@ describe("runHackathonJob", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); @@ -183,6 +184,7 @@ describe("runHackathonJob", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); @@ -228,6 +230,7 @@ describe("runHackathonJob", () => { // Already linked and pinned to the SAME topic this redelivery targets. threadId: 500, pinnedMessageId: 900, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); @@ -270,6 +273,7 @@ describe("runHackathonJob", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); @@ -306,6 +310,7 @@ describe("runHackathonJob", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); diff --git a/test/domain/usecases/show-analysis.test.ts b/test/domain/usecases/show-analysis.test.ts index 741b063..864b0d0 100644 --- a/test/domain/usecases/show-analysis.test.ts +++ b/test/domain/usecases/show-analysis.test.ts @@ -60,6 +60,7 @@ describe("showAnalysis", () => { suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, }); diff --git a/test/domain/usecases/show-topic-analysis.test.ts b/test/domain/usecases/show-topic-analysis.test.ts index bbe85b8..620e867 100644 --- a/test/domain/usecases/show-topic-analysis.test.ts +++ b/test/domain/usecases/show-topic-analysis.test.ts @@ -42,6 +42,7 @@ function analysis(overrides: Partial & { id: string; slug: st suggestedRepos: [], threadId: null, pinnedMessageId: null, + generalMessageId: null, createdAt: 0, updatedAt: 0, ...overrides, diff --git a/test/fakes/index.ts b/test/fakes/index.ts index 30c9b94..b3a617c 100644 --- a/test/fakes/index.ts +++ b/test/fakes/index.ts @@ -1,5 +1,6 @@ import { AlertSendFailedError, + ForumTopicCreateError, PublishFailedError, QueueSendFailedError, TenantMismatchError, @@ -28,6 +29,7 @@ import type { ChatAdminChecker, ChatPublisher, Clock, + ForumTopicManager, DmSelectionRepo, GithubOrgClaimRepo, HackathonAnalysisRepo, @@ -39,10 +41,13 @@ import type { MemberRepo, MembershipRepo, PageFetcher, + PostOptions, ProfileRepo, RepoMetadataSource, RepoTopicLinkRepo, TeamRepo, + TopicCreateFailure, + TopicProbe, } from "../../src/domain/ports"; import type { MemberId, MembershipId, TeamId } from "../../src/domain/ids"; @@ -367,10 +372,36 @@ export function fakeLlmExtractor( export function fakeHackathonAnalysisRepo(): HackathonAnalysisRepo & { rows: HackathonAnalysis[]; + // Live topic-claim expiry per analysis id (mirrors topic_claim_until). + claims: Map; } { const rows: HackathonAnalysis[] = []; + const claims = new Map(); return { rows, + claims, + // Mirrors the D1 CAS (single conditional UPDATE): wins only when the + // observed thread id still matches and no live claim exists. + claimTopicCreation: async ( + teamId: TeamId, + analysisId: string, + expectedThreadId: number | null, + now: number, + ttlMs: number, + ) => { + const row = rows.find((r) => r.teamId === teamId && r.id === analysisId); + if (!row || row.threadId !== expectedThreadId) return false; + if ((claims.get(analysisId) ?? 0) > now) return false; + claims.set(analysisId, now + ttlMs); + return true; + }, + releaseTopicClaim: async (_teamId: TeamId, analysisId: string) => { + claims.set(analysisId, 0); + }, + setGeneralMessageId: async (teamId: TeamId, analysisId: string, messageId: number) => { + const row = rows.find((r) => r.teamId === teamId && r.id === analysisId); + if (row) row.generalMessageId = messageId; + }, findBySlug: async (teamId: TeamId, slug: string) => rows.find((r) => r.teamId === teamId && r.slug === slug) ?? null, findById: async (teamId: TeamId, id: string) => @@ -496,27 +527,47 @@ export function fakeRepoMetadataSource( } export function fakeChatPublisher( - opts: { throws?: boolean; failureClass?: AlertSendFailureClass } = {}, + opts: { + throws?: boolean; + failureClass?: AlertSendFailureClass; + // clearButtons fails (participation: a button-clear failure is ignored). + clearThrows?: boolean; + } = {}, ): ChatPublisher & { posted: Array<{ chatId: number; threadId: number | null; text: string }>; + // Parallel to `posted` (same index): the options each post received, so + // the existing `posted` entry shape stays unchanged for older tests. + postOptions: Array; pinned: number[]; unpinned: number[]; + cleared: Array<{ chatId: number; messageId: number }>; } { const posted: Array<{ chatId: number; threadId: number | null; text: string }> = []; + const postOptions: Array = []; const pinned: number[] = []; const unpinned: number[] = []; + const cleared: Array<{ chatId: number; messageId: number }> = []; let nextMessageId = 1; return { posted, + postOptions, pinned, unpinned, - post: async (chatId: number, threadId: number | null, text: string) => { + cleared, + post: async (chatId: number, threadId: number | null, text: string, options?: PostOptions) => { if (opts.throws) { throw new PublishFailedError("sendMessage failed", opts.failureClass ?? "rejected"); } posted.push({ chatId, threadId, text }); + postOptions.push(options); return nextMessageId++; }, + clearButtons: async (chatId: number, messageId: number) => { + if (opts.clearThrows) { + throw new PublishFailedError("editMessageReplyMarkup failed", opts.failureClass ?? "rejected"); + } + cleared.push({ chatId, messageId }); + }, pin: async (_chatId: number, messageId: number) => { pinned.push(messageId); }, @@ -526,6 +577,45 @@ 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". +export type TopicCreateStep = { threadId: number } | { fails: TopicCreateFailure }; + +export function fakeForumTopicManager( + opts: { create?: TopicCreateStep[]; probe?: TopicProbe[] } = {}, +): 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; + const step = script && script.length > 0 ? script[Math.min(createIdx, script.length - 1)] : undefined; + createIdx += 1; + if (step && "fails" in step) { + throw new ForumTopicCreateError("createForumTopic failed", step.fails); + } + 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"; + }, + }; +} + export function membership( overrides: Partial & { id: MembershipId;