telegram-approval-buttons 5.0.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.1.0] - 2026-03-31
9
+
10
+ ### Added
11
+ - **OpenClaw 2026 approval format support** — parser now handles native `Approval required`, `Full id:`, tool-result `(id ..., full ...)`, and `/approve ...` guidance formats (#4)
12
+ - **Async completion delivery** — captures post-approval exec output when the agent emits `NO_REPLY` and delivers results to Telegram users instead of silently dropping them
13
+ - **Duplicate approval suppression** — detects and cancels assistant fallback `/approve` messages when a native approval prompt already exists, preventing double prompts
14
+
15
+ ### Fixed
16
+ - Broadened duplicate fallback suppression to cover all actions (`allow-once|allow-always|deny`)
17
+ - `NO_REPLY` delivery paths now reuse shared `escapeHtml` helper instead of manual `.replace()` chains
18
+ - Added null-result handling + warning logs for failed Telegram `NO_REPLY` sends
19
+ - Normalized `NO_REPLY` user-facing strings to English for consistency
20
+
21
+ ### Contributors
22
+
23
+ Thanks to [@BlazzzPlay](https://github.com/BlazzzPlay) for contributing OpenClaw 2026 compatibility and async completion delivery in this release 🙏
24
+
8
25
  ## [5.0.1] - 2026-03-01
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
+
2
+ --Closed #30695.
3
+
4
+
5
+
6
+
1
7
  # 🔐 Approval Buttons for OpenClaw
2
8
 
3
9
  > One-tap `exec` approvals in **Telegram** and **Slack** — no more typing `/approve <uuid> allow-once`.
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
- // telegram-approval-buttons · index.ts (v5.0.1)
2
+ // telegram-approval-buttons · index.ts (v5.1.0)
3
3
  // Plugin entry point — orchestration only, all logic lives in lib/
4
4
  //
5
5
  // Adds inline keyboard/button approval messages to Telegram and Slack.
@@ -16,6 +16,7 @@ import { SlackApi } from "./lib/slack-api.js";
16
16
  import { ApprovalStore } from "./lib/approval-store.js";
17
17
  import { parseApprovalText, detectApprovalResult } from "./lib/approval-parser.js";
18
18
  import {
19
+ escapeHtml,
19
20
  formatApprovalRequest,
20
21
  formatApprovalResolved,
21
22
  formatApprovalExpired,
@@ -37,9 +38,32 @@ import {
37
38
 
38
39
  // ── Constants ───────────────────────────────────────────────────────────────
39
40
 
40
- const PLUGIN_VERSION = "5.0.1";
41
+ const PLUGIN_VERSION = "5.1.0";
41
42
  const TAG = "telegram-approval-buttons";
42
43
 
44
+ const RE_RICH_APPROVAL_HEADER = /^\s*🔐\s+<b>Exec Approval<\/b>/i;
45
+ const RE_ASSISTANT_APPROVE_FALLBACK = /\/approve\s+[a-f0-9-]{8,}\s+(allow-once|allow-always|deny)\b/i;
46
+
47
+ function approvalIdVariants(id: string): string[] {
48
+ const clean = id.trim();
49
+ const short = clean.slice(0, 8);
50
+ if (!short || short === clean) return [clean];
51
+ return [clean, short];
52
+ }
53
+
54
+ type CompletionSnapshot = { output: string; capturedAt: number };
55
+ type ResolvedSnapshot = { id: string; command: string; resolvedAt: number };
56
+
57
+ const RE_ASYNC_COMPLETION = /An async command the user already approved has completed\./i;
58
+ const RE_COMPLETION_OUTPUT = /Exact completion details:\r?\nExec finished[^\r\n]*\r?\n([\s\S]+?)\r?\n\r?\nReply to the user/i;
59
+
60
+ function parseAsyncCompletionOutput(text: string): string | null {
61
+ if (!RE_ASYNC_COMPLETION.test(text)) return null;
62
+ const match = text.match(RE_COMPLETION_OUTPUT);
63
+ const output = match?.[1]?.trim();
64
+ return output || null;
65
+ }
66
+
43
67
  // ── Plugin registration ─────────────────────────────────────────────────────
44
68
 
45
69
  function register(api: any): void {
@@ -115,6 +139,9 @@ function register(api: any): void {
115
139
  },
116
140
  );
117
141
 
142
+ let lastCompletionGlobal: CompletionSnapshot | null = null;
143
+ let lastResolvedGlobal: ResolvedSnapshot | null = null;
144
+
118
145
  // ─── 4. Register background service (cleanup timer) ──────────────────
119
146
 
120
147
  api.registerService({
@@ -141,6 +168,24 @@ function register(api: any): void {
141
168
 
142
169
  // ─── 6. Register message_sending hook ────────────────────────────────
143
170
 
171
+ api.on(
172
+ "message_received",
173
+ async (
174
+ event: { content: string },
175
+ _ctx: { channelId: string },
176
+ ) => {
177
+ const output = parseAsyncCompletionOutput(event.content);
178
+ if (!output) return;
179
+ lastCompletionGlobal = {
180
+ output,
181
+ capturedAt: Date.now(),
182
+ };
183
+ if (config.verbose) {
184
+ log.info(`[${TAG}] captured async completion output (${output.length} chars)`);
185
+ }
186
+ },
187
+ );
188
+
144
189
  api.on(
145
190
  "message_sending",
146
191
  async (
@@ -149,7 +194,17 @@ function register(api: any): void {
149
194
  ) => {
150
195
  // ── Telegram ──────────────────────────────────────────────────
151
196
  if (ctx.channelId === "telegram" && tg && config.telegram) {
152
- return handleTelegram(event, config.telegram.chatId, tg, store, log);
197
+ return handleTelegram(
198
+ event,
199
+ config.telegram.chatId,
200
+ tg,
201
+ store,
202
+ log,
203
+ () => lastCompletionGlobal,
204
+ () => { lastCompletionGlobal = null; },
205
+ () => lastResolvedGlobal,
206
+ (next) => { lastResolvedGlobal = next; },
207
+ );
153
208
  }
154
209
 
155
210
  // ── Slack ─────────────────────────────────────────────────────
@@ -159,6 +214,44 @@ function register(api: any): void {
159
214
  },
160
215
  );
161
216
 
217
+ // Some OpenClaw builds deliver approval prompts before plugins can cancel.
218
+ // In that case, rewrite the already-sent native approval message in-place
219
+ // to avoid a second "button message".
220
+ api.on(
221
+ "message_sent",
222
+ async (
223
+ event: { content: string; messageId?: string; metadata?: Record<string, unknown> },
224
+ ctx: { channelId: string },
225
+ ) => {
226
+ if (ctx.channelId !== "telegram" || !tg || !config.telegram) return;
227
+ if (RE_RICH_APPROVAL_HEADER.test(event.content)) return;
228
+
229
+ const info = parseApprovalText(event.content);
230
+ if (!info) return;
231
+
232
+ const rawMsgId =
233
+ event.messageId
234
+ ?? (typeof event.metadata?.message_id === "string" ? event.metadata.message_id : undefined)
235
+ ?? (typeof event.metadata?.messageId === "string" ? event.metadata.messageId : undefined);
236
+
237
+ const messageId = rawMsgId ? Number(rawMsgId) : NaN;
238
+ if (!Number.isFinite(messageId)) return;
239
+
240
+ const edited = await tg.editMessageText(
241
+ config.telegram.chatId,
242
+ messageId,
243
+ formatApprovalRequest(info),
244
+ buildApprovalKeyboard(info.id),
245
+ );
246
+ if (!edited) return;
247
+
248
+ if (!store.has(info.id)) {
249
+ store.add(info.id, "telegram", { messageId }, info);
250
+ }
251
+ log.info(`[${TAG}] telegram upgraded native approval ${info.id.slice(0, 8)}… (msg=${messageId})`);
252
+ },
253
+ );
254
+
162
255
  // ─── Done ─────────────────────────────────────────────────────────────
163
256
 
164
257
  const channels = [config.telegram && "Telegram", config.slack && "Slack"]
@@ -175,13 +268,65 @@ async function handleTelegram(
175
268
  tg: TelegramApi,
176
269
  store: ApprovalStore,
177
270
  log: any,
271
+ getLastCompletion: () => CompletionSnapshot | null,
272
+ clearLastCompletion: () => void,
273
+ getLastResolved: () => ResolvedSnapshot | null,
274
+ setLastResolved: (next: ResolvedSnapshot | null) => void,
178
275
  ): Promise<{ cancel: true } | void> {
276
+ // If the agent emits NO_REPLY right after an async exec completion event,
277
+ // convert it into a short delivery so Telegram users still get the result.
278
+ if (event.content.trim() === "NO_REPLY") {
279
+ const completion = getLastCompletion();
280
+ if (completion && Date.now() - completion.capturedAt <= 120_000) {
281
+ const sentId = await tg.sendMessage(
282
+ chatId,
283
+ [
284
+ "✅ <b>Command completed</b>",
285
+ "",
286
+ `<pre>${escapeHtml(completion.output)}</pre>`,
287
+ ].join("\n"),
288
+ );
289
+ if (sentId === null) {
290
+ log.warn(`[${TAG}] telegram failed to deliver NO_REPLY completion output`);
291
+ }
292
+ clearLastCompletion();
293
+ setLastResolved(null);
294
+ return { cancel: true };
295
+ }
296
+
297
+ const resolved = getLastResolved();
298
+ if (resolved && Date.now() - resolved.resolvedAt <= 120_000) {
299
+ const sentId = await tg.sendMessage(
300
+ chatId,
301
+ [
302
+ "✅ <b>Command completed</b>",
303
+ "",
304
+ `<code>${escapeHtml(resolved.command)}</code>`,
305
+ "",
306
+ "Output was not forwarded by the agent (NO_REPLY).",
307
+ ].join("\n"),
308
+ );
309
+ if (sentId === null) {
310
+ log.warn(`[${TAG}] telegram failed to deliver NO_REPLY fallback notice`);
311
+ }
312
+ setLastResolved(null);
313
+ return { cancel: true };
314
+ }
315
+
316
+ if (log?.info) log.info(`[${TAG}] NO_REPLY without completion snapshot`);
317
+ }
318
+
179
319
  // Check for approval resolution
180
320
  const resolution = detectApprovalResult(event.content, store.entries());
181
321
  if (resolution) {
182
322
  const entry = store.resolve(resolution.id);
183
323
  if (entry && entry.channel === "telegram") {
184
324
  log.info(`[${TAG}] telegram resolved ${resolution.id.slice(0, 8)}… → ${resolution.action}`);
325
+ setLastResolved({
326
+ id: resolution.id,
327
+ command: entry.info.command,
328
+ resolvedAt: Date.now(),
329
+ });
185
330
  await tg.editMessageText(
186
331
  chatId,
187
332
  entry.messageId,
@@ -195,6 +340,27 @@ async function handleTelegram(
195
340
  const info = parseApprovalText(event.content);
196
341
  if (!info) return;
197
342
 
343
+ const looksLikeAssistantFallback =
344
+ RE_ASSISTANT_APPROVE_FALLBACK.test(event.content)
345
+ && !/Exec approval required/i.test(event.content)
346
+ && !/\bApproval required\b/i.test(event.content);
347
+
348
+ // Newer OpenClaw builds already send a native approval card/message.
349
+ // Suppress the assistant's plain `/approve ...` fallback so users don't
350
+ // receive a second approval prompt.
351
+ if (looksLikeAssistantFallback) return { cancel: true };
352
+
353
+ // If this is the model's fallback "/approve ..." guidance and we already
354
+ // converted the native approval prompt, suppress this duplicate text.
355
+ for (const variant of approvalIdVariants(info.id)) {
356
+ if (store.has(variant)) return { cancel: true };
357
+ for (const pendingId of store.entries().keys()) {
358
+ if (pendingId.startsWith(variant) || variant.startsWith(pendingId)) {
359
+ return { cancel: true };
360
+ }
361
+ }
362
+ }
363
+
198
364
  if (store.has(info.id)) return { cancel: true };
199
365
 
200
366
  log.info(`[${TAG}] telegram intercepting ${info.id.slice(0, 8)}…`);
@@ -8,11 +8,15 @@ import type { ApprovalAction, ApprovalInfo, ApprovalResolution, SentApproval } f
8
8
  // ─── Regex patterns (compiled once) ─────────────────────────────────────────
9
9
 
10
10
  const RE_APPROVAL_MARKER = /Exec approval required/i;
11
+ const RE_NATIVE_APPROVAL_MARKER = /\bApproval required\b/i;
12
+ const RE_TOOLRESULT_APPROVAL = /Approval required\s*\(id\s+([a-f0-9-]{8,})\s*,\s*full\s+([a-f0-9-]{8,})\)/i;
11
13
  const RE_ID = /ID:\s*([a-f0-9-]+)/i;
14
+ const RE_FULL_ID = /Full id:\s*([a-f0-9-]{8,})/i;
15
+ const RE_APPROVE_CMD = /\/approve\s+([a-f0-9-]{8,})\s+(allow-once|allow-always|deny)\b/i;
12
16
  const RE_UUID = /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i;
13
17
  const RE_SHORT_HEX = /\b([a-f0-9]{8,})\b/i;
14
18
  const RE_GATEWAY_DENIAL = /Exec denied.*approval-timeout/i;
15
- const RE_COMMAND_BLOCK = /Command:\s*`{0,3}\n?(.+?)\n?`{0,3}(?:\n|$)/is;
19
+ const RE_COMMAND_BLOCK = /Command:\s*```(?:[a-z0-9_+-]+)?\n([\s\S]+?)\n```/i;
16
20
  const RE_COMMAND_INLINE = /Command:\s*(.+)/i;
17
21
  const RE_CWD = /CWD:\s*(.+)/i;
18
22
  const RE_HOST = /Host:\s*(.+)/i;
@@ -20,6 +24,7 @@ const RE_AGENT = /Agent:\s*(.+)/i;
20
24
  const RE_SECURITY = /Security:\s*(.+)/i;
21
25
  const RE_ASK = /Ask:\s*(.+)/i;
22
26
  const RE_EXPIRES = /Expires in:\s*(.+)/i;
27
+ const RE_PENDING_COMMAND = /Pending command:\s*`{0,3}\n?(.+?)\n?(?:`{0,3})(?:\n|$)/is;
23
28
 
24
29
  /**
25
30
  * Parse OpenClaw's plain-text approval message into an ApprovalInfo object.
@@ -29,14 +34,32 @@ const RE_EXPIRES = /Expires in:\s*(.+)/i;
29
34
  * falls back to sensible defaults for missing fields.
30
35
  */
31
36
  export function parseApprovalText(text: string): ApprovalInfo | null {
32
- if (!RE_APPROVAL_MARKER.test(text)) return null;
37
+ const hasLegacyMarker = RE_APPROVAL_MARKER.test(text);
38
+ const hasNativeMarker = RE_NATIVE_APPROVAL_MARKER.test(text);
39
+ const toolResultMatch = text.match(RE_TOOLRESULT_APPROVAL);
40
+
41
+ let id = hasLegacyMarker ? text.match(RE_ID)?.[1]?.trim() : undefined;
42
+
43
+ if (!id && toolResultMatch) {
44
+ id = toolResultMatch[2]?.trim() || toolResultMatch[1]?.trim();
45
+ }
46
+
47
+ if (!id && hasNativeMarker) {
48
+ id = text.match(RE_FULL_ID)?.[1]?.trim();
49
+ }
50
+
51
+ // OpenClaw 2026 format often delivers plain guidance text like:
52
+ // `/approve <id> allow-once` (with or without markdown backticks).
53
+ if (!id) {
54
+ id = text.match(RE_APPROVE_CMD)?.[1]?.trim();
55
+ }
33
56
 
34
- const id = text.match(RE_ID)?.[1]?.trim();
35
57
  if (!id) return null;
36
58
 
37
- // Try block format first (```command```), then inline
38
- let command = text.match(RE_COMMAND_BLOCK)?.[1]?.trim();
39
- if (!command) command = text.match(RE_COMMAND_INLINE)?.[1]?.trim() ?? "unknown";
59
+ const command = text.match(RE_COMMAND_BLOCK)?.[1]?.trim()
60
+ ?? text.match(RE_PENDING_COMMAND)?.[1]?.trim()
61
+ ?? text.match(RE_COMMAND_INLINE)?.[1]?.trim()
62
+ ?? "approval-pending";
40
63
 
41
64
  return {
42
65
  id,
@@ -2,7 +2,7 @@
2
2
  "id": "telegram-approval-buttons",
3
3
  "name": "Telegram Approval Buttons",
4
4
  "description": "Adds inline buttons to exec approval messages in Telegram and Slack. Tap to approve/deny without typing commands.",
5
- "version": "5.0.1",
5
+ "version": "5.1.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telegram-approval-buttons",
3
- "version": "5.0.1",
3
+ "version": "5.1.0",
4
4
  "description": "Inline buttons for exec approval messages in Telegram and Slack — tap to approve/deny without typing commands",
5
5
  "type": "module",
6
6
  "main": "index.ts",