mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:19:35 +00:00
feat(telegram/acp): Topic Binding, Pin Binding Message, Fix Spawn Param Parsing (#36683)
* fix(acp): normalize unicode flags and Telegram topic binding * feat(telegram/acp): restore topic-bound ACP and session bindings * fix(acpx): clarify permission-denied guidance * feat(telegram/acp): pin spawn bind notice in topics * docs(telegram): document ACP topic thread binding behavior * refactor(reply): share Telegram conversation-id resolver * fix(telegram/acp): preserve bound session routing semantics * fix(telegram): respect binding persistence and expiry reporting * refactor(telegram): simplify binding lifecycle persistence * fix(telegram): bind acp spawns in direct messages * fix: document telegram ACP topic binding changelog (#36683) (thanks @huntharo) --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
@@ -108,4 +108,22 @@ describe("commands-acp context", () => {
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("-1001234567890:topic:42");
|
||||
});
|
||||
|
||||
it("resolves Telegram DM conversation ids from telegram targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:123456789",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "123456789",
|
||||
parentConversationId: "123456789",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-binding
|
||||
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
@@ -40,19 +41,28 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
|
||||
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
if (channel === "telegram") {
|
||||
const telegramConversationId = resolveTelegramConversationId({
|
||||
ctx: {
|
||||
MessageThreadId: params.ctx.MessageThreadId,
|
||||
OriginatingTo: params.ctx.OriginatingTo,
|
||||
To: params.ctx.To,
|
||||
},
|
||||
command: {
|
||||
to: params.command.to,
|
||||
},
|
||||
});
|
||||
if (telegramConversationId) {
|
||||
return telegramConversationId;
|
||||
}
|
||||
const threadId = resolveAcpCommandThreadId(params);
|
||||
const parentConversationId = resolveAcpCommandParentConversationId(params);
|
||||
if (threadId && parentConversationId) {
|
||||
const canonical = buildTelegramTopicConversationId({
|
||||
chatId: parentConversationId,
|
||||
topicId: threadId,
|
||||
});
|
||||
if (canonical) {
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
if (threadId) {
|
||||
return threadId;
|
||||
return (
|
||||
buildTelegramTopicConversationId({
|
||||
chatId: parentConversationId,
|
||||
topicId: threadId,
|
||||
}) ?? threadId
|
||||
);
|
||||
}
|
||||
}
|
||||
return resolveConversationIdFromTargets({
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { CommandHandlerResult, HandleCommandsParams } from "../commands-typ
|
||||
import {
|
||||
resolveAcpCommandAccountId,
|
||||
resolveAcpCommandBindingContext,
|
||||
resolveAcpCommandThreadId,
|
||||
resolveAcpCommandConversationId,
|
||||
} from "./context.js";
|
||||
import {
|
||||
ACP_STEER_OUTPUT_LIMIT,
|
||||
@@ -123,25 +123,27 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const currentThreadId = bindingContext.threadId ?? "";
|
||||
|
||||
if (threadMode === "here" && !currentThreadId) {
|
||||
const currentConversationId = bindingContext.conversationId?.trim() || "";
|
||||
const requiresThreadIdForHere = channel !== "telegram";
|
||||
if (
|
||||
threadMode === "here" &&
|
||||
((requiresThreadIdForHere && !currentThreadId) ||
|
||||
(!requiresThreadIdForHere && !currentConversationId))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`,
|
||||
};
|
||||
}
|
||||
|
||||
const threadId = currentThreadId || undefined;
|
||||
const placement = threadId ? "current" : "child";
|
||||
const placement = channel === "telegram" ? "current" : currentThreadId ? "current" : "child";
|
||||
if (!capabilities.placements.includes(placement)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thread bindings do not support ${placement} placement for ${channel}.`,
|
||||
};
|
||||
}
|
||||
const channelId = placement === "child" ? bindingContext.conversationId : undefined;
|
||||
|
||||
if (placement === "child" && !channelId) {
|
||||
if (!currentConversationId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
|
||||
@@ -149,11 +151,11 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const senderId = commandParams.command.senderId?.trim() || "";
|
||||
if (threadId) {
|
||||
if (placement === "current") {
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: threadId,
|
||||
conversationId: currentConversationId,
|
||||
});
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
@@ -162,19 +164,13 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Only ${boundBy} can rebind this thread.`,
|
||||
error: `Only ${boundBy} can rebind this ${channel === "telegram" ? "conversation" : "thread"}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const label = params.label || params.agentId;
|
||||
const conversationId = threadId || channelId;
|
||||
if (!conversationId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
|
||||
};
|
||||
}
|
||||
const conversationId = currentConversationId;
|
||||
|
||||
try {
|
||||
const binding = await bindingService.bind({
|
||||
@@ -344,12 +340,13 @@ export async function handleAcpSpawnAction(
|
||||
`✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`,
|
||||
];
|
||||
if (binding) {
|
||||
const currentThreadId = resolveAcpCommandThreadId(params) ?? "";
|
||||
const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || "";
|
||||
const boundConversationId = binding.conversation.conversationId.trim();
|
||||
if (currentThreadId && boundConversationId === currentThreadId) {
|
||||
parts.push(`Bound this thread to ${sessionKey}.`);
|
||||
const placementLabel = binding.conversation.channel === "telegram" ? "conversation" : "thread";
|
||||
if (currentConversationId && boundConversationId === currentConversationId) {
|
||||
parts.push(`Bound this ${placementLabel} to ${sessionKey}.`);
|
||||
} else {
|
||||
parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`);
|
||||
parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`);
|
||||
}
|
||||
} else {
|
||||
parts.push("Session is unbound (use /focus <session-key> to bind this thread/conversation).");
|
||||
@@ -360,6 +357,19 @@ export async function handleAcpSpawnAction(
|
||||
parts.push(`ℹ️ ${dispatchNote}`);
|
||||
}
|
||||
|
||||
const shouldPinBindingNotice =
|
||||
binding?.conversation.channel === "telegram" &&
|
||||
binding.conversation.conversationId.includes(":topic:");
|
||||
if (shouldPinBindingNotice) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: parts.join(" "),
|
||||
channelData: { telegram: { pin: true } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return stopWithText(parts.join(" "));
|
||||
}
|
||||
|
||||
|
||||
22
src/auto-reply/reply/commands-acp/shared.test.ts
Normal file
22
src/auto-reply/reply/commands-acp/shared.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSteerInput } from "./shared.js";
|
||||
|
||||
describe("parseSteerInput", () => {
|
||||
it("preserves non-option instruction tokens while normalizing unicode-dash flags", () => {
|
||||
const parsed = parseSteerInput([
|
||||
"\u2014session",
|
||||
"agent:codex:acp:s1",
|
||||
"\u2014briefly",
|
||||
"summarize",
|
||||
"this",
|
||||
]);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionToken: "agent:codex:acp:s1",
|
||||
instruction: "\u2014briefly summarize this",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./i
|
||||
|
||||
export const COMMAND = "/acp";
|
||||
export const ACP_SPAWN_USAGE =
|
||||
"Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
|
||||
"Usage: /acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
|
||||
export const ACP_STEER_USAGE =
|
||||
"Usage: /acp steer [--session <session-key|session-id|session-label>] <instruction>";
|
||||
export const ACP_SET_MODE_USAGE =
|
||||
@@ -77,6 +77,9 @@ export type ParsedSetCommandInput = {
|
||||
sessionToken?: string;
|
||||
};
|
||||
|
||||
const ACP_UNICODE_DASH_PREFIX_RE =
|
||||
/^[\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D]+/;
|
||||
|
||||
export function stopWithText(text: string): CommandHandlerResult {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
@@ -118,9 +121,9 @@ function readOptionValue(params: { tokens: string[]; index: number; flag: string
|
||||
error?: string;
|
||||
}
|
||||
| { matched: false } {
|
||||
const token = params.tokens[params.index] ?? "";
|
||||
const token = normalizeAcpOptionToken(params.tokens[params.index] ?? "");
|
||||
if (token === params.flag) {
|
||||
const nextValue = params.tokens[params.index + 1]?.trim() ?? "";
|
||||
const nextValue = normalizeAcpOptionToken(params.tokens[params.index + 1] ?? "");
|
||||
if (!nextValue || nextValue.startsWith("--")) {
|
||||
return {
|
||||
matched: true,
|
||||
@@ -152,6 +155,18 @@ function readOptionValue(params: { tokens: string[]; index: number; flag: string
|
||||
return { matched: false };
|
||||
}
|
||||
|
||||
function normalizeAcpOptionToken(raw: string): string {
|
||||
const token = raw.trim();
|
||||
if (!token || token.startsWith("--")) {
|
||||
return token;
|
||||
}
|
||||
const dashPrefix = token.match(ACP_UNICODE_DASH_PREFIX_RE)?.[0];
|
||||
if (!dashPrefix) {
|
||||
return token;
|
||||
}
|
||||
return `--${token.slice(dashPrefix.length)}`;
|
||||
}
|
||||
|
||||
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
|
||||
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
|
||||
return "off";
|
||||
@@ -164,16 +179,17 @@ export function parseSpawnInput(
|
||||
params: HandleCommandsParams,
|
||||
tokens: string[],
|
||||
): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } {
|
||||
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
|
||||
let mode: AcpRuntimeSessionMode = "persistent";
|
||||
let thread = resolveDefaultSpawnThreadMode(params);
|
||||
let cwd: string | undefined;
|
||||
let label: string | undefined;
|
||||
let rawAgentId: string | undefined;
|
||||
|
||||
for (let i = 0; i < tokens.length; ) {
|
||||
const token = tokens[i] ?? "";
|
||||
for (let i = 0; i < normalizedTokens.length; ) {
|
||||
const token = normalizedTokens[i] ?? "";
|
||||
|
||||
const modeOption = readOptionValue({ tokens, index: i, flag: "--mode" });
|
||||
const modeOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--mode" });
|
||||
if (modeOption.matched) {
|
||||
if (modeOption.error) {
|
||||
return { ok: false, error: `${modeOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
@@ -190,7 +206,11 @@ export function parseSpawnInput(
|
||||
continue;
|
||||
}
|
||||
|
||||
const threadOption = readOptionValue({ tokens, index: i, flag: "--thread" });
|
||||
const threadOption = readOptionValue({
|
||||
tokens: normalizedTokens,
|
||||
index: i,
|
||||
flag: "--thread",
|
||||
});
|
||||
if (threadOption.matched) {
|
||||
if (threadOption.error) {
|
||||
return { ok: false, error: `${threadOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
@@ -207,7 +227,7 @@ export function parseSpawnInput(
|
||||
continue;
|
||||
}
|
||||
|
||||
const cwdOption = readOptionValue({ tokens, index: i, flag: "--cwd" });
|
||||
const cwdOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--cwd" });
|
||||
if (cwdOption.matched) {
|
||||
if (cwdOption.error) {
|
||||
return { ok: false, error: `${cwdOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
@@ -217,7 +237,7 @@ export function parseSpawnInput(
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelOption = readOptionValue({ tokens, index: i, flag: "--label" });
|
||||
const labelOption = readOptionValue({ tokens: normalizedTokens, index: i, flag: "--label" });
|
||||
if (labelOption.matched) {
|
||||
if (labelOption.error) {
|
||||
return { ok: false, error: `${labelOption.error}. ${ACP_SPAWN_USAGE}` };
|
||||
@@ -251,7 +271,7 @@ export function parseSpawnInput(
|
||||
if (!selectedAgent) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ACP target agent is required. Pass an agent id or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
|
||||
error: `ACP target harness id is required. Pass an ACP harness id (for example codex) or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
|
||||
};
|
||||
}
|
||||
const normalizedAgentId = normalizeAgentId(selectedAgent);
|
||||
@@ -271,12 +291,13 @@ export function parseSpawnInput(
|
||||
export function parseSteerInput(
|
||||
tokens: string[],
|
||||
): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } {
|
||||
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
|
||||
let sessionToken: string | undefined;
|
||||
const instructionTokens: string[] = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; ) {
|
||||
for (let i = 0; i < normalizedTokens.length; ) {
|
||||
const sessionOption = readOptionValue({
|
||||
tokens,
|
||||
tokens: normalizedTokens,
|
||||
index: i,
|
||||
flag: "--session",
|
||||
});
|
||||
@@ -292,7 +313,7 @@ export function parseSteerInput(
|
||||
continue;
|
||||
}
|
||||
|
||||
instructionTokens.push(tokens[i]);
|
||||
instructionTokens.push(tokens[i] ?? "");
|
||||
i += 1;
|
||||
}
|
||||
|
||||
@@ -380,7 +401,7 @@ export function resolveAcpHelpText(): string {
|
||||
return [
|
||||
"ACP commands:",
|
||||
"-----",
|
||||
"/acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
|
||||
"/acp spawn [harness-id] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
|
||||
"/acp cancel [session-key|session-id|session-label]",
|
||||
"/acp steer [--session <session-key|session-id|session-label>] <instruction>",
|
||||
"/acp close [session-key|session-id|session-label]",
|
||||
@@ -397,6 +418,7 @@ export function resolveAcpHelpText(): string {
|
||||
"/acp sessions",
|
||||
"",
|
||||
"Notes:",
|
||||
"- /acp spawn harness-id is an ACP runtime harness alias (for example codex), not an OpenClaw agents.list id.",
|
||||
"- /focus and /unfocus also work with ACP session keys.",
|
||||
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
|
||||
].join("\n");
|
||||
|
||||
Reference in New Issue
Block a user