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 <hi@obviy.us>
This commit is contained in:
Ptah.ai
2026-04-13 14:08:14 -04:00
committed by GitHub
parent a56dbae80b
commit 8c43768e27
11 changed files with 314 additions and 43 deletions

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -105,6 +105,7 @@ export async function buildTelegramInboundContextPayload(params: {
options?: TelegramMessageContextOptions;
dmAllowFrom?: Array<string | number>;
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}`,
});

View File

@@ -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<unknown>;
type TelegramStatusReactionController = {
setQueued: () => void | Promise<void>;
setThinking: () => void | Promise<void>;
setTool: (name: string) => void | Promise<void>;
setCompacting: () => void | Promise<void>;
cancelPending: () => void;
setError: () => void | Promise<void>;
setDone: () => void | Promise<void>;
};
export type TelegramMessageContext = {
ctxPayload: TelegramMessageContextPayload["ctxPayload"];
@@ -83,7 +91,7 @@ export type TelegramMessageContext = {
ackReactionPromise: Promise<boolean> | 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<boolean> | null = statusReactionController
@@ -505,6 +555,7 @@ export const buildTelegramMessageContext = async ({
dmAllowFrom,
effectiveGroupAllow,
commandAuthorized: bodyResult.commandAuthorized,
topicName,
sessionRuntime,
});

View File

@@ -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);
});
});

View File

@@ -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<string, TopicEntry>();
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<Omit<TopicEntry, "updatedAt">>,
): 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;
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
/**

View File

@@ -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",
}),
});
});

View File

@@ -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,
},
};
}