Skip to content

Commit d58dafa

Browse files
huntharoosolmaz
andauthored
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 <[email protected]>
1 parent 92b4892 commit d58dafa

35 files changed

Lines changed: 2395 additions & 451 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.
2222
- Plugins/hook policy: add `plugins.entries.<id>.hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.
2323
- Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.
24+
- Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in `/acp spawn`, support Telegram topic thread binding (`--thread here|auto`), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.
2425

2526
### Breaking
2627

docs/channels/telegram.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,13 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
524524

525525
This is currently scoped to forum topics in groups and supergroups.
526526

527+
**Thread-bound ACP spawn from chat**:
528+
529+
- `/acp spawn <agent> --thread here|auto` can bind the current Telegram topic to a new ACP session.
530+
- Follow-up topic messages route to the bound ACP session directly (no `/acp steer` required).
531+
- OpenClaw pins the spawn confirmation message in-topic after a successful bind.
532+
- Requires `channels.telegram.threadBindings.spawnAcpSessions=true`.
533+
527534
Template context includes:
528535

529536
- `MessageThreadId`

docs/tools/acp-agents.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,14 @@ Required feature flags for thread-bound ACP:
7979
- `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch)
8080
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
8181
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
82+
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
8283

8384
### Thread supporting channels
8485

8586
- Any channel adapter that exposes session/thread binding capability.
86-
- Current built-in support: Discord.
87+
- Current built-in support:
88+
- Discord threads/channels
89+
- Telegram topics (forum topics in groups/supergroups and DM topics)
8790
- Plugin channels can add support through the same binding interface.
8891

