From 8c43768e27b0e048899f1ce94ed07de7210425ae Mon Sep 17 00:00:00 2001 From: "Ptah.ai" <11701+ptahdunbar@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:08:14 -0400 Subject: [PATCH] fix: expose telegram topic names in agent context (#65973) (thanks @ptahdunbar) * feat(telegram): expose forum topic names in agent context Telegram Bot API does not provide a method to look up forum topic names by thread ID. This adds an in-memory LRU cache that learns topic names from service messages (forum_topic_created, forum_topic_edited, forum_topic_closed, forum_topic_reopened) and seeds from reply_to_message.forum_topic_created as a fallback for pre-existing topics. The resolved topic name is surfaced as: - TopicName in MsgContext (available to {{TopicName}} in templates) - topic_name in the agent prompt metadata block - topicName in plugin hook event metadata Includes unit tests for the topic-name-cache module (11 tests including eviction and read-recency). Known limitation: cache is in-memory only; after a restart it falls back to the creation-time name until a rename event is observed. * refactor(telegram): distill topic name flow * fix: expose telegram topic names in agent context (#65973) (thanks @ptahdunbar) --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 2 + .../bot-message-context.dm-threads.test.ts | 18 +++ .../src/bot-message-context.session.ts | 3 + .../telegram/src/bot-message-context.ts | 135 ++++++++++++------ .../telegram/src/topic-name-cache.test.ts | 94 ++++++++++++ extensions/telegram/src/topic-name-cache.ts | 79 ++++++++++ src/auto-reply/reply/inbound-meta.test.ts | 14 ++ src/auto-reply/reply/inbound-meta.ts | 1 + src/auto-reply/templating.ts | 2 + src/hooks/message-hook-mappers.test.ts | 4 +- src/hooks/message-hook-mappers.ts | 5 + 11 files changed, 314 insertions(+), 43 deletions(-) create mode 100644 extensions/telegram/src/topic-name-cache.test.ts create mode 100644 extensions/telegram/src/topic-name-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0ff441240..5c21d7bbd20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar. + ### Fixes - Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index 9583ff3e478..11ba27bcf7a 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -143,6 +143,24 @@ describe("buildTelegramMessageContext group sessions without forum", () => { expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99"); expect(ctx?.ctxPayload?.MessageThreadId).toBe(99); }); + + it("surfaces topic name from reply_to_message forum metadata", async () => { + const ctx = await buildContext({ + message_id: 3, + chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true }, + date: 1700000002, + text: "@bot hello", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + reply_to_message: { + message_id: 2, + forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 }, + }, + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); + }); }); describe("buildTelegramMessageContext direct peer routing", () => { diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 73d20d37969..33f45babc21 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -105,6 +105,7 @@ export async function buildTelegramInboundContextPayload(params: { options?: TelegramMessageContextOptions; dmAllowFrom?: Array; effectiveGroupAllow?: NormalizedAllowFrom; + topicName?: string; sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides; }): Promise<{ ctxPayload: FinalizedTelegramInboundContext; @@ -139,6 +140,7 @@ export async function buildTelegramInboundContextPayload(params: { options, dmAllowFrom, effectiveGroupAllow, + topicName, sessionRuntime: sessionRuntimeOverride, } = params; const replyTarget = describeReplyTarget(msg); @@ -349,6 +351,7 @@ export async function buildTelegramInboundContextPayload(params: { CommandSource: options?.commandSource, MessageThreadId: threadSpec.id, IsForum: isForum, + TopicName: isForum && topicName ? topicName : undefined, OriginatingChannel: "telegram" as const, OriginatingTo: `telegram:${chatId}`, }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 484485a255a..c653cb2b9ae 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -2,10 +2,8 @@ import type { ReactionTypeEmoji } from "@grammyjs/types"; import { resolveAckReaction, shouldAckReaction as shouldAckReactionGate, - type StatusReactionController, } from "openclaw/plugin-sdk/channel-feedback"; import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; -import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -35,6 +33,7 @@ import { resolveTelegramReactionVariant, resolveTelegramStatusReactionEmojis, } from "./status-reaction-variants.js"; +import { getTopicName, updateTopicName } from "./topic-name-cache.js"; export type { BuildTelegramMessageContextParams, @@ -56,6 +55,15 @@ type TelegramReactionApi = ( messageId: number, reactions: Array<{ type: "emoji"; emoji: ReactionTypeEmoji["emoji"] }>, ) => Promise; +type TelegramStatusReactionController = { + setQueued: () => void | Promise; + setThinking: () => void | Promise; + setTool: (name: string) => void | Promise; + setCompacting: () => void | Promise; + cancelPending: () => void; + setError: () => void | Promise; + setDone: () => void | Promise; +}; export type TelegramMessageContext = { ctxPayload: TelegramMessageContextPayload["ctxPayload"]; @@ -83,7 +91,7 @@ export type TelegramMessageContext = { ackReactionPromise: Promise | null; reactionApi: TelegramReactionApi | null; removeAckAfterReply: boolean; - statusReactionController: StatusReactionController | null; + statusReactionController: TelegramStatusReactionController | null; accountId: string; }; @@ -140,6 +148,45 @@ export const buildTelegramMessageContext = async ({ const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined; const replyThreadId = threadSpec.id; const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; + let topicName: string | undefined; + if (isForum && resolvedThreadId != null) { + const ftCreated = msg.forum_topic_created; + const ftEdited = msg.forum_topic_edited; + const ftClosed = msg.forum_topic_closed; + const ftReopened = msg.forum_topic_reopened; + + if (ftCreated?.name) { + updateTopicName(chatId, resolvedThreadId, { + name: ftCreated.name, + iconColor: ftCreated.icon_color, + iconCustomEmojiId: ftCreated.icon_custom_emoji_id, + closed: false, + }); + } else if (ftEdited?.name) { + updateTopicName(chatId, resolvedThreadId, { + name: ftEdited.name, + iconCustomEmojiId: ftEdited.icon_custom_emoji_id, + }); + } else if (ftClosed) { + updateTopicName(chatId, resolvedThreadId, { closed: true }); + } else if (ftReopened) { + updateTopicName(chatId, resolvedThreadId, { closed: false }); + } + + topicName = getTopicName(chatId, resolvedThreadId); + if (!topicName) { + const replyFtCreated = msg.reply_to_message?.forum_topic_created; + if (replyFtCreated?.name) { + updateTopicName(chatId, resolvedThreadId, { + name: replyFtCreated.name, + iconColor: replyFtCreated.icon_color, + iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id, + }); + topicName = replyFtCreated.name; + } + } + } + const threadIdForConfig = resolvedThreadId ?? dmThreadId; const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig); // Use direct config dmPolicy override if available for DMs @@ -219,7 +266,7 @@ export const buildTelegramMessageContext = async ({ return null; } - const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic; + const requireTopic = groupConfig?.requireTopic; const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null; if (topicRequiredButMissing) { logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`); @@ -330,7 +377,7 @@ export const buildTelegramMessageContext = async ({ const requireMention = firstDefined( activationOverride, topicConfig?.requireMention, - (groupConfig as TelegramGroupConfig | undefined)?.requireMention, + groupConfig?.requireMention, baseRequireMention, ); @@ -413,46 +460,49 @@ export const buildTelegramMessageContext = async ({ ? (runtime?.createStatusReactionController ?? (await loadTelegramMessageContextRuntime()).createStatusReactionController) : null; - const statusReactionController: StatusReactionController | null = createStatusReactionController - ? createStatusReactionController({ - enabled: true, - adapter: { - setReaction: async (emoji: string) => { - if (reactionApi) { - if (!allowedStatusReactionEmojisPromise) { - allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({ - chat: msg.chat, - chatId, - getChat: getChatApi ?? undefined, - }).catch((err) => { - logVerbose( - `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, - ); - return null; + const statusReactionController: TelegramStatusReactionController | null = + createStatusReactionController + ? createStatusReactionController({ + enabled: true, + adapter: { + setReaction: async (emoji: string) => { + if (reactionApi) { + if (!allowedStatusReactionEmojisPromise) { + allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({ + chat: msg.chat, + chatId, + getChat: getChatApi ?? undefined, + }).catch((err) => { + logVerbose( + `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, + ); + return null; + }); + } + const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; + const resolvedEmoji = resolveTelegramReactionVariant({ + requestedEmoji: emoji, + variantsByRequestedEmoji: statusReactionVariantsByEmoji, + allowedEmojiReactions: allowedStatusReactionEmojis, }); + if (!resolvedEmoji) { + return; + } + await reactionApi(chatId, msg.message_id, [ + { type: "emoji", emoji: resolvedEmoji }, + ]); } - const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; - const resolvedEmoji = resolveTelegramReactionVariant({ - requestedEmoji: emoji, - variantsByRequestedEmoji: statusReactionVariantsByEmoji, - allowedEmojiReactions: allowedStatusReactionEmojis, - }); - if (!resolvedEmoji) { - return; - } - await reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: resolvedEmoji }]); - } + }, + // Telegram replaces atomically — no removeReaction needed }, - // Telegram replaces atomically — no removeReaction needed - }, - initialEmoji: ackReaction, - emojis: resolvedStatusReactionEmojis, - timing: statusReactionsConfig?.timing, - onError: (err) => { - logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`); - }, - }) - : null; + initialEmoji: ackReaction, + emojis: resolvedStatusReactionEmojis, + timing: statusReactionsConfig?.timing, + onError: (err) => { + logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`); + }, + }) + : null; // When status reactions are enabled, setQueued() replaces the simple ack reaction const ackReactionPromise: Promise | null = statusReactionController @@ -505,6 +555,7 @@ export const buildTelegramMessageContext = async ({ dmAllowFrom, effectiveGroupAllow, commandAuthorized: bodyResult.commandAuthorized, + topicName, sessionRuntime, }); diff --git a/extensions/telegram/src/topic-name-cache.test.ts b/extensions/telegram/src/topic-name-cache.test.ts new file mode 100644 index 00000000000..b11dff4be5c --- /dev/null +++ b/extensions/telegram/src/topic-name-cache.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + clearTopicNameCache, + getTopicEntry, + getTopicName, + topicNameCacheSize, + updateTopicName, +} from "./topic-name-cache.js"; + +describe("topic-name-cache", () => { + beforeEach(() => { + clearTopicNameCache(); + }); + + it("stores and retrieves a topic name", () => { + updateTopicName(-100123, 42, { name: "Deployments" }); + expect(getTopicName(-100123, 42)).toBe("Deployments"); + }); + + it("returns undefined for unknown topics", () => { + expect(getTopicName(-100123, 99)).toBeUndefined(); + }); + + it("handles renames via forum_topic_edited (overwrites previous name)", () => { + updateTopicName(-100123, 42, { name: "Deployments" }); + updateTopicName(-100123, 42, { name: "CI/CD" }); + expect(getTopicName(-100123, 42)).toBe("CI/CD"); + }); + + it("preserves name when patching only closed status", () => { + updateTopicName(-100123, 42, { name: "Deployments" }); + updateTopicName(-100123, 42, { closed: true }); + expect(getTopicName(-100123, 42)).toBe("Deployments"); + expect(getTopicEntry(-100123, 42)?.closed).toBe(true); + }); + + it("marks topic as reopened", () => { + updateTopicName(-100123, 42, { name: "Deployments", closed: true }); + updateTopicName(-100123, 42, { closed: false }); + expect(getTopicEntry(-100123, 42)?.closed).toBe(false); + }); + + it("stores icon metadata", () => { + updateTopicName(-100123, 42, { + name: "Design", + iconColor: 0x6fb9f0, + iconCustomEmojiId: "emoji123", + }); + const entry = getTopicEntry(-100123, 42); + expect(entry?.iconColor).toBe(0x6fb9f0); + expect(entry?.iconCustomEmojiId).toBe("emoji123"); + }); + + it("does not store entries with empty name and no prior entry", () => { + updateTopicName(-100123, 42, { closed: true }); + expect(getTopicName(-100123, 42)).toBeUndefined(); + expect(topicNameCacheSize()).toBe(0); + }); + + it("updates timestamps on write", async () => { + updateTopicName(-100123, 42, { name: "A" }); + const t1 = getTopicEntry(-100123, 42)?.updatedAt ?? 0; + await new Promise((r) => setTimeout(r, 10)); + updateTopicName(-100123, 42, { name: "B" }); + const t2 = getTopicEntry(-100123, 42)?.updatedAt ?? 0; + expect(t2).toBeGreaterThan(t1); + }); + + it("works with string chatId and threadId", () => { + updateTopicName("-100123", "42", { name: "StringKeys" }); + expect(getTopicName("-100123", "42")).toBe("StringKeys"); + }); + + it("evicts the oldest entry when cache exceeds 2048", () => { + for (let i = 0; i < 2049; i++) { + updateTopicName(-100000, i, { name: `Topic ${i}` }); + } + expect(topicNameCacheSize()).toBe(2048); + expect(getTopicName(-100000, 0)).toBeUndefined(); + expect(getTopicName(-100000, 2048)).toBe("Topic 2048"); + }); + + it("refreshes recency on read so active topics survive eviction", async () => { + updateTopicName(-100000, 1, { name: "Active" }); + await new Promise((r) => setTimeout(r, 10)); + for (let i = 2; i <= 2048; i++) { + updateTopicName(-100000, i, { name: `Topic ${i}` }); + } + getTopicName(-100000, 1); + updateTopicName(-100000, 9999, { name: "Newcomer" }); + expect(getTopicName(-100000, 1)).toBe("Active"); + expect(topicNameCacheSize()).toBe(2048); + }); +}); diff --git a/extensions/telegram/src/topic-name-cache.ts b/extensions/telegram/src/topic-name-cache.ts new file mode 100644 index 00000000000..8b2ddf8735e --- /dev/null +++ b/extensions/telegram/src/topic-name-cache.ts @@ -0,0 +1,79 @@ +const MAX_ENTRIES = 2_048; + +export type TopicEntry = { + name: string; + iconColor?: number; + iconCustomEmojiId?: string; + closed?: boolean; + updatedAt: number; +}; + +const cache = new Map(); + +function cacheKey(chatId: number | string, threadId: number | string): string { + return `${chatId}:${threadId}`; +} + +function evictOldest(): void { + if (cache.size <= MAX_ENTRIES) { + return; + } + let oldestKey: string | undefined; + let oldestTime = Infinity; + for (const [key, entry] of cache) { + if (entry.updatedAt < oldestTime) { + oldestTime = entry.updatedAt; + oldestKey = key; + } + } + if (oldestKey) { + cache.delete(oldestKey); + } +} + +export function updateTopicName( + chatId: number | string, + threadId: number | string, + patch: Partial>, +): void { + const key = cacheKey(chatId, threadId); + const existing = cache.get(key); + const merged: TopicEntry = { + name: patch.name ?? existing?.name ?? "", + iconColor: patch.iconColor ?? existing?.iconColor, + iconCustomEmojiId: patch.iconCustomEmojiId ?? existing?.iconCustomEmojiId, + closed: patch.closed ?? existing?.closed, + updatedAt: Date.now(), + }; + if (!merged.name) { + return; + } + cache.set(key, merged); + evictOldest(); +} + +export function getTopicName( + chatId: number | string, + threadId: number | string, +): string | undefined { + const entry = cache.get(cacheKey(chatId, threadId)); + if (entry) { + entry.updatedAt = Date.now(); + } + return entry?.name; +} + +export function getTopicEntry( + chatId: number | string, + threadId: number | string, +): TopicEntry | undefined { + return cache.get(cacheKey(chatId, threadId)); +} + +export function clearTopicNameCache(): void { + cache.clear(); +} + +export function topicNameCacheSize(): number { + return cache.size; +} diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 0b04890a920..72ef3d0079d 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -280,6 +280,20 @@ describe("buildInboundUserContextPrefix", () => { expect(text).toContain('"conversation_label": "ops-room"'); }); + it("includes topic_name for forum chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + IsForum: true, + MessageThreadId: 42, + TopicName: "Deployments", + } as TemplateContext); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["topic_id"]).toBe("42"); + expect(conversationInfo["topic_name"]).toBe("Deployments"); + expect(conversationInfo["is_forum"]).toBe(true); + }); + it("includes sender identifier in conversation info", () => { const text = buildInboundUserContextPrefix({ ChatType: "group", diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 42f80deae45..d2a641da874 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -196,6 +196,7 @@ export function buildInboundUserContextPrefix( ctx.MessageThreadId != null ? (normalizePromptMetadataString(String(ctx.MessageThreadId)) ?? undefined) : undefined, + topic_name: normalizePromptMetadataString(ctx.TopicName) ?? undefined, is_forum: ctx.IsForum === true ? true : undefined, is_group_chat: !isDirect ? true : undefined, was_mentioned: ctx.WasMentioned === true ? true : undefined, diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 2d815dc6f98..4ff36ff7387 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -167,6 +167,8 @@ export type MsgContext = { NativeDirectUserId?: string; /** Telegram forum supergroup marker. */ IsForum?: boolean; + /** Human-readable Telegram forum topic name (cached from service messages). */ + TopicName?: string; /** Warning: DM has topics enabled but this message is not in a topic. */ TopicRequiredButMissing?: boolean; /** diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index d4cf8f5ebf5..07fed8bf265 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -132,7 +132,7 @@ describe("message hook mappers", () => { }); it("maps canonical inbound context to plugin/internal received payloads", () => { - const canonical = deriveInboundMessageHookContext(makeInboundCtx()); + const canonical = deriveInboundMessageHookContext(makeInboundCtx({ TopicName: "Deployments" })); expect(toPluginMessageContext(canonical)).toEqual({ channelId: "demo-chat", @@ -147,6 +147,7 @@ describe("message hook mappers", () => { messageId: "msg-1", senderName: "User One", threadId: 42, + topicName: "Deployments", }), }); expect(toInternalMessageReceivedContext(canonical)).toEqual({ @@ -160,6 +161,7 @@ describe("message hook mappers", () => { metadata: expect.objectContaining({ senderUsername: "userone", senderE164: "+15551234567", + topicName: "Deployments", }), }); }); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 56123ebd0e3..9be1920c157 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -48,6 +48,7 @@ export type CanonicalInboundMessageHookContext = { channelName?: string; isGroup: boolean; groupId?: string; + topicName?: string; }; export type CanonicalSentMessageHookContext = { @@ -131,6 +132,7 @@ export function deriveInboundMessageHookContext( channelName: ctx.GroupChannel, isGroup, groupId: isGroup ? conversationId : undefined, + topicName: ctx.TopicName, }; } @@ -266,6 +268,7 @@ export function toPluginInboundClaimEvent( guildId: canonical.guildId, channelName: canonical.channelName, groupId: canonical.groupId, + topicName: canonical.topicName, }, }; } @@ -291,6 +294,7 @@ export function toPluginMessageReceivedEvent( senderE164: canonical.senderE164, guildId: canonical.guildId, channelName: canonical.channelName, + topicName: canonical.topicName, }, }; } @@ -328,6 +332,7 @@ export function toInternalMessageReceivedContext( senderE164: canonical.senderE164, guildId: canonical.guildId, channelName: canonical.channelName, + topicName: canonical.topicName, }, }; }