mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 23:28:12 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user