Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions openspec/changes/hackathon-participation/apply-progress.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Apply Progress: hackathon-participation

Completed so far: Phase 1 (1.1-1.12) and Phase 2 (2.1-2.11). Phase 3 (PR2) and Phase 4 pending.

## Batch 1 — Phase 1 Infrastructure (PR1a) — branch `feat/participation-infra`

Mode: Strict TDD. Delivery: stacked-to-main, PR1a (infrastructure unused until PR1b).
Expand Down Expand Up @@ -38,3 +40,52 @@ Completed: 1.1–1.12 (12/12 of Phase 1). Phases 2–4 pending.
- Claim uses `topic_claim_until <= now` (an expired-at-now claim can be retaken).
- Fake publisher keeps `posted` shape unchanged; options are logged in a parallel `postOptions` array.
- Fakes (1.11) have no direct tests; they are exercised by the PR1b use-case tests.

## Batch 2 — Phase 2 Use Case and Join (PR1b) — branch `feat/participation-usecase`

Mode: Strict TDD. Base: main bfec91d (PR1a merged). Completed: Step 0 (PR1a review warnings) + 2.1–2.11 (all of Phase 2).

### Step 0 (PR1a advisory warnings)

| Finding | Resolution |
|---|---|
| R3-002 fake repo `save` replaced the whole row | `fakeHackathonAnalysisRepo.save` now copies the row and keeps the stored `generalMessageId` (claims live in a separate map, so they were already preserved); `fakeAnalysisJobRepo.persistAnalysis` inherits it. Tests in `test/fakes/hackathon-analysis-repo.test.ts` (RED: 3 failed, then 5/5) |
| R3-001 refresh keeps non-null `generalMessageId` | Test added in `analyze-hackathon.test.ts`. It passed on first run because the carry-over line already existed (regression guard, not a true RED) |
| R2-001 detached `ChatPublisher.post` comment | Moved back above `ChatPublisher` |
| R2-002 `<` vs `<=` | design.md decision 3 and the ports.ts comment now say `<=` |

### TDD Cycle Evidence

| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR |
|------|-----------|-------|------------|-----|-------|-------------|----------|
| 2.1/2.2 | `test/domain/hackathon/topic.test.ts` | Unit (pure) | N/A (new) | module missing | 9 tests pass | name, control/bidi, empty→slug, 128 cap, surrogate, ellipsis, link -100/non--100 | None needed |
| 2.3/2.4 | `test/domain/hackathon/argument.test.ts` | Unit (pure) | 5/5 | 5 failed (not a function) | 10/10 | join, bare, non-slug, 3 tokens, other→null | None needed |
| 2.5–2.5h/2.6 | `test/domain/usecases/participate-in-hackathon.test.ts` | Unit (fakes) | N/A (new) | module missing | 25/25 | happy, slug fallback, no link, redelivery, live, unknown, deleted, lost claim, busy, 4 refusals, uncertain keeps claim, pin/post/link failures, clear dedupe, null id | Concurrent test relaxed to accept busy or already (interleaving) |
| 2.7/2.9 | `test/adapters/telegram/participation.test.ts` | Unit (fakes) | N/A (new) | module missing | 13/13 | 7 refusal maps, safe General post failure, busy no-op, unrecognized rethrow | None needed |
| 2.8/2.9 | `test/adapters/telegram/commands.test.ts` (+12 join cases), `test/http/hackathon-command-e2e.test.ts` (+3) | Integration | 90+/all | 13 failed | all pass | admin/topic/General, usage x2, non-admin, stranger, unknown slug, old analysis, button clear, linkFailed, private chat, other multi-token unchanged; e2e create+clear, redelivery, no-rights | e2e ids made per-chat |
| 2.10 | `test/copy/catalog-language.test.ts` | Unit | pass | new catalogs failed to import | pass | denylist, non-empty, no voseo/usted | None needed |
| 2.11 | full suite | — | — | — | 997/997, typecheck clean | — | — |

### Work Unit Evidence

