diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 070abfb3639..8e4da6f079e 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 747857ceba1..16738a4c655 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 f323569f0de..25ae9e2bf2d 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 a73c7d90836..184fb04afbd 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 626d66c98da..5b1f5c96589 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 840471d6bcf..19a6ed99b02 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 9961ae23d65..73662e3fbd8 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 98014ad9c88..400ec2011c8 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 d1672d0873b..934ce0fd668 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 1d89b722bc1..316330a3715 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 0cd442ca597..36d86b4107a 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 469acfd939f..ebc47d01093 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" >;