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
9 changes: 9 additions & 0 deletions openspec/changes/hackathon-participation/apply-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,12 @@ Review found that treating any `rejected` post as "deleted" would recreate a LIV
- TDD: RED first (7 failing: 3 adapter thread-gone cases, the use-case rejected-no-recreate case and 3 recreate tests moved to thread-gone), then GREEN. Added adapter cases (400 thread not found, TOPIC_ID_INVALID, TOPIC_DELETED to thread-gone; TOPIC_CLOSED, 403 and a 403 with thread wording to rejected; pin stays rejected) and use-case cases (rejected, unexpected TypeError, with the topic-check-failed log asserted).
- R3-003 and R3-004: proposal, design and spec now describe the post-based check and its residual risk (a different Telegram description would not recreate: the safe side, checked by the smoke test); the `claimTopicCreation` comment no longer mentions probe.
- Full suite 76 files, 1027/1027; `npm run typecheck` clean.

## Fix: trophy as the topic icon, paced fresh-topic flow

- Production showed the topic named "πŸ† BNB Hack: ..." with the generic icon. The πŸ† is now the topic icon and the name is plain.
- `ForumTopicManager.create(chatId, name, { iconEmoji, fallbackName }?)`. The adapter fetches `getForumTopicIconStickers()` (per-isolate promise cache, cleared on rejection; `resetTopicIconCache` for tests), matches the emoji ignoring U+FE0F and calls `createForumTopic` with `icon_custom_emoji_id`. No match or a failed fetch creates the topic without an icon under `fallbackName`; the fetch never fails the creation.
- `topicNameFor(name, slug, iconApplied)` in the domain: plain name when the icon applies, `πŸ† ` prefix (128-unit cap including it) otherwise. The use case passes both variants; the General confirmation uses the plain name.
- Pacing: the use case waits `TOPIC_POST_DELAY_MS` (1500 ms) after the link is saved, and `postAnalysisAndLinkTopic` accepts an optional `pinDelayMs` (`PIN_DELAY_MS`, 1000 ms) between post and pin. Evidence: topic 306 was created, the post (307) pinned at once, `pinChatMessage` returned ok, yet nothing was pinned. Only the fresh-topic path passes delays. `Sleep` is an injected dependency (`setTimeout` in `composition.ts`, `fakeSleep` in tests).
- TDD: RED tests first for the domain name variants, the adapter (found, not found, fetch failure, cache once, refetch after rejection, no hint), the use case (names, confirmation, create -> sleep -> post -> sleep -> pin order, no sleep on the live-topic path) and `postAnalysisAndLinkTopic` (delay only when requested). The e2e stub now serves the icon list.
- Known limit: the delay is a mitigation for a Telegram race whose cause is unconfirmed; the pin failure note still covers a pin that errors.
6 changes: 3 additions & 3 deletions openspec/changes/hackathon-participation/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The use case owns five things: the admin gate, the slug lookup, the existing-top
| `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.
6. Wait `TOPIC_POST_DELAY_MS` (1500 ms) through the injected `sleep`, then call `postAnalysisAndLinkTopic({threadId: T, analysis: {...analysis, threadId: T}, pinDelayMs: PIN_DELAY_MS})`, which waits `PIN_DELAY_MS` (1000 ms) between the post and the pin. Production showed a pin issued right after the post into a just-created topic return `ok` yet never pin. The delays apply to the fresh-topic path only (the live-topic check and other callers pass no delay); `sleep` is a `Sleep` dependency wired to `setTimeout` in the composition root, so the domain owns no timer. The 2.5 s total is idle wall-clock time, not CPU. 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
Expand All @@ -55,7 +55,7 @@ consumer: postToGeneral(text, participateSlug) ─ setGeneralMessageId (best-eff
```ts
type TopicCreateFailure = "no-rights" | "not-forum" | "rate-limited" | "rejected" | "unavailable";
interface ForumTopicManager {
create(chatId: number, name: string): Promise<number>; // throws ForumTopicCreateError(failure)
create(chatId: number, name: string, options?: { iconEmoji: string; fallbackName: string }): Promise<number>; // throws ForumTopicCreateError(failure)
}
interface ChatPublisher { post(chatId, threadId, text, options?: { participateSlug?: string }): Promise<number>;
pin; unpin; clearButtons(chatId: number, messageId: number): Promise<void>; }
Expand All @@ -72,7 +72,7 @@ setGeneralMessageId(team, id, messageId: number): Promise<void>;
- 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.
Topic name: strip `\p{Cc}\p{Cf}` (bidi spoofing too), collapse whitespace and trim; fall back to the slug; the result is the plain name, cut at code-point boundaries to at most 128 UTF-16 units, with `…` when cut. The πŸ† is the topic **icon**, not part of the name: the use case passes `create(chatId, name, { iconEmoji: "πŸ†", fallbackName })`, where `fallbackName` is the same name prefixed with `πŸ† ` (cut to 128 units including the prefix). The adapter calls `getForumTopicIconStickers()`, picks the sticker whose `emoji` equals the hint (U+FE0F stripped on both sides) and calls `createForumTopic` with `icon_custom_emoji_id`; bots may use these ids without Premium. The icon list is cached per isolate as a promise that is cleared on rejection. When there is no match, or the list call fails, the topic is created without an icon and named `fallbackName`; an icon failure never fails the creation. The domain never learns which variant won, so the port stays free of Telegram types. The General confirmation shows the plain sanitized name.

## Copy Table (spec strings are authoritative)

Expand Down
2 changes: 1 addition & 1 deletion openspec/changes/hackathon-participation/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ After a General-chat analysis, the team decides whether to join. Today an admin
- an inline "βœ… Participamos" button on the General analysis post (`callback_data` `hp:<slug>`, at most 64 bytes);
- `/hackathon join <slug>` 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 `πŸ† <name>`:
- Topic name `<name>` with πŸ† as the topic icon (a `πŸ† ` name prefix only when the icon cannot be applied):
- 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,33 @@ The system MUST allow only a team admin to confirm participation and MUST change

### Requirement: Topic Creation and Naming

The system MUST create the topic named `πŸ† <name>`, where `<name>` 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.
The system MUST create the topic named `<name>` (no emoji) and set πŸ† as the topic icon through `icon_custom_emoji_id`, taken from Telegram's topic icon set. Only when the icon cannot be applied (no matching icon, or the icon list is unavailable) MUST the name carry the visible fallback prefix `πŸ† <name>`. Here `<name>` 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"
- THEN the topic is named "Meridian Hack 2026"
- AND the topic icon is πŸ†

#### Scenario: Icon unavailable

- GIVEN Telegram has no matching topic icon, or the icon list cannot be fetched
- WHEN participation is confirmed
- THEN the topic is still created, named "πŸ† Meridian Hack 2026" with no icon

#### Scenario: Fresh topic is paced

- GIVEN the topic was just created
- WHEN the analysis is posted and pinned
- THEN the system waits about 1.5 s before the post and about 1 s between the post and the pin
- AND a live-topic check never waits

#### Scenario: Missing or empty name

- GIVEN `fields.name.value` is absent or empty after sanitizing
- WHEN participation is confirmed
- THEN the topic is named "πŸ† <slug>"
- THEN the topic is named "<slug>"

#### Scenario: Overlong name

Expand Down
48 changes: 46 additions & 2 deletions src/adapters/telegram/forum-topic-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,55 @@ function classifyCreateFailure(err: unknown): TopicCreateFailure {
return "rejected";
}

const VARIATION_SELECTOR = /️/gu;
const normalizeEmoji = (emoji: string): string => emoji.replace(VARIATION_SELECTOR, "");

type IconStickers = Awaited<ReturnType<Api["getForumTopicIconStickers"]>>;

// Per-isolate cache of Telegram's topic icon set. A rejection clears it so a
// transient failure is retried instead of cached forever.
let iconStickers: Promise<IconStickers> | null = null;

export function resetTopicIconCache(): void {
iconStickers = null;
}

function loadIconStickers(api: Api): Promise<IconStickers> {
if (!iconStickers) {
const pending = api.getForumTopicIconStickers(topicSignal());
iconStickers = pending;
pending.catch(() => {
if (iconStickers === pending) iconStickers = null;
});
}
return iconStickers;
}

// Best-effort: any failure (or no match) means "no icon", never a failed create.
async function findIconId(api: Api, emoji: string): Promise<string | null> {
try {
const stickers = await loadIconStickers(api);
if (!Array.isArray(stickers)) return null;
const wanted = normalizeEmoji(emoji);
const hit = stickers.find((s) => s.emoji !== undefined && normalizeEmoji(s.emoji) === wanted);
return hit?.custom_emoji_id ?? null;
} catch {
return null;
}
}

