ACP: add persistent Discord channel and Telegram topic bindings (#34873)

* docs: add ACP persistent binding experiment plan

* docs: align ACP persistent binding spec to channel-local config

* docs: scope Telegram ACP bindings to forum topics only

* docs: lock bound /new and /reset behavior to in-place ACP reset

* ACP: add persistent discord/telegram conversation bindings

* ACP: fix persistent binding reuse and discord thread parent context

* docs: document channel-specific persistent ACP bindings

* ACP: split persistent bindings and share conversation id helpers

* ACP: defer configured binding init until preflight passes

* ACP: fix discord thread parent fallback and explicit disable inheritance

* ACP: keep bound /new and /reset in-place

* ACP: honor configured bindings in native command flows

* ACP: avoid configured fallback after runtime bind failure

* docs: refine ACP bindings experiment config examples

* acp: cut over to typed top-level persistent bindings

* ACP bindings: harden reset recovery and native command auth

* Docs: add ACP bound command auth proposal

* Tests: normalize i18n registry zh-CN assertion encoding

* ACP bindings: address review findings for reset and fallback routing

* ACP reset: gate hooks on success and preserve /new arguments

* ACP bindings: fix auth and binding-priority review findings

* Telegram ACP: gate ensure on auth and accepted messages

* ACP bindings: fix session-key precedence and unavailable handling

* ACP reset/native commands: honor fallback targets and abort on bootstrap failure

* Config schema: validate ACP binding channel and Telegram topic IDs

* Discord ACP: apply configured DM bindings to native commands

* ACP reset tails: dispatch through ACP after command handling

* ACP tails/native reset auth: fix target dispatch and restore full auth

* ACP reset detection: fallback to active ACP keys for DM contexts

* Tests: type runTurn mock input in ACP dispatch test

* ACP: dedup binding route bootstrap and reset target resolution

* reply: align ACP reset hooks with bound session key

* docs: replace personal discord ids with placeholders

* fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
Bob
2026-03-05 09:38:12 +01:00
committed by GitHub
parent 2c8ee593b9
commit 6a705a37f2
50 changed files with 4830 additions and 186 deletions

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
vi.mock("../acp/persistent-bindings.js", () => ({
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
ensureConfiguredAcpBindingSessionMock(...args),
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
resolveConfiguredAcpBindingRecordMock(...args),
}));
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
function createConfiguredTelegramBinding() {
return {
spec: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:work:-1001234567890:topic:42",
targetSessionKey: "agent:codex:acp:binding:telegram:work:abc123",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: "persistent",
agentId: "codex",
},
},
} as const;
}
describe("buildTelegramMessageContext ACP configured bindings", () => {
beforeEach(() => {
ensureConfiguredAcpBindingSessionMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding());
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
});
});
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).not.toBeNull();
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
});
it("skips ACP session initialization when topic access is denied", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { enabled: false },
}),
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("defers ACP session initialization for unauthorized control commands", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "/new",
},
cfg: {
channels: {
telegram: {},
},
commands: {
useAccessGroups: true,
},
},
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("drops inbound processing when configured ACP binding initialization fails", async () => {
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: false,
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
error: "gateway unavailable",
});
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -16,6 +16,7 @@ type BuildTelegramMessageContextForTestParams = {
allMedia?: TelegramMediaRef[];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
@@ -45,7 +46,7 @@ export async function buildTelegramMessageContextForTest(
},
} as never,
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
account: { accountId: "default" } as never,
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",

View File