| Evidence | Value |
|---|---|
| Focused test command and result | `npx vitest run test/domain test/adapters/telegram test/http/hackathon-command-e2e.test.ts test/copy test/fakes`: all green; full suite 76 files, 997/997 |
| Runtime harness | N/A: no fetch/LLM path; e2e drives the real Hono route, composition, D1 and adapters with stubbed Telegram HTTP |
| Rollback boundary | `participate-in-hackathon.ts`, `hackathon/topic.ts`, `participation.ts`, join branch in `hackathon-commands.ts`, `forumTopicManager` wiring in `composition.ts` |

### Commits
- `e8c6649` test(fakes): preserve generalMessageId and claim state on fake repo save
- `ed5fa16` docs(ports): restore ChatPublisher contract comment and align claim expiry to <=
- `09e9c7f` feat(hackathon): topic name, topic link and join argument helpers
- `097db02` feat(participation): use case, errors, domain copy
- `ed31694` feat(telegram): /hackathon join and runParticipation

### Deviations / notes
- Domain copy is one `participationCopy` object (`alreadyHasTopic`, `confirmed`, `postFailed`, `linkFailed`); adapter copy is `participateCopy`. A null link (chat id without `-100`) drops the link and separator.
- Use case result is `{kind, replyText}` with `busy` carrying `replyText: null`. `already` and `created` are delivered to General through the safe post (design decision 8), not `ctx.reply`; only pre-creation refusals use the command reply.
- `sanitizeTopicName` is exported from `topic.ts` (the confirmation shows the name without the emoji).
- A non-member is refused with the same `adminOnly` text: `UnauthorizedError` in the use case, `NotFoundError` mapped in `runParticipation`, and a direct reply in the join handler when membership cannot be resolved.
- An unknown error from `create` (not a `ForumTopicCreateError`) keeps the claim and rethrows: the topic may exist.
- `ForumTopicManager` is wired in `composition.ts` (needed by join); the button, `hp:` handler and consumer wiring stay in Phase 3.
- Phase 3 must keep the `runParticipation` `reply` param for the callback alert.
2 changes: 1 addition & 1 deletion openspec/changes/hackathon-participation/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The use case owns five things: the admin gate, the slug lookup, the live/deleted
|---|---|---|---|
| 1 | Ports | New ISP port `ForumTopicManager { create, probe }`. `ChatPublisher.post` gains an optional `PostOptions { participateSlug?: string }`, which is semantic, not a keyboard. It also gains `clearButtons(chatId, messageId)`. The adapter owns the label, the `hp:` encoding and `editMessageReplyMarkup` (omitting `reply_markup` removes the keyboard). | Extending `ChatPublisher` with topic methods: the consumer would see topic rights it never needs. A domain-built `{label,data}` keyboard would leak the Telegram payload format into the domain. |
| 2 | Deleted-topic probe | `sendChatAction(chat, "typing", {message_thread_id})`. **ok ⇒ `live`** (a positive signal). A 400 whose description matches `/message thread not found\|TOPIC_ID_INVALID\|TOPIC_DELETED/i` ⇒ `deleted`. **Anything else** (other 400s, 403, 429, 5xx, timeout) ⇒ `unknown`, treated as live: never recreate on ambiguity. It needs no `can_manage_topics`. Its side effect is a "typing…" indicator of at most 5 s. | `editForumTopic` with no changes: needs `can_manage_topics` unless the bot created the topic, and a live topic also fails (`TOPIC_NOT_MODIFIED`), so live vs deleted hinges on two error strings. `closeForumTopic`/`reopenForumTopic`: they change state, post a service message, and need `can_manage_topics`. Telegram does not document its error descriptions (they come from community reports), so validation is an operator smoke step. The port hides the mechanism, so switching it is adapter-only. |
| 3 | Concurrency and redelivery | **(a) Migration 0004 with a claim.** `topic_claim_until` is set by a conditional `UPDATE … WHERE team_id=? AND id=? AND thread_id IS ?expected AND topic_claim_until < ?now` (CAS on the observed `thread_id`: null, or the stale id). TTL is 60 s. | (b) Accepting the race. Webhook `max_connections` defaults to 40, so parallel taps are real, and the cost (a duplicate public topic) is visible and manual to undo. |
| 3 | Concurrency and redelivery | **(a) Migration 0004 with a claim.** `topic_claim_until` is set by a conditional `UPDATE … WHERE team_id=? AND id=? AND thread_id IS ?expected AND topic_claim_until <= ?now` (CAS on the observed `thread_id`: null, or the stale id). TTL is 60 s. | (b) Accepting the race. Webhook `max_connections` defaults to 40, so parallel taps are real, and the cost (a duplicate public topic) is visible and manual to undo. |
| 4 | Button and message id | Migration 0004 adds `general_message_id`. A helper `postToGeneral` in `run-hackathon-job` (used by both General sites) posts with `participateSlug`. It then calls `setGeneralMessageId`, best-effort: catch and log, never retry, because a retry would repost. Each repost overwrites the id; old buttons stay idempotent. `/hackathon join` clears the stored id's button. Old analyses have null, so nothing is cleared. | Not storing the id: `join` could never remove the button. |
| 5 | Callback handling | `bot.callbackQuery(/^hp:([a-z0-9]+(?:-[a-z0-9]+)*)$/)`. A private chat or a missing chat is ignored. The new `callbackCallerLocation(ctx)` reads `ctx.chat.id`, `ctx.from.id`, `ctx.msg?.message_thread_id` and `ctx.callbackQuery.message?.message_id`. The team comes from `teamRepo.findByChatId` and the role from `ctx.from.id` (`resolveGroupMembership`). Non-members and non-admins get the same alert (`show_alert`). The handler answers the callback early and best-effort. | Changing `callerLocation` to `ctx.msg`: it would widen every command to edited or channel messages. Trusting a team id in the payload is ruled out by the spec. |
| 6 | Argument parsing | A new pure `parseJoinArgument(arg)` in `domain/hackathon/argument.ts`, checked **before** the whitespace rule. It returns `join` + slug when the argument is `^join\s+(\S+)$` and the slug is slug-shaped. It returns `join-usage` for a bare `join`, for `join <non-slug>` and for `join a b`. Otherwise it returns null, and the existing rules apply. | Loosening the whitespace rule globally. |
Expand Down
38 changes: 19 additions & 19 deletions openspec/changes/hackathon-participation/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,25 @@ Each PR is independently deployable and keeps `npm test` green. PR1a adds unused