export function createTelegramForumTopicManager(api: Api): ForumTopicManager {
return {
async create(chatId, name) {
async create(chatId, name, options) {
const iconId = options ? await findIconId(api, options.iconEmoji) : null;
const finalName = options && iconId === null ? options.fallbackName : name;
try {
const topic = await api.createForumTopic(chatId, name, undefined, topicSignal());
const topic = await api.createForumTopic(
chatId,
finalName,
iconId === null ? undefined : { icon_custom_emoji_id: iconId },
topicSignal(),
);
return topic.message_thread_id;
} catch (err) {
// Fixed message: Telegram's description is never carried over.
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/telegram/hackathon-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
Logger,
MemberRepo,
MembershipRepo,
Sleep,
TeamRepo,
} from "../../domain/ports";
import { linkAnalysisToTopic } from "../../domain/usecases/link-analysis-to-topic";
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface HackathonCommandDeps {
analysisJobRepo: AnalysisJobRepo;
chatPublisher: ChatPublisher;
forumTopicManager: ForumTopicManager;
sleep: Sleep;
clock: Clock;
idGen: IdGen;
logger: Logger;
Expand Down
1 change: 1 addition & 0 deletions src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function buildBot(env: Env) {
analysisJobQueue,
chatPublisher: createTelegramChatPublisher(bot.api),
forumTopicManager: createTelegramForumTopicManager(bot.api),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
clock,
idGen,
logger,
Expand Down
18 changes: 13 additions & 5 deletions src/domain/hackathon/topic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Pure helpers for the participation topic (hackathon-participation design.md
// "Topic name" and decision 7 "Deep link").

const TOPIC_PREFIX = "πŸ† ";
// The trophy is the topic ICON (see `TopicCreateOptions`); as a name prefix it
// is only the visible fallback when the icon cannot be applied.
export const TOPIC_ICON_EMOJI = "πŸ†";
const TOPIC_PREFIX = `${TOPIC_ICON_EMOJI} `;
// Telegram's forum topic name limit, counted in UTF-16 code units.
const TOPIC_NAME_MAX = 128;
const ELLIPSIS = "…";
Expand All @@ -19,17 +22,22 @@ export function sanitizeTopicName(name: string | null | undefined, slug: string)
return cleaned === "" ? slug : cleaned;
}

export function topicNameFor(name: string | null | undefined, slug: string): string {
export function topicNameFor(
name: string | null | undefined,
slug: string,
iconApplied: boolean,
): string {
const sanitized = sanitizeTopicName(name, slug);
const room = TOPIC_NAME_MAX - TOPIC_PREFIX.length;
if (sanitized.length <= room) return TOPIC_PREFIX + sanitized;
const prefix = iconApplied ? "" : TOPIC_PREFIX;
const room = TOPIC_NAME_MAX - prefix.length;
if (sanitized.length <= room) return prefix + sanitized;
// Cut at code-point boundaries, leaving one unit for the ellipsis.
let cut = "";
for (const ch of sanitized) {
if (cut.length + ch.length > room - ELLIPSIS.length) break;
cut += ch;
}
return TOPIC_PREFIX + cut + ELLIPSIS;
return prefix + cut + ELLIPSIS;
}

// Telegram's "Copy link" format for a topic (a topic id is its creation
Expand Down
13 changes: 12 additions & 1 deletion src/domain/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ export interface Clock {
now(): number;
}

// Delay seam: the domain owns no timers, the composition root injects one.
export type Sleep = (ms: number) => Promise<void>;

export interface IdGen {
newId(): string;
}
Expand Down Expand Up @@ -348,8 +351,16 @@ export type TopicCreateFailure =

// ISP port: only the use case that creates topics sees topic rights.
// `create` throws ForumTopicCreateError(failure).
// Icon hint for a new topic. The adapter looks `iconEmoji` up in Telegram's
// topic icon set; when it cannot apply the icon it names the topic
// `fallbackName` instead of `name` (the domain never learns which one won).
export interface TopicCreateOptions {
iconEmoji: string;
fallbackName: string;
}

export interface ForumTopicManager {
create(chatId: number, name: string): Promise<number>;
create(chatId: number, name: string, options?: TopicCreateOptions): Promise<number>;
}

// Semantic post options (hackathon-participation design.md decision 1): the
Expand Down
17 changes: 14 additions & 3 deletions src/domain/usecases/link-analysis-to-topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { analysisCopy } from "../copy";
import { formatAnalysis, REPLY_MAX, truncate } from "../hackathon/format";
import type { HackathonAnalysis } from "../entities";
import type { MembershipId, TeamId } from "../ids";
import type { ChatPublisher, HackathonAnalysisRepo, Logger, MembershipRepo } from "../ports";
import type {
ChatPublisher,
HackathonAnalysisRepo,
Logger,
MembershipRepo,
Sleep,
} from "../ports";

export interface LinkAnalysisToTopicInput {
teamId: TeamId;
Expand Down Expand Up @@ -68,12 +74,16 @@ export interface PostAnalysisAndLinkTopicInput {
chatId: number;
threadId: number;
analysis: HackathonAnalysis;
// Wait this long between the post and the pin. Only a freshly created topic
// needs it (Telegram can silently drop a pin issued right after the post);
// every other caller omits it and pins at once.
pinDelayMs?: number;
}

export type PostAnalysisAndLinkTopicDeps = Pick<
LinkAnalysisToTopicDeps,
"hackathonAnalysisRepo" | "chatPublisher" | "logger"
>;
> & { sleep?: Sleep };

// The permission-free core (design.md "Pin Behavior": "a `/hackathon <url>`
// run inside a topic now links and pins from the consumer"). Reused by
Expand All @@ -84,7 +94,7 @@ export async function postAnalysisAndLinkTopic(
input: PostAnalysisAndLinkTopicInput,
deps: PostAnalysisAndLinkTopicDeps,
): Promise<LinkAnalysisToTopicResult> {
const { teamId, chatId, threadId, analysis } = input;
const { teamId, chatId, threadId, analysis, pinDelayMs } = input;
const notes: string[] = [];

// spec: "Topic already holds a different analysis" β€” move the link,
Expand Down Expand Up @@ -118,6 +128,7 @@ export async function postAnalysisAndLinkTopic(

let pinnedMessageId: number | null = messageId;
try {
if (pinDelayMs !== undefined && pinDelayMs > 0 && deps.sleep) await deps.sleep(pinDelayMs);
await deps.chatPublisher.pin(chatId, messageId);
} catch (err) {
// Best-effort (spec "Pin Failure Falls Back to Unpinned Posting"): the
Expand Down
23 changes: 19 additions & 4 deletions src/domain/usecases/participate-in-hackathon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
TopicRightsMissingError,
UnauthorizedError,
} from "../errors";
import { sanitizeTopicName, topicLink, topicNameFor } from "../hackathon/topic";
import { TOPIC_ICON_EMOJI, sanitizeTopicName, topicLink, topicNameFor } from "../hackathon/topic";
import type { HackathonAnalysis } from "../entities";
import type { MembershipId, TeamId } from "../ids";
import type {
Expand All @@ -19,13 +19,22 @@ import type {
HackathonAnalysisRepo,
Logger,
MembershipRepo,
Sleep,
} from "../ports";
import { postAnalysisAndLinkTopic } from "./link-analysis-to-topic";

// design.md decision 3: the creation claim outlives a slow createForumTopic
// but not a stuck one.
const CLAIM_TTL_MS = 60_000;

// Pacing for a FRESH topic only. Telegram accepted the pin of a message posted
// into a just-created topic (ok: true) yet never pinned it, so the topic gets
// a moment to settle before the post, and the post before the pin. The whole
// join adds 2.5 s of wall-clock waiting (no CPU; Workers only bill/limit CPU
// time, and Telegram's webhook client waits far longer), well inside budget.
export const TOPIC_POST_DELAY_MS = 1500;
export const PIN_DELAY_MS = 1000;

export interface ParticipateInHackathonInput {
teamId: TeamId;
actorMembershipId: MembershipId;
Expand All @@ -42,6 +51,7 @@ export interface ParticipateInHackathonDeps {
chatPublisher: ChatPublisher;
forumTopicManager: ForumTopicManager;
clock: Clock;
sleep: Sleep;
logger: Logger;
}

Expand Down Expand Up @@ -111,7 +121,11 @@ export async function participateInHackathon(
const name = analysis.fields.name?.value;
let threadId: number;
try {
threadId = await deps.forumTopicManager.create(chatId, topicNameFor(name, analysis.slug));
threadId = await deps.forumTopicManager.create(
chatId,
topicNameFor(name, analysis.slug, true),
{ iconEmoji: TOPIC_ICON_EMOJI, fallbackName: topicNameFor(name, analysis.slug, false) },
);
} catch (err) {
if (err instanceof ForumTopicCreateError) {
if (err.failure === "unavailable") {
Expand All @@ -133,12 +147,13 @@ export async function participateInHackathon(
return { kind: "linkFailed", replyText: participationCopy.linkFailed(analysis.slug, link) };
}

// 6. Post and pin in the new topic; the threadId override avoids a spurious
// 6. Let the new topic settle, then post and pin in it; the threadId override avoids a spurious
// unpin and "moved" note for a recreated topic.
let notes: string[];
try {
await deps.sleep(TOPIC_POST_DELAY_MS);
const posted = await postAnalysisAndLinkTopic(
{ teamId, chatId, threadId, analysis: { ...analysis, threadId } },
{ teamId, chatId, threadId, analysis: { ...analysis, threadId }, pinDelayMs: PIN_DELAY_MS },
deps,
);
notes = posted.notes;
Expand Down
Loading
Loading