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 +17 -0
- package/README.md +6 -0
- package/index.ts +169 -3
- package/lib/approval-parser.ts +29 -6
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// telegram-approval-buttons · index.ts (v5.0
|
|
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
|
|
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(
|
|
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)}…`);
|
package/lib/approval-parser.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
|
5
|
+
"version": "5.1.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED