From 64d81dfb2f40c3c5ca764529fec49a4c566fd901 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:55:37 -0400 Subject: [PATCH 1/3] feat(participation): use the trophy as the forum topic icon instead of a name prefix --- src/adapters/telegram/forum-topic-manager.ts | 48 +++++++- src/domain/hackathon/topic.ts | 18 ++- src/domain/ports.ts | 10 +- .../usecases/participate-in-hackathon.ts | 8 +- test/adapters/telegram/commands.test.ts | 12 +- .../telegram/forum-topic-manager.test.ts | 109 +++++++++++++++++- test/domain/hackathon/topic.test.ts | 41 +++++-- .../usecases/participate-in-hackathon.test.ts | 13 ++- test/fakes/index.ts | 9 +- test/http/hackathon-command-e2e.test.ts | 4 +- 10 files changed, 239 insertions(+), 33 deletions(-) diff --git a/src/adapters/telegram/forum-topic-manager.ts b/src/adapters/telegram/forum-topic-manager.ts index c3f6914..b5d4ecb 100644 --- a/src/adapters/telegram/forum-topic-manager.ts +++ b/src/adapters/telegram/forum-topic-manager.ts @@ -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>; + +// 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 | null = null; + +export function resetTopicIconCache(): void { + iconStickers = null; +} + +function loadIconStickers(api: Api): Promise { + 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 { + 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. diff --git a/src/domain/hackathon/topic.ts b/src/domain/hackathon/topic.ts index f37f0a2..0900cdd 100644 --- a/src/domain/hackathon/topic.ts +++ b/src/domain/hackathon/topic.ts @@ -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 = "โ€ฆ"; @@ -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 diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 5f312df..581c52f 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -348,8 +348,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; + create(chatId: number, name: string, options?: TopicCreateOptions): Promise; } // Semantic post options (hackathon-participation design.md decision 1): the diff --git a/src/domain/usecases/participate-in-hackathon.ts b/src/domain/usecases/participate-in-hackathon.ts index 074b7a3..7c2aaa4 100644 --- a/src/domain/usecases/participate-in-hackathon.ts +++ b/src/domain/usecases/participate-in-hackathon.ts @@ -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 { @@ -111,7 +111,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") { diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index 2af13a3..b9e81fb 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -1477,7 +1477,9 @@ describe("registerCommands โ€” /hackathon join (hackathon-participation s await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "join meridian" })); - expect(deps.forumTopicManager.created).toEqual([{ chatId: 10, name: "๐Ÿ† Hack meridian" }]); + expect(deps.forumTopicManager.created).toEqual([ + { chatId: 10, name: "Hack meridian", iconEmoji: "๐Ÿ†", fallbackName: "๐Ÿ† Hack meridian" }, + ]); const threadId = deps.hackathonAnalysisRepo.rows[0]?.threadId; expect(threadId).toBe(1000); expect(deps.chatPublisher.posted).toMatchObject([ @@ -1661,7 +1663,9 @@ describe("registerCommands โ€” hp: callback (hackathon-participation spec: await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian", { messageId: 4242 })); - expect(deps.forumTopicManager.created).toEqual([{ chatId: 10, name: "๐Ÿ† Hack meridian" }]); + expect(deps.forumTopicManager.created).toEqual([ + { chatId: 10, name: "Hack meridian", iconEmoji: "๐Ÿ†", fallbackName: "๐Ÿ† Hack meridian" }, + ]); expect(deps.hackathonAnalysisRepo.rows[0]?.threadId).toBe(1000); expect(deps.chatPublisher.posted.at(-1)).toMatchObject({ chatId: 10, @@ -1689,9 +1693,9 @@ describe("registerCommands โ€” hp: callback (hackathon-participation spec: deps.hackathonAnalysisRepo.rows.push(storedAnalysis(teamId, "meridian")); let answeredBeforeCreate = false; const create = deps.forumTopicManager.create; - deps.forumTopicManager.create = async (chatId, name) => { + deps.forumTopicManager.create = async (chatId, name, opts) => { answeredBeforeCreate = answers.length === 1; - return create(chatId, name); + return create(chatId, name, opts); }; await bot.handleUpdate(groupCallbackUpdate(10, 1, "hp:meridian")); diff --git a/test/adapters/telegram/forum-topic-manager.test.ts b/test/adapters/telegram/forum-topic-manager.test.ts index 51aa5f8..80820e7 100644 --- a/test/adapters/telegram/forum-topic-manager.test.ts +++ b/test/adapters/telegram/forum-topic-manager.test.ts @@ -2,13 +2,17 @@ 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 { + createTelegramForumTopicManager, + resetTopicIconCache, +} from "../../../src/adapters/telegram/forum-topic-manager"; import { stubTelegramApi } from "../../support/telegram-stub"; // hackathon-participation design.md "Interfaces / Contracts": // `create` classifies a failed createForumTopic into a fixed failure code. afterEach(() => { + resetTopicIconCache(); vi.unstubAllGlobals(); }); @@ -101,3 +105,106 @@ describe("createTelegramForumTopicManager.create", () => { expect(signal?.aborted).toBe(false); }); }); + +describe("createTelegramForumTopicManager.create with an icon hint", () => { + const OPTS = { iconEmoji: "๐Ÿ†", fallbackName: "๐Ÿ† Meridian" }; + const stickers = [ + { emoji: "๐Ÿ“ฐ", custom_emoji_id: "111" }, + { emoji: "๐Ÿ†", custom_emoji_id: "5309" }, + ]; + const topicResult = { message_thread_id: 9, name: "Meridian", icon_color: 1 }; + + it("uses the matching sticker as the icon and keeps the plain name", async () => { + const calls = stubTelegramApi((method) => + method === "getForumTopicIconStickers" ? stickers : topicResult, + ); + + const threadId = await makeManager().create(-1001234, "Meridian", OPTS); + + expect(threadId).toBe(9); + expect(calls.map((c) => c.method)).toEqual(["getForumTopicIconStickers", "createForumTopic"]); + expect(calls[1]?.body).toMatchObject({ + chat_id: -1001234, + name: "Meridian", + icon_custom_emoji_id: "5309", + }); + }); + + it("matches ignoring the U+FE0F variation selector on either side", async () => { + const calls = stubTelegramApi((method) => + method === "getForumTopicIconStickers" + ? [{ emoji: "โค๏ธ", custom_emoji_id: "77" }] + : topicResult, + ); + + await makeManager().create(-1001234, "Meridian", { iconEmoji: "โค", fallbackName: "โค M" }); + + expect(calls[1]?.body).toMatchObject({ name: "Meridian", icon_custom_emoji_id: "77" }); + }); + + it("creates the topic with the fallback name and no icon when nothing matches", async () => { + const calls = stubTelegramApi((method) => + method === "getForumTopicIconStickers" + ? [{ emoji: "๐Ÿ“ฐ", custom_emoji_id: "111" }] + : topicResult, + ); + + await makeManager().create(-1001234, "Meridian", OPTS); + + const body = calls.find((c) => c.method === "createForumTopic")?.body as Record; + expect(body.name).toBe("๐Ÿ† Meridian"); + expect(body).not.toHaveProperty("icon_custom_emoji_id"); + }); + + it("still creates the topic with the fallback name when the sticker fetch fails", async () => { + const calls = stubTelegramApi((method) => + method === "getForumTopicIconStickers" ? fail(500, "Internal Server Error") : topicResult, + ); + + const threadId = await makeManager().create(-1001234, "Meridian", OPTS); + + expect(threadId).toBe(9); + const body = calls.find((c) => c.method === "createForumTopic")?.body as Record; + expect(body.name).toBe("๐Ÿ† Meridian"); + expect(body).not.toHaveProperty("icon_custom_emoji_id"); + }); + + it("fetches the icon list once across two creates", async () => { + const calls = stubTelegramApi((method) => + method === "getForumTopicIconStickers" ? stickers : topicResult, + ); + const manager = makeManager(); + + await manager.create(-1001234, "A", OPTS); + await manager.create(-1001234, "B", OPTS); + + expect(calls.filter((c) => c.method === "getForumTopicIconStickers")).toHaveLength(1); + expect(calls.filter((c) => c.method === "createForumTopic")).toHaveLength(2); + }); + + it("does not cache a failed fetch: the next create refetches", async () => { + let fetches = 0; + const calls = stubTelegramApi((method) => { + if (method !== "getForumTopicIconStickers") return topicResult; + fetches += 1; + return fetches === 1 ? fail(500, "Internal Server Error") : stickers; + }); + const manager = makeManager(); + + await manager.create(-1001234, "A", OPTS); + await manager.create(-1001234, "B", OPTS); + + expect(fetches).toBe(2); + const creates = calls.filter((c) => c.method === "createForumTopic"); + expect(creates[0]?.body).not.toHaveProperty("icon_custom_emoji_id"); + expect(creates[1]?.body).toMatchObject({ name: "B", icon_custom_emoji_id: "5309" }); + }); + + it("does not fetch icons when no hint is given", async () => { + const calls = stubTelegramApi(() => topicResult); + + await makeManager().create(-1001234, "Plain"); + + expect(calls.map((c) => c.method)).toEqual(["createForumTopic"]); + }); +}); diff --git a/test/domain/hackathon/topic.test.ts b/test/domain/hackathon/topic.test.ts index 975a92a..1d293da 100644 --- a/test/domain/hackathon/topic.test.ts +++ b/test/domain/hackathon/topic.test.ts @@ -1,30 +1,49 @@ import { describe, expect, it } from "vitest"; import { topicLink, topicNameFor } from "../../../src/domain/hackathon/topic"; -describe("topicNameFor", () => { - it("prefixes the name with the trophy and collapses whitespace", () => { - expect(topicNameFor("Meridian Hack\n2026", "meridian")).toBe("๐Ÿ† Meridian Hack 2026"); +describe("topicNameFor with the icon applied", () => { + it("is the plain sanitized name, whitespace collapsed, no emoji", () => { + expect(topicNameFor("Meridian Hack\n2026", "meridian", true)).toBe("Meridian Hack 2026"); }); it("strips control and bidi format characters", () => { - expect(topicNameFor("Mer\u0000idโ€ฎianโ€‹", "meridian")).toBe("๐Ÿ† Meridian"); + expect(topicNameFor("Mer\u0000id\u202Eian\u200B", "meridian", true)).toBe("Meridian"); }); it("falls back to the slug when the name is missing or empty after sanitizing", () => { - expect(topicNameFor(undefined, "meridian")).toBe("๐Ÿ† meridian"); - expect(topicNameFor(null, "meridian")).toBe("๐Ÿ† meridian"); - expect(topicNameFor(" โ€‹\n ", "meridian-2")).toBe("๐Ÿ† meridian-2"); + expect(topicNameFor(undefined, "meridian", true)).toBe("meridian"); + expect(topicNameFor(null, "meridian", true)).toBe("meridian"); + expect(topicNameFor(" โ€‹\n ", "meridian-2", true)).toBe("meridian-2"); + }); + + it("keeps a name of exactly 128 UTF-16 units untouched", () => { + const name = "a".repeat(128); + expect(topicNameFor(name, "s", true)).toBe(name); + }); + + it("cuts an overlong name to at most 128 units and ends with an ellipsis", () => { + const out = topicNameFor("b".repeat(300), "s", true); + expect(out.length).toBeLessThanOrEqual(128); + expect(out.startsWith("bbb")).toBe(true); + expect(out.endsWith("โ€ฆ")).toBe(true); + }); +}); + +describe("topicNameFor without an icon (fallback)", () => { + it("prefixes the name with the trophy", () => { + expect(topicNameFor("Meridian Hack\n2026", "meridian", false)).toBe("๐Ÿ† Meridian Hack 2026"); + expect(topicNameFor(null, "meridian", false)).toBe("๐Ÿ† meridian"); }); it("keeps a name that fits within 128 UTF-16 units untouched", () => { const name = "a".repeat(128 - "๐Ÿ† ".length); - const out = topicNameFor(name, "s"); + const out = topicNameFor(name, "s", false); expect(out).toBe(`๐Ÿ† ${name}`); expect(out.length).toBe(128); }); it("cuts an overlong name to at most 128 units and ends with an ellipsis", () => { - const out = topicNameFor("b".repeat(300), "s"); + const out = topicNameFor("b".repeat(300), "s", false); expect(out.length).toBeLessThanOrEqual(128); expect(out.startsWith("๐Ÿ† bbb")).toBe(true); expect(out.endsWith("โ€ฆ")).toBe(true); @@ -32,10 +51,10 @@ describe("topicNameFor", () => { it("never splits a surrogate pair when cutting", () => { // "๐Ÿ† " is 3 units; each ๐Ÿ˜€ is 2 units, so the cut lands mid-pair. - const out = topicNameFor("๐Ÿ˜€".repeat(100), "s"); + const out = topicNameFor("๐Ÿ˜€".repeat(100), "s", false); expect(out.length).toBeLessThanOrEqual(128); expect(out.endsWith("โ€ฆ")).toBe(true); - expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { const result = await participateInHackathon(input(), deps); expect(deps.forumTopicManager.created).toEqual([ - { chatId: CHAT_ID, name: "๐Ÿ† Meridian Hack 2026" }, + { + chatId: CHAT_ID, + name: "Meridian Hack 2026", + iconEmoji: "๐Ÿ†", + fallbackName: "๐Ÿ† Meridian Hack 2026", + }, ]); expect(row(deps).threadId).toBe(77); expect(deps.chatPublisher.posted).toHaveLength(1); @@ -161,7 +166,11 @@ describe("participateInHackathon: happy path", () => { create: [{ threadId: 5 }], }); const result = await participateInHackathon(input(), deps); - expect(deps.forumTopicManager.created[0]?.name).toBe("๐Ÿ† meridian"); + expect(deps.forumTopicManager.created[0]).toMatchObject({ + name: "meridian", + iconEmoji: "๐Ÿ†", + fallbackName: "๐Ÿ† meridian", + }); expect(result.replyText).toBe(`โœ… Participamos en meridian โ†’ ${LINK(5)}`); }); diff --git a/test/fakes/index.ts b/test/fakes/index.ts index 1df05f1..d8afab9 100644 --- a/test/fakes/index.ts +++ b/test/fakes/index.ts @@ -30,6 +30,7 @@ import type { ChatPublisher, Clock, ForumTopicManager, + TopicCreateOptions, DmSelectionRepo, GithubOrgClaimRepo, HackathonAnalysisRepo, @@ -590,15 +591,15 @@ export type TopicCreateStep = { threadId: number } | { fails: TopicCreateFailure export function fakeForumTopicManager( opts: { create?: TopicCreateStep[] } = {}, ): ForumTopicManager & { - created: Array<{ chatId: number; name: string }>; + created: Array<{ chatId: number; name: string; iconEmoji?: string; fallbackName?: string }>; } { - const created: Array<{ chatId: number; name: string }> = []; + const created: Array<{ chatId: number; name: string; iconEmoji?: string; fallbackName?: string }> = []; let createIdx = 0; let nextThreadId = 1000; return { created, - create: async (chatId: number, name: string) => { - created.push({ chatId, name }); + create: async (chatId: number, name: string, hint?: TopicCreateOptions) => { + created.push({ chatId, name, ...hint }); const script = opts.create; const step = script && script.length > 0 ? script[Math.min(createIdx, script.length - 1)] : undefined; createIdx += 1; diff --git a/test/http/hackathon-command-e2e.test.ts b/test/http/hackathon-command-e2e.test.ts index b7925ff..e9baf03 100644 --- a/test/http/hackathon-command-e2e.test.ts +++ b/test/http/hackathon-command-e2e.test.ts @@ -143,6 +143,7 @@ describe("POST /telegram/webhook โ€” /hackathon through real composition", describe("POST /telegram/webhook โ€” /hackathon join through real composition", () => { const stubForum = () => stubTelegramApi((method) => { + if (method === "getForumTopicIconStickers") return [{ emoji: "๐Ÿ†", custom_emoji_id: "5309" }]; if (method === "createForumTopic") return { message_thread_id: 4242, name: "x", icon_color: 0 }; return undefined; }); @@ -202,7 +203,8 @@ describe("POST /telegram/webhook โ€” /hackathon join through real composition", expect(row).toEqual({ thread_id: 4242, general_message_id: 321 }); expect(calls.filter((c) => c.method === "createForumTopic")).toHaveLength(1); expect(calls.find((c) => c.method === "createForumTopic")?.body).toMatchObject({ - name: "๐Ÿ† Meridian Hack", + name: "Meridian Hack", + icon_custom_emoji_id: "5309", }); const clear = calls.find((c) => c.method === "editMessageReplyMarkup"); expect(clear?.body).toMatchObject({ chat_id: chatId, message_id: 321 }); From ffd3ab725118e977d51b3fb2ad558cf066e4d732 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:57:18 -0400 Subject: [PATCH 2/3] fix(participation): pace the fresh-topic flow with short delays before the post and the pin --- src/adapters/telegram/hackathon-commands.ts | 2 + src/composition.ts | 1 + src/domain/ports.ts | 3 + src/domain/usecases/link-analysis-to-topic.ts | 17 ++++- .../usecases/participate-in-hackathon.ts | 15 +++- test/adapters/telegram/commands.test.ts | 2 + test/adapters/telegram/participation.test.ts | 2 + .../usecases/link-analysis-to-topic.test.ts | 57 +++++++++++++- .../usecases/participate-in-hackathon.test.ts | 74 ++++++++++++++++++- test/fakes/index.ts | 12 +++ 10 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/adapters/telegram/hackathon-commands.ts b/src/adapters/telegram/hackathon-commands.ts index 8a9a093..7c3def1 100644 --- a/src/adapters/telegram/hackathon-commands.ts +++ b/src/adapters/telegram/hackathon-commands.ts @@ -14,6 +14,7 @@ import type { Logger, MemberRepo, MembershipRepo, + Sleep, TeamRepo, } from "../../domain/ports"; import { linkAnalysisToTopic } from "../../domain/usecases/link-analysis-to-topic"; @@ -45,6 +46,7 @@ export interface HackathonCommandDeps { analysisJobRepo: AnalysisJobRepo; chatPublisher: ChatPublisher; forumTopicManager: ForumTopicManager; + sleep: Sleep; clock: Clock; idGen: IdGen; logger: Logger; diff --git a/src/composition.ts b/src/composition.ts index 32eb081..e44c5fc 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -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, diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 581c52f..f256b43 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -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; + export interface IdGen { newId(): string; } diff --git a/src/domain/usecases/link-analysis-to-topic.ts b/src/domain/usecases/link-analysis-to-topic.ts index 58f5ae3..1266e5d 100644 --- a/src/domain/usecases/link-analysis-to-topic.ts +++ b/src/domain/usecases/link-analysis-to-topic.ts @@ -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; @@ -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 ` // run inside a topic now links and pins from the consumer"). Reused by @@ -84,7 +94,7 @@ export async function postAnalysisAndLinkTopic( input: PostAnalysisAndLinkTopicInput, deps: PostAnalysisAndLinkTopicDeps, ): Promise { - 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, @@ -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 diff --git a/src/domain/usecases/participate-in-hackathon.ts b/src/domain/usecases/participate-in-hackathon.ts index 7c2aaa4..12458ce 100644 --- a/src/domain/usecases/participate-in-hackathon.ts +++ b/src/domain/usecases/participate-in-hackathon.ts @@ -19,6 +19,7 @@ import type { HackathonAnalysisRepo, Logger, MembershipRepo, + Sleep, } from "../ports"; import { postAnalysisAndLinkTopic } from "./link-analysis-to-topic"; @@ -26,6 +27,14 @@ import { postAnalysisAndLinkTopic } from "./link-analysis-to-topic"; // 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; @@ -42,6 +51,7 @@ export interface ParticipateInHackathonDeps { chatPublisher: ChatPublisher; forumTopicManager: ForumTopicManager; clock: Clock; + sleep: Sleep; logger: Logger; } @@ -137,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; diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index b9e81fb..d96341a 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -19,6 +19,7 @@ import { fakeIdGen, fakeMemberRepo, fakeForumTopicManager, + fakeSleep, fakeMembershipRepo, fakeProfileRepo, fakeRepoTopicLinkRepo, @@ -115,6 +116,7 @@ function makeBot( analysisJobRepo, chatPublisher, forumTopicManager, + sleep: fakeSleep(), clock: fakeClock(), idGen: fakeIdGen(), logger: createSafeLogger(), diff --git a/test/adapters/telegram/participation.test.ts b/test/adapters/telegram/participation.test.ts index 6926d0e..5aed176 100644 --- a/test/adapters/telegram/participation.test.ts +++ b/test/adapters/telegram/participation.test.ts @@ -8,6 +8,7 @@ import { fakeChatPublisher, fakeClock, fakeForumTopicManager, + fakeSleep, fakeHackathonAnalysisRepo, fakeLogger, fakeMemberRepo, @@ -66,6 +67,7 @@ function setup(opts: { role?: "admin" | "member"; create?: TopicCreateStep[]; se chatPublisher: fakeChatPublisher(), forumTopicManager: fakeForumTopicManager(opts.create ? { create: opts.create } : {}), clock: fakeClock(), + sleep: fakeSleep(), logger: fakeLogger(), }; const replies: string[] = []; diff --git a/test/domain/usecases/link-analysis-to-topic.test.ts b/test/domain/usecases/link-analysis-to-topic.test.ts index 78bd6ef..64b8f07 100644 --- a/test/domain/usecases/link-analysis-to-topic.test.ts +++ b/test/domain/usecases/link-analysis-to-topic.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { linkAnalysisToTopic } from "../../../src/domain/usecases/link-analysis-to-topic"; +import { + linkAnalysisToTopic, + postAnalysisAndLinkTopic, +} from "../../../src/domain/usecases/link-analysis-to-topic"; import { REPLY_MAX } from "../../../src/domain/hackathon/format"; import { AnalysisNotFoundError, @@ -14,6 +17,7 @@ import { fakeLogger, fakeMemberRepo, fakeMembershipRepo, + fakeSleep, } from "../../fakes"; const teamId = asTeamId("team-1"); @@ -242,3 +246,54 @@ describe("linkAnalysisToTopic", () => { ).rejects.toThrow(NotFoundError); }); }); + +describe("postAnalysisAndLinkTopic: pin delay", () => { + const row = () => analysis({ id: "a-pin", slug: "pin" }); + + it("waits pinDelayMs between the post and the pin when asked", async () => { + const deps = makeDeps(); + const events: string[] = []; + const realPost = deps.chatPublisher.post; + const realPin = deps.chatPublisher.pin; + deps.chatPublisher.post = async (...args) => { + events.push("post"); + return realPost(...args); + }; + deps.chatPublisher.pin = async (...args) => { + events.push("pin"); + return realPin(...args); + }; + const sleep = fakeSleep((ms) => events.push(`sleep:${ms}`)); + deps.hackathonAnalysisRepo.rows.push(row()); + + await postAnalysisAndLinkTopic( + { teamId, chatId: 10, threadId: 5, analysis: row(), pinDelayMs: 750 }, + { ...deps, sleep }, + ); + + expect(events).toEqual(["post", "sleep:750", "pin"]); + }); + + it("never sleeps without pinDelayMs, even when a sleep dep is present", async () => { + const deps = makeDeps(); + const sleep = fakeSleep(); + deps.hackathonAnalysisRepo.rows.push(row()); + + await postAnalysisAndLinkTopic( + { teamId, chatId: 10, threadId: 5, analysis: row() }, + { ...deps, sleep }, + ); + + expect(sleep.calls).toEqual([]); + expect(deps.chatPublisher.pinned).toHaveLength(1); + }); + + it("works with no sleep dep at all (existing callers)", async () => { + const deps = makeDeps(); + deps.hackathonAnalysisRepo.rows.push(row()); + + await postAnalysisAndLinkTopic({ teamId, chatId: 10, threadId: 5, analysis: row() }, deps); + + expect(deps.chatPublisher.pinned).toHaveLength(1); + }); +}); diff --git a/test/domain/usecases/participate-in-hackathon.test.ts b/test/domain/usecases/participate-in-hackathon.test.ts index c8c0e46..b5f3c46 100644 --- a/test/domain/usecases/participate-in-hackathon.test.ts +++ b/test/domain/usecases/participate-in-hackathon.test.ts @@ -10,7 +10,11 @@ import { } from "../../../src/domain/errors"; import type { HackathonAnalysis } from "../../../src/domain/entities"; import { asMemberId, asMembershipId, asTeamId } from "../../../src/domain/ids"; -import { participateInHackathon } from "../../../src/domain/usecases/participate-in-hackathon"; +import { + PIN_DELAY_MS, + TOPIC_POST_DELAY_MS, + participateInHackathon, +} from "../../../src/domain/usecases/participate-in-hackathon"; import { fakeChatPublisher, fakeClock, @@ -19,6 +23,7 @@ import { fakeLogger, fakeMemberRepo, fakeMembershipRepo, + fakeSleep, } from "../../fakes"; import type { TopicCreateStep } from "../../fakes"; @@ -83,6 +88,7 @@ function setup( ...(opts.create ? { create: opts.create } : {}), }), clock: fakeClock(), + sleep: fakeSleep(), logger: fakeLogger(), }; } @@ -477,3 +483,69 @@ describe("participateInHackathon: after the topic exists (never rethrows)", () = expect(deps.chatPublisher.cleared).toEqual([]); }); }); + +describe("participateInHackathon: pacing on a fresh topic", () => { + it("waits create -> sleep -> post -> sleep -> pin", async () => { + const deps = setup({ create: [{ threadId: 77 }] }); + const events: string[] = []; + const realPost = deps.chatPublisher.post; + const realPin = deps.chatPublisher.pin; + const realCreate = deps.forumTopicManager.create; + deps.forumTopicManager.create = async (...args) => { + const id = await realCreate(...args); + events.push("create"); + return id; + }; + deps.chatPublisher.post = async (...args) => { + events.push("post"); + return realPost(...args); + }; + deps.chatPublisher.pin = async (...args) => { + events.push("pin"); + return realPin(...args); + }; + deps.sleep = fakeSleep((ms) => events.push(`sleep:${ms}`)); + + const result = await participateInHackathon(input(), deps); + + expect(result.kind).toBe("created"); + expect(events).toEqual([ + "create", + `sleep:${TOPIC_POST_DELAY_MS}`, + "post", + `sleep:${PIN_DELAY_MS}`, + "pin", + ]); + expect(TOPIC_POST_DELAY_MS).toBe(1500); + expect(PIN_DELAY_MS).toBe(1000); + }); + + it("does not sleep when the topic already exists and is verified", async () => { + const deps = setup({ row: { threadId: 40, pinnedMessageId: 8 } }); + + const result = await participateInHackathon(input(), deps); + + expect(result.kind).toBe("already"); + expect(deps.sleep.calls).toEqual([]); + }); + + it("does not sleep when creation fails", async () => { + const deps = setup({ create: [{ fails: "no-rights" }] }); + + await expect(participateInHackathon(input(), deps)).rejects.toThrow(); + + expect(deps.sleep.calls).toEqual([]); + }); + + it("does not sleep when the link save fails (nothing is posted)", async () => { + const deps = setup({ create: [{ threadId: 77 }] }); + deps.hackathonAnalysisRepo.moveTopicLink = async () => { + throw new Error("d1 down"); + }; + + const result = await participateInHackathon(input(), deps); + + expect(result.kind).toBe("linkFailed"); + expect(deps.sleep.calls).toEqual([]); + }); +}); diff --git a/test/fakes/index.ts b/test/fakes/index.ts index d8afab9..0d63bf5 100644 --- a/test/fakes/index.ts +++ b/test/fakes/index.ts @@ -30,6 +30,7 @@ import type { ChatPublisher, Clock, ForumTopicManager, + Sleep, TopicCreateOptions, DmSelectionRepo, GithubOrgClaimRepo, @@ -633,3 +634,14 @@ export function fakeLogger(): Logger & { entries: LogEvent[] } { }, }; } + +// Records every requested delay and resolves at once (no real waiting). +// `onSleep` lets a test interleave the calls with other fakes' events. +export function fakeSleep(onSleep?: (ms: number) => void): Sleep & { calls: number[] } { + const calls: number[] = []; + const sleep = async (ms: number) => { + calls.push(ms); + onSleep?.(ms); + }; + return Object.assign(sleep, { calls }); +} From 39600ba5ac756bed206a048c8d0d0e942faea85c Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Tue, 29 Sep 2026 18:57:42 -0400 Subject: [PATCH 3/3] docs(hackathon-participation): document the topic icon and the fresh-topic pacing --- .../hackathon-participation/apply-progress.md | 9 +++++++++ .../changes/hackathon-participation/design.md | 6 +++--- .../hackathon-participation/proposal.md | 2 +- .../specs/hackathon-participation/spec.md | 20 ++++++++++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/openspec/changes/hackathon-participation/apply-progress.md b/openspec/changes/hackathon-participation/apply-progress.md index e240c15..992943d 100644 --- a/openspec/changes/hackathon-participation/apply-progress.md +++ b/openspec/changes/hackathon-participation/apply-progress.md @@ -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. diff --git a/openspec/changes/hackathon-participation/design.md b/openspec/changes/hackathon-participation/design.md index 45fcc40..403095f 100644 --- a/openspec/changes/hackathon-participation/design.md +++ b/openspec/changes/hackathon-participation/design.md @@ -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 @@ -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; // throws ForumTopicCreateError(failure) + create(chatId: number, name: string, options?: { iconEmoji: string; fallbackName: string }): Promise; // throws ForumTopicCreateError(failure) } interface ChatPublisher { post(chatId, threadId, text, options?: { participateSlug?: string }): Promise; pin; unpin; clearButtons(chatId: number, messageId: number): Promise; } @@ -72,7 +72,7 @@ setGeneralMessageId(team, id, messageId: number): Promise; - 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) diff --git a/openspec/changes/hackathon-participation/proposal.md b/openspec/changes/hackathon-participation/proposal.md index 8a1c0d8..8d56be0 100644 --- a/openspec/changes/hackathon-participation/proposal.md +++ b/openspec/changes/hackathon-participation/proposal.md @@ -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:`, 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 `๐Ÿ† `: +- Topic 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. diff --git a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md index bc83a92..afce09d 100644 --- a/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md +++ b/openspec/changes/hackathon-participation/specs/hackathon-participation/spec.md @@ -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 `๐Ÿ† `, 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. +The system MUST create the topic named `` (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 `๐Ÿ† `. Here `` 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 "๐Ÿ† " +- THEN the topic is named "" #### Scenario: Overlong name