From 840cfd69cd47ecdbbdbef15092421055fc435dc3 Mon Sep 17 00:00:00 2001 From: Martin Kessler Date: Mon, 15 Jun 2026 23:35:14 -0700 Subject: [PATCH] fix(telegram): bind bot mentions to assistant identity (#93088) * fix(telegram): bind bot mentions to assistant identity * fix(telegram): satisfy context payload mention typing * refactor(telegram): carry mention facts as one context object * test(telegram): use neutral bot handle fixture * fix(ci): terminate heartbeat command groups * fix(ci): preserve heartbeat shell functions * fix(telegram): project effective mention facts * fix(telegram): keep mention identity portable * test(telegram): align mention facts mock --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --- docs/channels/telegram.md | 4 ++ .../telegram/src/bot-message-context.body.ts | 48 +++++++++++++++++++ ...e-context.implicit-mention.test-support.ts | 2 + .../src/bot-message-context.session.ts | 8 ++++ .../bot-message-context.sticker-media.test.ts | 7 +++ .../telegram/src/bot-message-context.ts | 3 +- .../src/bot.create-telegram-bot.test.ts | 31 ++++++++++++ src/auto-reply/reply/groups.test.ts | 27 +++++++++++ src/auto-reply/reply/groups.ts | 6 +++ src/channels/inbound-event/context.test.ts | 8 ++++ src/channels/inbound-event/context.ts | 5 ++ src/channels/turn/types.ts | 10 +++- 12 files changed, 157 insertions(+), 2 deletions(-) diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 070abfb3639a..8e4da6f079ef 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -111,6 +111,10 @@ After a successful startup, OpenClaw caches the bot identity in the state direct ## Access control and activation +### Group bot identity + +In Telegram groups and forum topics, an explicit mention of the configured bot handle (for example `@my_bot`) is treated as addressing the selected OpenClaw agent, even when the agent persona name differs from the Telegram username. The group silence policy still applies to unrelated group traffic, but the bot handle itself is not considered "someone else." + `channels.telegram.dmPolicy` controls direct message access: diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 747857ceba14..16738a4c6553 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -6,6 +6,7 @@ import { logInboundDrop, matchesMentionWithExplicit, resolveInboundMentionDecision, + type BuildChannelInboundEventContextParams, type BuildMentionRegexesOptions, type NormalizedLocation, } from "openclaw/plugin-sdk/channel-inbound"; @@ -50,6 +51,9 @@ import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; type StickerVisionRuntime = typeof import("./sticker-vision.runtime.js"); type MediaUnderstandingRuntime = typeof import("./media-understanding.runtime.js"); +type TelegramMentionFacts = NonNullable< + NonNullable["mentions"] +>; let stickerVisionRuntimePromise: Promise | undefined; let mediaUnderstandingRuntimePromise: Promise | undefined; @@ -70,6 +74,7 @@ export type TelegramInboundBodyResult = { historyKey?: string; commandAuthorized: boolean; effectiveWasMentioned: boolean; + mentionFacts: TelegramMentionFacts; canDetectMention: boolean; shouldBypassMention: boolean; hasControlCommand: boolean; @@ -120,6 +125,39 @@ function formatSavedMediaPlaceholder(allMedia: TelegramMediaRef[]): string | und return ` (${allMedia.length} attachments)`; } +function resolveTelegramMentionFacts(params: { + canDetectMention: boolean; + effectiveWasMentioned: boolean; + explicitlyMentionedBot: boolean; + computedWasMentioned: boolean; + implicitMentionKinds: TelegramMentionFacts["implicitMentionKinds"]; + requireMention: boolean; + shouldBypassMention: boolean; + shouldSkip: boolean; +}): TelegramMentionFacts { + let mentionSource: TelegramMentionFacts["mentionSource"]; + if (params.explicitlyMentionedBot) { + mentionSource = "explicit_bot"; + } else if (params.computedWasMentioned) { + mentionSource = "mention_pattern"; + } else if (params.implicitMentionKinds && params.implicitMentionKinds.length > 0) { + mentionSource = "implicit_thread"; + } else if (params.shouldBypassMention) { + mentionSource = "command_bypass"; + } + + return { + canDetectMention: params.canDetectMention, + wasMentioned: params.effectiveWasMentioned, + explicitlyMentionedBot: params.explicitlyMentionedBot, + mentionSource, + implicitMentionKinds: params.implicitMentionKinds, + effectiveWasMentioned: params.effectiveWasMentioned, + requireMention: params.requireMention, + shouldSkip: params.shouldSkip, + }; +} + async function resolveStickerVisionSupport(params: { cfg: OpenClawConfig; agentId?: string; @@ -442,6 +480,16 @@ export async function resolveTelegramInboundBody(params: { historyKey, commandAuthorized, effectiveWasMentioned, + mentionFacts: resolveTelegramMentionFacts({ + canDetectMention, + effectiveWasMentioned, + explicitlyMentionedBot: explicitlyMentioned, + computedWasMentioned, + implicitMentionKinds, + requireMention: Boolean(requireMention), + shouldBypassMention: mentionDecision.shouldBypassMention, + shouldSkip: mentionDecision.shouldSkip, + }), canDetectMention, shouldBypassMention: mentionDecision.shouldBypassMention, hasControlCommand: hasControlCommandInMessage, diff --git a/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts b/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts index f323569f0de9..25ae9e2bf2d0 100644 --- a/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts +++ b/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts @@ -106,6 +106,8 @@ describe("buildTelegramMessageContext implicitMention forum service messages", ( // Real bot reply → implicitMention fires → message is NOT skipped. expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.WasMentioned).toBe(true); + expect(ctx?.ctxPayload?.MentionSource).toBe("implicit_thread"); + expect(ctx?.ctxPayload?.ImplicitMentionKinds).toEqual(["reply_to_bot"]); }); it("DOES trigger implicitMention for bot media messages with caption", async () => { diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index a73c7d908367..184fb04afbdc 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -1,6 +1,7 @@ // Telegram plugin module implements bot message context.session behavior. import path from "node:path"; import { + type BuildChannelInboundEventContextParams, type BuildChannelInboundEventContextAsyncParams, type BuiltChannelInboundEventContext, classifyChannelInboundEvent, @@ -33,6 +34,10 @@ import type { TelegramMessageContextSessionRuntimeOverrides, TelegramPromptContextEntry, } from "./bot-message-context.types.js"; + +type TelegramMentionFacts = NonNullable< + NonNullable["mentions"] +>; import { buildGroupLabel, buildSenderLabel, @@ -220,6 +225,7 @@ export async function buildTelegramInboundContextPayload(params: { groupConfig?: TelegramGroupConfig | TelegramDirectConfig; topicConfig?: TelegramTopicConfig; effectiveWasMentioned: boolean; + mentionFacts: TelegramMentionFacts; hasControlCommand: boolean; stickerCacheHit?: boolean; audioTranscribedMediaIndex?: number; @@ -270,6 +276,7 @@ export async function buildTelegramInboundContextPayload(params: { groupConfig, topicConfig, effectiveWasMentioned, + mentionFacts, hasControlCommand, stickerCacheHit, audioTranscribedMediaIndex, @@ -544,6 +551,7 @@ export async function buildTelegramInboundContextPayload(params: { commands: { authorized: commandAuthorized, }, + mentions: mentionFacts, }, command: commandSource === "native" diff --git a/extensions/telegram/src/bot-message-context.sticker-media.test.ts b/extensions/telegram/src/bot-message-context.sticker-media.test.ts index 626d66c98daf..5b1f5c96589c 100644 --- a/extensions/telegram/src/bot-message-context.sticker-media.test.ts +++ b/extensions/telegram/src/bot-message-context.sticker-media.test.ts @@ -11,6 +11,13 @@ const inboundBodyMock = vi.hoisted(() => historyKey: undefined, commandAuthorized: false, effectiveWasMentioned: false, + mentionFacts: { + canDetectMention: true, + wasMentioned: false, + effectiveWasMentioned: false, + requireMention: false, + shouldSkip: false, + }, canDetectMention: true, shouldBypassMention: false, hasControlCommand: false, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 840471d6bcfd..19a6ed99b027 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -477,7 +477,7 @@ export const buildTelegramMessageContext = async ({ groupConfig, topicConfig, providerMentionPatterns: cfg.channels?.telegram?.accounts?.[account.accountId]?.mentionPatterns, - requireMention, + requireMention: Boolean(requireMention), options, groupHistories, historyLimit, @@ -533,6 +533,7 @@ export const buildTelegramMessageContext = async ({ groupConfig, topicConfig, effectiveWasMentioned: bodyResult.effectiveWasMentioned, + mentionFacts: bodyResult.mentionFacts, hasControlCommand: bodyResult.hasControlCommand, stickerCacheHit: bodyResult.stickerCacheHit, ...(bodyResult.audioTranscribedMediaIndex !== undefined diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 9961ae23d658..73662e3fbd8d 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3758,6 +3758,37 @@ describe("createTelegramBot", () => { } } }); + it("marks explicit Telegram bot-handle mentions in the inbound context", async () => { + resetHarnessSpies(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "@openclaw_bot status", + entities: [{ type: "mention", offset: 0, length: "@openclaw_bot".length }], + date: 1736380800, + message_id: 4, + from: { id: 9, first_name: "Ada" }, + }, + me: { id: 999, username: "openclaw_bot" }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0]; + expect(payload.WasMentioned).toBe(true); + expect(payload.ExplicitlyMentionedBot).toBe(true); + expect(payload.MentionSource).toBe("explicit_bot"); + expect(payload.BotUsername).toBe("openclaw_bot"); + }); + it("keeps group envelope headers stable (sender identity is separate)", async () => { resetHarnessSpies(); diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index 98014ad9c88c..400ec2011c89 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -120,6 +120,33 @@ describe("group runtime loading", () => { expect(disallowed).not.toContain("Never say that you are staying quiet"); }); + it("binds an explicitly mentioned channel handle to the current assistant identity", () => { + const context = groups.buildGroupChatContext({ + sessionCtx: { + ChatType: "group", + Provider: "telegram", + BotUsername: "SirPinchALotBot", + ExplicitlyMentionedBot: true, + }, + silentToken: "NO_REPLY", + silentReplyPolicy: "allow", + }); + + expect(context).toContain("explicitly mentions your channel identity @SirPinchALotBot"); + expect(context).toContain("Treat that mention as addressed to you"); + + const notExplicit = groups.buildGroupChatContext({ + sessionCtx: { + ChatType: "group", + Provider: "telegram", + BotUsername: "kesslerAIBot", + }, + silentToken: "NO_REPLY", + silentReplyPolicy: "allow", + }); + expect(notExplicit).not.toContain("channel identity @kesslerAIBot"); + }); + it("marks non-visible assistant replies silent for groups with silence allowed", () => { expect( groups.resolveGroupSilentReplyBehavior({ diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index d1672d0873b8..934ce0fd6680 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -231,9 +231,15 @@ export function buildGroupChatContext(params: { const providerLabel = resolveProviderLabel(params.sessionCtx.Provider); const provider = normalizeOptionalLowercaseString(params.sessionCtx.Provider); const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only"; + const botUsername = normalizeOptionalString(params.sessionCtx.BotUsername); const lines: string[] = []; lines.push(`You are in a ${providerLabel} group chat.`); + if (params.sessionCtx.ExplicitlyMentionedBot === true && botUsername) { + lines.push( + `The incoming message explicitly mentions your channel identity @${botUsername}. Treat that mention as addressed to you, even if your persona name differs.`, + ); + } if (messageToolOnly) { lines.push( "Normal final replies are private and are not automatically sent to this group chat. To post visible output here, use the message tool with action=send; the target defaults to this group chat.", diff --git a/src/channels/inbound-event/context.test.ts b/src/channels/inbound-event/context.test.ts index 1d89b722bc18..316330a37157 100644 --- a/src/channels/inbound-event/context.test.ts +++ b/src/channels/inbound-event/context.test.ts @@ -86,6 +86,10 @@ describe("buildChannelInboundEventContext", () => { mentions: { canDetectMention: true, wasMentioned: true, + explicitlyMentionedBot: true, + mentionSource: "explicit_bot", + mentionedUserIds: ["bot-1"], + implicitMentionKinds: ["reply_to_bot"], }, }, commandTurn: { @@ -161,6 +165,10 @@ describe("buildChannelInboundEventContext", () => { Provider: "test-provider", Surface: "test-surface", WasMentioned: true, + ExplicitlyMentionedBot: true, + MentionedUserIds: ["bot-1"], + ImplicitMentionKinds: ["reply_to_bot"], + MentionSource: "explicit_bot", CommandAuthorized: true, CommandSource: "text", CommandTurn: { diff --git a/src/channels/inbound-event/context.ts b/src/channels/inbound-event/context.ts index 0cd442ca5978..36d86b4107a4 100644 --- a/src/channels/inbound-event/context.ts +++ b/src/channels/inbound-event/context.ts @@ -503,6 +503,11 @@ export function buildChannelInboundEventContext( Provider: params.provider ?? params.channel, Surface: params.surface ?? params.provider ?? params.channel, WasMentioned: params.access?.mentions?.wasMentioned, + ExplicitlyMentionedBot: params.access?.mentions?.explicitlyMentionedBot, + MentionedUserIds: params.access?.mentions?.mentionedUserIds, + MentionedSubteamIds: params.access?.mentions?.mentionedSubteamIds, + ImplicitMentionKinds: params.access?.mentions?.implicitMentionKinds, + MentionSource: params.access?.mentions?.mentionSource, CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access) === true, CommandTurn: commandTurn, MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId, diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index 469acfd939fb..ebc47d010932 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -8,7 +8,11 @@ import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/his import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js"; import type { ReplyDispatcherWithTypingOptions } from "../../auto-reply/reply/reply-dispatcher.js"; import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js"; -import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js"; +import type { + FinalizedMsgContext, + MentionSource, + MsgContext, +} from "../../auto-reply/templating.js"; import type { GroupKeyResolution } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { @@ -179,6 +183,10 @@ export type AccessFacts = { canDetectMention: boolean; wasMentioned: boolean; hasAnyMention?: boolean; + explicitlyMentionedBot?: boolean; + mentionedUserIds?: string[]; + mentionedSubteamIds?: string[]; + mentionSource?: MentionSource; implicitMentionKinds?: Array< "reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native" >;