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>
This commit is contained in:
Martin Kessler
2026-06-15 23:35:14 -07:00
committed by GitHub
parent b037280ea9
commit 840cfd69cd
12 changed files with 157 additions and 2 deletions

View File

@@ -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."
<Tabs>
<Tab title="DM policy">
`channels.telegram.dmPolicy` controls direct message access:

View File

@@ -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<BuildChannelInboundEventContextParams["access"]>["mentions"]
>;
let stickerVisionRuntimePromise: Promise<StickerVisionRuntime> | undefined;
let mediaUnderstandingRuntimePromise: Promise<MediaUnderstandingRuntime> | 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 `<media:document> (${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,

View File

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

View File

@@ -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<BuildChannelInboundEventContextParams["access"]>["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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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