## Phase 2: Use Case and Join Command (PR1b)

- [ ] 2.1 RED: `test/domain/hackathon/topic.test.ts` — `topicNameFor` (control/bidi chars stripped, whitespace collapsed, empty → slug, `🏆 ` prefix, ≤128 UTF-16 units, no split surrogate, `…` when cut); `topicLink` (`-100` stripped, non-`-100` → null).
- [ ] 2.2 GREEN: create `src/domain/hackathon/topic.ts`.
- [ ] 2.3 RED: `test/domain/hackathon/argument.test.ts` — `parseJoinArgument`: `join meridian` → join+slug; bare `join`, `join Not_Slug`, `join a b` → `join-usage`; other arguments → null (existing rules unchanged).
- [ ] 2.4 GREEN: `src/domain/hackathon/argument.ts` (`parseJoinArgument`, checked before the whitespace rule).
- [ ] 2.5 RED: create `test/domain/usecases/participate-in-hackathon.test.ts` — non-admin/non-member → `UnauthorizedError`, unknown slug → `AnalysisNotFoundError`, nothing changes; happy path fresh topic created, linked, pinned, General text `confirmed(name, link)`.
- [ ] 2.5a RED: same file — **redelivery never creates a second topic**: second call after success returns `already(link)`, exactly one `create`.
- [ ] 2.5b RED: same file — live topic (`live`) → `already`, no create; **ambiguous probe error treated as live (no recreate)**: `unknown` → `already`, no create, buttons cleared best-effort.
- [ ] 2.5c RED: same file — **deleted topic detected and recreated**: `deleted` → claim with `expected = stale id`, new topic created, stale id replaced, no spurious unpin/"moved" note.
- [ ] 2.5d RED: same file — **concurrent taps**: claim loss then re-read linked → `already`; not linked → `busy` (neutral no-op, nothing posted); exactly one `create` across two calls.
- [ ] 2.5e RED: same file — **missing Manage Topics / not a forum (nothing persisted)**: `no-rights` → `TopicRightsMissingError`, `not-forum` → `ChatNotForumError`; claim released, no link stored. `rate-limited`/`rejected` → `TopicCreationFailedError`, claim released. `unavailable` → `TopicCreationUncertainError`, claim kept.
- [ ] 2.5f RED: same file — **pin failure**: analysis posted unpinned, link persists, `pinFailed` note in result.
- [ ] 2.5g RED: same file — **post failure after topic creation (never rethrows)**: link persists, result `postFailed(slug, link)`; `moveTopicLink` failure → `linkFailed(slug, link)`; `clearButtons` failure ignored; clears the deduped set {callback message id, `generalMessageId`}.
- [ ] 2.5h RED: same file — **old analyses without a message id**: `generalMessageId: null` → join works, nothing to clear, no `clearButtons` call for it.
- [ ] 2.6 GREEN: `src/domain/errors.ts` (`TopicRightsMissingError`, `ChatNotForumError`, `TopicCreationFailedError`, `TopicCreationUncertainError`), `src/domain/copy.ts` (`alreadyHasTopic`, `confirmed`, `postFailed`, `linkFailed`), create `src/domain/usecases/participate-in-hackathon.ts` with the design step order (no-throw zone from `moveTopicLink`).
- [ ] 2.7 RED: `test/adapters/telegram/participation.test.ts` — `runParticipation` maps each domain error by name to its Spanish reply (`adminOnly`, `noAnalysis(slug)`, `noRights`, `notForum`, `createFailed` "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.", `createUncertain` "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo."); a failing safe General post is caught and logged, never rethrown (no 500).
- [ ] 2.8 RED: `test/adapters/telegram/commands.test.ts` (hackathon cases) and `test/http/hackathon-command-e2e.test.ts` — `/hackathon join <slug>` runs participation with no fetch/cap; `join` usage line "Uso: /hackathon join <slug>"; non-admin reply; unknown-slug reply; `join <slug>` on an old analysis works; `linkFailed` reply text.
- [ ] 2.9 GREEN: create `src/adapters/telegram/participation.ts` (`runParticipation`, safe General post); `src/adapters/telegram/copy.ts` (all adapter strings from the design copy table); `src/adapters/telegram/hackathon-commands.ts` (join branch); `src/composition.ts` (wire use case, `ForumTopicManager`).
- [ ] 2.10 Extend `test/copy/catalog-language.test.ts` to the new catalog entries (non-empty, Spanish, no English denylist match, neutral "tú"); make it pass.
- [ ] 2.11 Run `npm test` and `npm run typecheck`; join works, button not yet rendered.
- [x] 2.1 RED: `test/domain/hackathon/topic.test.ts` — `topicNameFor` (control/bidi chars stripped, whitespace collapsed, empty → slug, `🏆 ` prefix, ≤128 UTF-16 units, no split surrogate, `…` when cut); `topicLink` (`-100` stripped, non-`-100` → null).
- [x] 2.2 GREEN: create `src/domain/hackathon/topic.ts`.
- [x] 2.3 RED: `test/domain/hackathon/argument.test.ts` — `parseJoinArgument`: `join meridian` → join+slug; bare `join`, `join Not_Slug`, `join a b` → `join-usage`; other arguments → null (existing rules unchanged).
- [x] 2.4 GREEN: `src/domain/hackathon/argument.ts` (`parseJoinArgument`, checked before the whitespace rule).
- [x] 2.5 RED: create `test/domain/usecases/participate-in-hackathon.test.ts` — non-admin/non-member → `UnauthorizedError`, unknown slug → `AnalysisNotFoundError`, nothing changes; happy path fresh topic created, linked, pinned, General text `confirmed(name, link)`.
- [x] 2.5a RED: same file — **redelivery never creates a second topic**: second call after success returns `already(link)`, exactly one `create`.
- [x] 2.5b RED: same file — live topic (`live`) → `already`, no create; **ambiguous probe error treated as live (no recreate)**: `unknown` → `already`, no create, buttons cleared best-effort.
- [x] 2.5c RED: same file — **deleted topic detected and recreated**: `deleted` → claim with `expected = stale id`, new topic created, stale id replaced, no spurious unpin/"moved" note.
- [x] 2.5d RED: same file — **concurrent taps**: claim loss then re-read linked → `already`; not linked → `busy` (neutral no-op, nothing posted); exactly one `create` across two calls.
- [x] 2.5e RED: same file — **missing Manage Topics / not a forum (nothing persisted)**: `no-rights` → `TopicRightsMissingError`, `not-forum` → `ChatNotForumError`; claim released, no link stored. `rate-limited`/`rejected` → `TopicCreationFailedError`, claim released. `unavailable` → `TopicCreationUncertainError`, claim kept.
- [x] 2.5f RED: same file — **pin failure**: analysis posted unpinned, link persists, `pinFailed` note in result.
- [x] 2.5g RED: same file — **post failure after topic creation (never rethrows)**: link persists, result `postFailed(slug, link)`; `moveTopicLink` failure → `linkFailed(slug, link)`; `clearButtons` failure ignored; clears the deduped set {callback message id, `generalMessageId`}.
- [x] 2.5h RED: same file — **old analyses without a message id**: `generalMessageId: null` → join works, nothing to clear, no `clearButtons` call for it.
- [x] 2.6 GREEN: `src/domain/errors.ts` (`TopicRightsMissingError`, `ChatNotForumError`, `TopicCreationFailedError`, `TopicCreationUncertainError`), `src/domain/copy.ts` (`alreadyHasTopic`, `confirmed`, `postFailed`, `linkFailed`), create `src/domain/usecases/participate-in-hackathon.ts` with the design step order (no-throw zone from `moveTopicLink`).
- [x] 2.7 RED: `test/adapters/telegram/participation.test.ts` — `runParticipation` maps each domain error by name to its Spanish reply (`adminOnly`, `noAnalysis(slug)`, `noRights`, `notForum`, `createFailed` "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.", `createUncertain` "No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo."); a failing safe General post is caught and logged, never rethrown (no 500).
- [x] 2.8 RED: `test/adapters/telegram/commands.test.ts` (hackathon cases) and `test/http/hackathon-command-e2e.test.ts` — `/hackathon join <slug>` runs participation with no fetch/cap; `join` usage line "Uso: /hackathon join <slug>"; non-admin reply; unknown-slug reply; `join <slug>` on an old analysis works; `linkFailed` reply text.
- [x] 2.9 GREEN: create `src/adapters/telegram/participation.ts` (`runParticipation`, safe General post); `src/adapters/telegram/copy.ts` (all adapter strings from the design copy table); `src/adapters/telegram/hackathon-commands.ts` (join branch); `src/composition.ts` (wire use case, `ForumTopicManager`).
- [x] 2.10 Extend `test/copy/catalog-language.test.ts` to the new catalog entries (non-empty, Spanish, no English denylist match, neutral "tú"); make it pass.
- [x] 2.11 Run `npm test` and `npm run typecheck`; join works, button not yet rendered.

## Phase 3: Button, Callback, Consumer (PR2)

Expand Down
14 changes: 14 additions & 0 deletions src/adapters/telegram/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,17 @@ export const pickerCopy = {
notMember: "No eres miembro de ese equipo.",
selected: "Equipo seleccionado. Vuelve a ejecutar tu comando para continuar.",
};

// hackathon-participation adapter replies (design.md "Copy Table").
export const participateCopy = {
adminOnly: "Solo un administrador del equipo puede confirmar la participación.",
joinUsage: "Uso: /hackathon join <slug>",
noAnalysis: (slug: string) => `No se encontró ningún análisis con el slug ${slug}.`,
noRights:
"No puedo crear temas: concede al bot el permiso «Administrar temas» y vuelve a intentarlo.",
notForum:
"Este grupo no tiene los temas activados. Actívalos en la configuración del grupo y vuelve a intentarlo.",
createFailed: "Telegram rechazó la creación del tema. Inténtalo de nuevo en un minuto.",
createUncertain:
"No se pudo confirmar si se creó el tema. Revisa la lista de temas antes de volver a intentarlo.",
};
Loading
Loading