8992
## Channel specific settings
@@ -303,7 +306,9 @@ If no target resolves, OpenClaw returns a clear error (`Unable to resolve sessio
303306
Notes:
304307

305308
- On non-thread binding surfaces, default behavior is effectively `off`.
306-
- Thread-bound spawn requires channel policy support (for Discord: `channels.discord.threadBindings.spawnAcpSessions=true`).
309+
- Thread-bound spawn requires channel policy support:
310+
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
311+
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
307312

308313
## ACP controls
309314

extensions/acpx/src/runtime-internals/test-fixtures.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ if (command === "prompt") {
223223
process.exit(1);
224224
}
225225
226+
if (stdinText.includes("permission-denied")) {
227+
process.exit(5);
228+
}
229+
226230
if (stdinText.includes("split-spacing")) {
227231
emitUpdate(sessionFromOption, {
228232
sessionUpdate: "agent_message_chunk",

extensions/acpx/src/runtime.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,42 @@ describe("AcpxRuntime", () => {
224224
});
225225
});
226226

227+
it("maps acpx permission-denied exits to actionable guidance", async () => {
228+
const runtime = sharedFixture?.runtime;
229+
expect(runtime).toBeDefined();
230+
if (!runtime) {
231+
throw new Error("shared runtime fixture missing");
232+
}
233+
const handle = await runtime.ensureSession({
234+
sessionKey: "agent:codex:acp:permission-denied",
235+
agent: "codex",
236+
mode: "persistent",
237+
});
238+
239+
const events = [];
240+
for await (const event of runtime.runTurn({
241+
handle,
242+
text: "permission-denied",
243+
mode: "prompt",
244+
requestId: "req-perm",
245+
})) {
246+
events.push(event);
247+
}
248+
249+
expect(events).toContainEqual(
250+
expect.objectContaining({
251+
type: "error",
252+
message: expect.stringContaining("Permission denied by ACP runtime (acpx)."),
253+
}),
254+
);
255+
expect(events).toContainEqual(
256+
expect.objectContaining({
257+
type: "error",
258+
message: expect.stringContaining("approve-reads, approve-all, deny-all"),
259+
}),
260+
);
261+
});
262+
227263
it("supports cancel and close using encoded runtime handle state", async () => {
228264
const { runtime, logPath, config } = await createMockRuntimeFixture();
229265
const handle = await runtime.ensureSession({

extensions/acpx/src/runtime.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,30 @@ export const ACPX_BACKEND_ID = "acpx";
4242

4343
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
4444
const DEFAULT_AGENT_FALLBACK = "codex";
45+
const ACPX_EXIT_CODE_PERMISSION_DENIED = 5;
4546
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
4647
controls: ["session/set_mode", "session/set_config_option", "session/status"],
4748
};
4849

50+
function formatPermissionModeGuidance(): string {
51+
return "Configure plugins.entries.acpx.config.permissionMode to one of: approve-reads, approve-all, deny-all.";
52+
}
53+
54+
function formatAcpxExitMessage(params: {
55+
stderr: string;
56+
exitCode: number | null | undefined;
57+
}): string {
58+
const stderr = params.stderr.trim();
59+
if (params.exitCode === ACPX_EXIT_CODE_PERMISSION_DENIED) {
60+
return [
61+
stderr || "Permission denied by ACP runtime (acpx).",
62+
"ACPX blocked a write/exec permission request in a non-interactive session.",
63+
formatPermissionModeGuidance(),
64+
].join(" ");
65+
}
66+
return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`;
67+
}
68+
4969
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
5070
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
5171
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
@@ -333,7 +353,10 @@ export class AcpxRuntime implements AcpRuntime {
333353
if ((exit.code ?? 0) !== 0 && !sawError) {
334354
yield {
335355
type: "error",
336-
message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
356+
message: formatAcpxExitMessage({
357+
stderr,
358+
exitCode: exit.code,
359+
}),
337360
};
338361
return;
339362
}
@@ -639,7 +662,10 @@ export class AcpxRuntime implements AcpRuntime {
639662
if ((result.code ?? 0) !== 0) {
640663
throw new AcpRuntimeError(
641664
params.fallbackCode,
642-
result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
665+
formatAcpxExitMessage({
666+
stderr: result.stderr,
667+
exitCode: result.code,
668+
}),
643669
);
644670
}
645671
return events;

src/auto-reply/commands-registry.data.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ function buildChatCommands(): ChatCommandDefinition[] {
354354
defineChatCommand({
355355
key: "focus",
356356
nativeName: "focus",
357-
description: "Bind this Discord thread (or a new one) to a session target.",
357+
description:
358+
"Bind this thread (Discord) or topic/conversation (Telegram) to a session target.",
358359
textAlias: "/focus",
359360
category: "management",
360361
args: [
@@ -369,7 +370,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
369370
defineChatCommand({
370371
key: "unfocus",
371372
nativeName: "unfocus",
372-
description: "Remove the current Discord thread binding.",
373+
description: "Remove the current thread (Discord) or topic/conversation (Telegram) binding.",
373374
textAlias: "/unfocus",
374375
category: "management",
375376
}),
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,29 @@ type DiscordAccountParams = {
1717
};
1818

1919
export function isDiscordSurface(params: DiscordSurfaceParams): boolean {
20+
return resolveCommandSurfaceChannel(params) === "discord";
21+
}
22+
23+
export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
24+
return resolveCommandSurfaceChannel(params) === "telegram";
25+
}
26+
27+
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
2028
const channel =
2129
params.ctx.OriginatingChannel ??
2230
params.command.channel ??
2331
params.ctx.Surface ??
2432
params.ctx.Provider;
25-
return (
26-
String(channel ?? "")
27-
.trim()
28-
.toLowerCase() === "discord"
29-
);
33+
return String(channel ?? "")
34+
.trim()
35+
.toLowerCase();
3036
}
3137

3238
export function resolveDiscordAccountId(params: DiscordAccountParams): string {
39+
return resolveChannelAccountId(params);
40+
}
41+
42+
export function resolveChannelAccountId(params: DiscordAccountParams): string {
3343
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
3444
return accountId || "default";
3545
}

0 commit comments

Comments
 (0)