@@ -1,4 +1,8 @@
import type { Bot } from "grammy";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../acp/persistent-bindings.route.js";
import { resolveAckReaction } from "../agents/identity.js";
import {
findModelInCatalog,
@@ -245,9 +249,22 @@ export const buildTelegramMessageContext = async ({
`telegram: per-topic agent override: topic=${resolvedThreadId ?? dmThreadId} agent=${topicAgentId} sessionKey=${overrideSessionKey}`,
);
}
const configuredRoute = resolveConfiguredAcpRoute({
cfg: freshCfg,
route,
channel: "telegram",
accountId: account.accountId,
conversationId: peerId,
parentConversationId: isGroup ? String(chatId) : undefined,
});
const configuredBinding = configuredRoute.configuredBinding;
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
route = configuredRoute.route;
const requiresExplicitAccountBinding = (candidate: ResolvedAgentRoute): boolean =>
candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default";
// Fail closed for named Telegram accounts when route resolution falls back to
// default-agent routing. This prevents cross-account DM/session contamination.
if (route.accountId !== DEFAULT_ACCOUNT_ID && route.matchedBy === "default") {
if (requiresExplicitAccountBinding(route)) {
logInboundDrop({
log: logVerbose,
channel: "telegram",
@@ -256,14 +273,6 @@ export const buildTelegramMessageContext = async ({
});
return null;
}
const baseSessionKey = route.sessionKey;
// DMs: use thread suffix for session isolation (works regardless of dmScope)
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
@@ -307,21 +316,6 @@ export const buildTelegramMessageContext = async ({
return null;
}
// Compute requireMention early for preflight transcription gating
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
baseRequireMention,
);
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
if (topicRequiredButMissing) {
@@ -371,6 +365,54 @@ export const buildTelegramMessageContext = async ({
) {
return null;
}
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
return true;
}
const ensured = await ensureConfiguredAcpRouteReady({
cfg: freshCfg,
configuredBinding,
});
if (ensured.ok) {
logVerbose(
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
);
return true;
}
logVerbose(
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "configured ACP binding unavailable",
target: configuredBinding.spec.conversationId,
});
return false;
};
const baseSessionKey = route.sessionKey;
// DMs: use thread suffix for session isolation (works regardless of dmScope)
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
// Compute requireMention after access checks and final route selection.
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
baseRequireMention,
);
recordChannelActivity({
channel: "telegram",
@@ -553,6 +595,10 @@ export const buildTelegramMessageContext = async ({
}
}
if (!(await ensureConfiguredBindingReady())) {
return null;
}
// ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "telegram",

View File

@@ -5,6 +5,18 @@ import { createNativeCommandTestParams } from "./bot-native-commands.test-helper
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
type ResolveConfiguredAcpBindingRecordFn =
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
})),
}));
const sessionMocks = vi.hoisted(() => ({
recordSessionMetaFromInbound: vi.fn(),
resolveStorePath: vi.fn(),
@@ -13,6 +25,14 @@ const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined),
}));
vi.mock("../acp/persistent-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../acp/persistent-bindings.js")>();
return {
...actual,
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
};
});
vi.mock("../config/sessions.js", () => ({
recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound,
resolveStorePath: sessionMocks.resolveStorePath,
@@ -64,31 +84,102 @@ function buildStatusCommandContext() {
};
}
function registerAndResolveStatusHandler(cfg: OpenClawConfig): TelegramCommandHandler {
function buildStatusTopicCommandContext() {
return {
match: "",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: {
id: -1001234567890,
type: "supergroup" as const,
title: "OpenClaw",
is_forum: true,
},
message_thread_id: 42,
from: { id: 200, username: "bob" },
},
};
}
function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { cfg, allowFrom, groupAllowFrom } = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
registerTelegramNativeCommands({
...createNativeCommandTestParams({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
allowFrom: ["*"],
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
}),
});
const handler = commandHandlers.get("status");
expect(handler).toBeTruthy();
return handler as TelegramCommandHandler;
return { handler: handler as TelegramCommandHandler, sendMessage };
}
function registerAndResolveCommandHandler(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { commandName, cfg, allowFrom, groupAllowFrom, useAccessGroups } = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
registerTelegramNativeCommands({
...createNativeCommandTestParams({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: useAccessGroups ?? true,
}),
});
const handler = commandHandlers.get(commandName);
expect(handler).toBeTruthy();
return { handler: handler as TelegramCommandHandler, sendMessage };
}
describe("registerTelegramNativeCommands — session metadata", () => {
beforeEach(() => {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined);
@@ -96,7 +187,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
const cfg: OpenClawConfig = {};
const handler = registerAndResolveStatusHandler(cfg);
const { handler } = registerAndResolveStatusHandler({ cfg });
await handler(buildStatusCommandContext());
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
@@ -115,7 +206,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise);
const cfg: OpenClawConfig = {};
const handler = registerAndResolveStatusHandler(cfg);
const { handler } = registerAndResolveStatusHandler({ cfg });
const runPromise = handler(buildStatusCommandContext());
await vi.waitFor(() => {
@@ -128,4 +219,168 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
});
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: false,
sessionKey: boundSessionKey,
error: "gateway unavailable",
});
const { handler, sendMessage } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"Configured ACP binding is unavailable right now. Please try again.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
});

View File

@@ -1,4 +1,8 @@
import type { Bot, Context } from "grammy";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../acp/persistent-bindings.route.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import type { CommandArgs } from "../auto-reply/commands-registry.js";
import {
@@ -170,6 +174,11 @@ async function resolveTelegramCommandAuth(params: {
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
@@ -205,9 +214,10 @@ async function resolveTelegramCommandAuth(params: {
const senderUsername = msg.from?.username ?? "";
const sendAuthMessage = async (text: string) => {
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text),
fn: () => bot.api.sendMessage(chatId, text, threadParams),
});
return null;
};
@@ -409,12 +419,19 @@ export const registerTelegramNativeCommands = ({
botIdentity: opts.token,
});
const resolveCommandRuntimeContext = (params: {
const resolveCommandRuntimeContext = async (params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
}) => {
}): Promise<{
chatId: number;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
route: ReturnType<typeof resolveAgentRoute>;
mediaLocalRoots: readonly string[] | undefined;
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: ReturnType<typeof resolveChunkMode>;
} | null> => {
const { msg, isGroup, isForum, resolvedThreadId } = params;
const chatId = msg.chat.id;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
@@ -424,16 +441,49 @@ export const registerTelegramNativeCommands = ({
messageThreadId,
});
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
const route = resolveAgentRoute({
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
let route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
id: peerId,
},
parentPeer,
});
const configuredRoute = resolveConfiguredAcpRoute({
cfg,
route,
channel: "telegram",
accountId,
conversationId: peerId,
parentConversationId: isGroup ? String(chatId) : undefined,
});
const configuredBinding = configuredRoute.configuredBinding;
route = configuredRoute.route;
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg,
configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(
chatId,
"Configured ACP binding is unavailable right now. Please try again.",
buildTelegramThreadParams(threadSpec) ?? {},
),
});
return null;
}
}
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const tableMode = resolveMarkdownTableMode({
cfg,
@@ -504,15 +554,19 @@ export const registerTelegramNativeCommands = ({
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
commandAuthorized: initialCommandAuthorized,
} = auth;
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
let commandAuthorized = initialCommandAuthorized;
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
if (!runtimeContext) {
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
chatId,
accountId: route.accountId,
@@ -729,13 +783,16 @@ export const registerTelegramNativeCommands = ({
return;
}
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
if (!runtimeContext) {
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
chatId,
accountId: route.accountId,