telegram-approval-buttons 4.0.3 → 5.0.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,62 @@ 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.0.0] - 2026-02-24
9
+
10
+ ### ⚠️ Breaking Changes
11
+
12
+ - **Plugin ID renamed**: `telegram-approval-buttons` → `approval-buttons`
13
+
14
+ If you're upgrading from v4.x, update your `~/.openclaw/openclaw.json`:
15
+
16
+ ```diff
17
+ "plugins": {
18
+ "entries": {
19
+ - "telegram-approval-buttons": {
20
+ + "approval-buttons": {
21
+ "enabled": true,
22
+ ...
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Then restart: `openclaw gateway restart`
29
+
30
+ ### Added
31
+ - **Slack inline button support** — approval buttons now work in Slack via Block Kit interactive messages
32
+ - New files: `lib/slack-api.ts`, `lib/slack-formatter.ts`, `tests/slack-formatter.test.ts`
33
+ - Auto-detects Slack credentials from `channels.slack` config or `SLACK_BOT_TOKEN` / `SLACK_CHANNEL_ID` env vars
34
+ - `/approvalstatus` now shows both Telegram and Slack connectivity
35
+ - Multi-channel architecture: either or both channels can be independently enabled
36
+
37
+ ### Changed
38
+ - **Cleaner approval messages** — removed verbose internal fields (Security, Ask, Host) from both Telegram and Slack formats. Messages now focus on: command, CWD, agent, and expiry
39
+ - Plugin ID and TAG shortened to `approval-buttons`
40
+
41
+ ### Contributors
42
+
43
+ Thanks to [@sjkey](https://github.com/sjkey) for contributing Slack support and the message simplification improvements in this release 🙏
44
+
45
+ ## [4.1.0] - 2026-02-20
46
+
47
+ ### Changed
48
+ - **Performance: O(1) approval resolution lookup** — `detectApprovalResult()` now extracts UUIDs via regex and performs a direct `Map.has()` lookup instead of iterating all pending entries. The old O(n) linear scan is replaced with O(1) hash lookup for full UUIDs, with a fallback prefix scan only for truncated IDs.
49
+
50
+ ### Added
51
+ - **Gateway denial detection** — New `resolveAction()` function and `RE_GATEWAY_DENIAL` regex detect when the gateway auto-denies an approval due to timeout (`Exec denied.*approval-timeout`). The plugin now immediately cleans up stale Telegram buttons when the gateway reports a timeout, instead of waiting for the stale cleanup timer.
52
+ - **Robust short hex matching** — Short approval IDs are now matched via `\b([a-f0-9]{8,})\b` regex with `startsWith()` prefix matching, supporting variable-length truncated IDs instead of the previous hardcoded 8-char `slice()`.
53
+
54
+ ### Fixed
55
+ - Synced `openclaw.plugin.json` manifest version with `package.json` (was stuck at 4.0.2)
56
+ - Updated header comment in `index.ts` to reflect current version
57
+
58
+ ## [4.0.3] - 2026-02-16
59
+
60
+ ### Fixed
61
+ - Auto-detect `botToken` key from `channels.telegram.botToken` in addition to `channels.telegram.token`
62
+ - Improved README setup documentation with clearer quick start instructions
63
+
8
64
  ## [4.0.2] - 2026-02-15
9
65
 
10
66
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # 🔐 Telegram Approval Buttons for OpenClaw
1
+ # 🔐 Approval Buttons for OpenClaw
2
2
 
3
- > One-tap `exec` approvals in Telegram — no more typing `/approve <uuid> allow-once`.
3
+ > One-tap `exec` approvals in **Telegram** and **Slack** — no more typing `/approve <uuid> allow-once`.
4
4
 
5
5
  ## What does this look like?
6
6
 
@@ -10,13 +10,14 @@
10
10
 
11
11
  ## What does this do?
12
12
 
13
- OpenClaw's Discord has built-in approval buttons. **Telegram doesn't** — you're stuck typing long `/approve` commands. This plugin fixes that.
13
+ OpenClaw's Discord has built-in approval buttons. **Telegram and Slack don't** — you're stuck typing long `/approve` commands. This plugin fixes that for both.
14
14
 
15
15
  **Features:**
16
16
  - ✅ **One-tap approvals** — Allow Once · 🔏 Always · ❌ Deny
17
+ - 💬 **Multi-channel** — works on Telegram (inline keyboard) and Slack (Block Kit buttons)
17
18
  - 🔄 **Auto-resolve** — edits the message after decision (removes buttons, shows result)
18
19
  - ⏰ **Expiry handling** — stale approvals auto-cleaned and marked as expired
19
- - 🩺 **Self-diagnostics** — `/approvalstatus` checks health and stats
20
+ - 🩺 **Self-diagnostics** — `/approvalstatus` checks health and stats for both channels
20
21
  - 🛡️ **Graceful fallback** — if buttons fail, the original text goes through
21
22
  - 📦 **Zero dependencies** — uses only Node.js built-in `fetch`
22
23
 
@@ -74,7 +75,7 @@ Open your `~/.openclaw/openclaw.json` and add two things:
74
75
  },
75
76
  "plugins": {
76
77
  "entries": {
77
- "telegram-approval-buttons": {
78
+ "approval-buttons": {
78
79
  "enabled": true,
79
80
  "config": {
80
81
  "botToken": "<your_bot_token>",
@@ -101,20 +102,22 @@ Then send `/approvalstatus` in your Telegram chat. You should see:
101
102
  ```
102
103
  🟢 Approval Buttons Status
103
104
 
104
- Config: chatId=✓ · token=✓
105
- Telegram: ✓ connected (@your_bot)
105
+ Telegram: chatId=✓ · token=✓
106
+ ✓ connected (@your_bot)
107
+ Slack: not configured
108
+
106
109
  Pending: 0 · Processed: 0
107
110
  Uptime: 1m
108
111
  ```
109
112
 
110
- > ⚠️ **If you see `DISABLED — missing config`**, the plugin can't find your bot token or chat ID. Double-check that `botToken` and `chatId` are set in `plugins.entries.telegram-approval-buttons.config` in your `~/.openclaw/openclaw.json`.
113
+ > ⚠️ **If you see `DISABLED — missing config`**, the plugin can't find your bot token or chat ID. Double-check that `botToken` and `chatId` are set in `plugins.entries.approval-buttons.config` in your `~/.openclaw/openclaw.json`.
111
114
 
112
115
  **That's it!** Next time the AI triggers an `exec` approval, you'll get inline buttons instead of text.
113
116
 
114
117
  ## Prerequisites
115
118
 
116
119
  - **OpenClaw ≥ 2026.2.9** installed and running
117
- - **Node.js ≥ 22** (uses built-in `fetch`)
120
+ - **Node.js ≥ 20** (uses built-in `fetch`)
118
121
  - **Telegram configured** in your `openclaw.json` (bot token + `allowFrom`)
119
122
  - **Exec approvals targeting Telegram** — see Step 2 above
120
123
 
@@ -159,13 +162,15 @@ The plugin **auto-detects** `botToken` and `chatId` from your Telegram channel c
159
162
  {
160
163
  "plugins": {
161
164
  "entries": {
162
- "telegram-approval-buttons": {
165
+ "approval-buttons": {
163
166
  "enabled": true,
164
167
  "config": {
165
- "chatId": "123456789", // Override auto-detected chat ID
166
- "botToken": "123:ABC...", // Override auto-detected bot token
167
- "staleMins": 10, // Minutes before stale cleanup (default: 10)
168
- "verbose": false // Diagnostic logging (default: false)
168
+ "chatId": "123456789", // Telegram chat ID
169
+ "botToken": "123:ABC...", // Telegram bot token
170
+ "slackBotToken": "xoxb-...", // Slack bot OAuth token (optional)
171
+ "slackChannelId": "C0123456", // Slack channel/DM ID (optional)
172
+ "staleMins": 10, // Minutes before stale cleanup (default: 10)
173
+ "verbose": false // Diagnostic logging (default: false)
169
174
  }
170
175
  }
171
176
  }
@@ -197,25 +202,44 @@ A: Yes, but the bot needs to be an admin or it needs permission to edit its own
197
202
 
198
203
  | Problem | Fix |
199
204
  |---------|-----|
200
- | `DISABLED — missing config` in logs | Add `botToken` and `chatId` to `plugins.entries.telegram-approval-buttons.config` in your `~/.openclaw/openclaw.json`. See Step 2. |
205
+ | `DISABLED — missing config` in logs | Add `botToken` and `chatId` to `plugins.entries.approval-buttons.config` in your `~/.openclaw/openclaw.json`. See Step 2. |
206
+ | `plugin not found: telegram-approval-buttons` | You upgraded from v4.x. Rename the key in your config from `telegram-approval-buttons` to `approval-buttons`. See [Upgrading from v4.x](#upgrading-from-v4x). |
201
207
  | Still getting old text approvals | Your `approvals.exec` config must target Telegram. See Step 2. |
202
208
  | `/approvalstatus` says "unknown command" | Plugin didn't load. Run `openclaw plugins install telegram-approval-buttons` and restart the gateway. |
203
209
  | No buttons appear | Check `tools.exec.ask` is not `"off"`. Run `/approvalstatus` to check config. |
204
210
  | Buttons show but nothing happens | Bot needs message editing permission. Use a private chat or make bot admin. |
205
- | `/approvalstatus` says "token=✗" | Set `botToken` in plugin config. See Step 2. |
206
- | `/approvalstatus` says "chatId=✗" | Set `chatId` in plugin config. See Step 2. |
207
211
  | Buttons say "expired" | Approval timed out before you tapped. Adjust `staleMins` if needed. |
208
212
 
213
+ ## Upgrading from v4.x
214
+
215
+ v5.0.0 renamed the plugin ID from `telegram-approval-buttons` to `approval-buttons`. Update your `~/.openclaw/openclaw.json`:
216
+
217
+ ```diff
218
+ "plugins": {
219
+ "entries": {
220
+ - "telegram-approval-buttons": {
221
+ + "approval-buttons": {
222
+ "enabled": true,
223
+ ...
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ Then restart: `openclaw gateway restart`
230
+
209
231
  ## Architecture
210
232
 
211
233
  ```
212
- telegram-approval-buttons/
234
+ approval-buttons/
213
235
  ├── index.ts # Entry point — orchestration only
214
236
  ├── types.ts # Shared TypeScript interfaces
215
237
  ├── lib/
216
- │ ├── telegram-api.ts # Telegram Bot API client (isolated)
238
+ │ ├── telegram-api.ts # Telegram Bot API client
239
+ │ ├── slack-api.ts # Slack Web API client
217
240
  │ ├── approval-parser.ts # Parse OpenClaw approval text format
218
241
  │ ├── message-formatter.ts # HTML formatting for Telegram messages
242
+ │ ├── slack-formatter.ts # Block Kit formatting for Slack messages
219
243
  │ ├── approval-store.ts # In-memory pending approval tracker
220
244
  │ └── diagnostics.ts # Config resolution, health checks
221
245
  ├── openclaw.plugin.json # Plugin manifest
@@ -226,6 +250,11 @@ telegram-approval-buttons/
226
250
 
227
251
  Issues and PRs welcome. Each file in `lib/` is self-contained with a single responsibility.
228
252
 
253
+ ## Contributors
254
+
255
+ - [@JairFC](https://github.com/JairFC) — creator and maintainer
256
+ - [@sjkey](https://github.com/sjkey) — Slack support and message simplification (v5.0.0)
257
+
229
258
  ## License
230
259
 
231
260
  [MIT](LICENSE)
package/SECURITY.md ADDED
@@ -0,0 +1,39 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |---------|-----------|
7
+ | 4.x | ✅ Active |
8
+ | < 4.0 | ❌ EOL |
9
+
10
+ ## Reporting a Vulnerability
11
+
12
+ If you discover a security vulnerability in this plugin, please report it responsibly:
13
+
14
+ 1. **Do NOT open a public issue** — this could expose the vulnerability to others
15
+ 2. **Email**: [francojair81@gmail.com](mailto:francojair81@gmail.com)
16
+ 3. **Include**:
17
+ - Description of the vulnerability
18
+ - Steps to reproduce
19
+ - Potential impact
20
+ - Suggested fix (if you have one)
21
+
22
+ I will acknowledge your report within **48 hours** and aim to release a fix within **7 days** for critical issues.
23
+
24
+ ## Security Model
25
+
26
+ This plugin runs **in-process** with the OpenClaw Gateway as trusted code:
27
+
28
+ - **No external network calls** except to the Telegram Bot API (`api.telegram.org`)
29
+ - **No data persistence** — all approval state is in-memory and lost on restart
30
+ - **No credential storage** — bot token and chat ID are read from OpenClaw's config at runtime
31
+ - **Input validation** — callback query data is validated against the pending approvals map; unknown IDs are silently ignored
32
+ - **HTML escaping** — all user-supplied text is escaped before Telegram HTML rendering to prevent injection
33
+
34
+ ## Best Practices for Users
35
+
36
+ - Keep your OpenClaw instance and this plugin updated
37
+ - Use `plugins.allow` allowlists to restrict which plugins can load
38
+ - Review the source code before installing any community plugin
39
+ - Never share your bot token or chat ID publicly
package/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
- // telegram-approval-buttons · index.ts (v4.0.2)
2
+ // approval-buttons · index.ts (v5.0.0)
3
3
  // Plugin entry point — orchestration only, all logic lives in lib/
4
4
  //
5
- // Adds inline keyboard buttons to exec approval messages in Telegram.
5
+ // Adds inline keyboard/button approval messages to Telegram and Slack.
6
6
  // When a user taps a button, OpenClaw processes the /approve command
7
- // automatically via its callback_query synthetic text message pipeline.
7
+ // automatically via the channel's callback mechanism.
8
8
  // ─────────────────────────────────────────────────────────────────────────────
9
9
 
10
10
  import type { PluginConfig } from "./types.js";
@@ -12,6 +12,7 @@ import type { PluginConfig } from "./types.js";
12
12
  // ── Modules ─────────────────────────────────────────────────────────────────
13
13
 
14
14
  import { TelegramApi } from "./lib/telegram-api.js";
15
+ import { SlackApi } from "./lib/slack-api.js";
15
16
  import { ApprovalStore } from "./lib/approval-store.js";
16
17
  import { parseApprovalText, detectApprovalResult } from "./lib/approval-parser.js";
17
18
  import {
@@ -21,6 +22,12 @@ import {
21
22
  buildApprovalKeyboard,
22
23
  formatHealthCheck,
23
24
  } from "./lib/message-formatter.js";
25
+ import {
26
+ formatSlackApprovalRequest,
27
+ formatSlackApprovalResolved,
28
+ formatSlackApprovalExpired,
29
+ slackFallbackText,
30
+ } from "./lib/slack-formatter.js";
24
31
  import {
25
32
  resolveConfig,
26
33
  runHealthCheck,
@@ -30,8 +37,8 @@ import {
30
37
 
31
38
  // ── Constants ───────────────────────────────────────────────────────────────
32
39
 
33
- const PLUGIN_VERSION = "4.0.3";
34
- const TAG = "telegram-approval-buttons";
40
+ const PLUGIN_VERSION = "5.0.0";
41
+ const TAG = "approval-buttons";
35
42
 
36
43
  // ── Plugin registration ─────────────────────────────────────────────────────
37
44
 
@@ -43,6 +50,7 @@ function register(api: any): void {
43
50
 
44
51
  const pluginCfg: PluginConfig = api.pluginConfig ?? {};
45
52
  const telegramCfg = api.config?.channels?.telegram ?? {};
53
+ const slackCfg = api.config?.channels?.slack ?? {};
46
54
 
47
55
  const config = resolveConfig(
48
56
  {
@@ -51,51 +59,74 @@ function register(api: any): void {
51
59
  token: telegramCfg.token || telegramCfg.botToken,
52
60
  allowFrom: telegramCfg.allowFrom,
53
61
  },
62
+ slackChannelConfig: {
63
+ token: slackCfg.token,
64
+ botToken: slackCfg.botToken,
65
+ allowFrom: slackCfg.allowFrom,
66
+ },
54
67
  env: {
55
68
  TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN,
56
69
  TELEGRAM_CHAT_ID: process.env.TELEGRAM_CHAT_ID,
70
+ SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN,
71
+ SLACK_CHANNEL_ID: process.env.SLACK_CHANNEL_ID,
57
72
  },
58
73
  },
59
74
  log,
60
75
  );
61
76
 
62
77
  if (!config) {
63
- log.warn(`[${TAG}] v${PLUGIN_VERSION} loaded (DISABLED — missing config)`);
78
+ log.warn(`[${TAG}] v${PLUGIN_VERSION} loaded (DISABLED — no channels configured)`);
64
79
  return;
65
80
  }
66
81
 
67
82
  logStartupDiagnostics(config, log);
68
83
 
69
- // ─── 2. Initialize modules ───────────────────────────────────────────
84
+ // ─── 2. Initialize API clients ────────────────────────────────────────
85
+
86
+ const tg = config.telegram
87
+ ? new TelegramApi(config.telegram.botToken, config.verbose ? log : undefined)
88
+ : null;
70
89
 
71
- const tg = new TelegramApi(config.botToken, config.verbose ? log : undefined);
90
+ const slack = config.slack
91
+ ? new SlackApi(config.slack.botToken, config.verbose ? log : undefined)
92
+ : null;
93
+
94
+ // ─── 3. Initialize store with expiry handler ──────────────────────────
72
95
 
73
96
  const store = new ApprovalStore(
74
97
  config.staleMins * 60_000,
75
98
  config.verbose ? log : undefined,
76
- // onExpired: edit the Telegram message to show "expired"
99
+ // onExpired: edit the message to show "expired"
77
100
  (entry) => {
78
- tg.editMessageText(
79
- config.chatId,
80
- entry.messageId,
81
- formatApprovalExpired(entry.info),
82
- ).catch(() => { });
101
+ if (entry.channel === "telegram" && tg && config.telegram) {
102
+ tg.editMessageText(
103
+ config.telegram.chatId,
104
+ entry.messageId,
105
+ formatApprovalExpired(entry.info),
106
+ ).catch(() => {});
107
+ } else if (entry.channel === "slack" && slack && config.slack) {
108
+ slack.updateMessage(
109
+ config.slack.channelId,
110
+ entry.slackTs,
111
+ "Exec Approval Expired",
112
+ formatSlackApprovalExpired(entry.info),
113
+ ).catch(() => {});
114
+ }
83
115
  },
84
116
  );
85
117
 
86
- // ─── 3. Register background service (cleanup timer) ──────────────────
118
+ // ─── 4. Register background service (cleanup timer) ──────────────────
87
119
 
88
120
  api.registerService({
89
121
  id: `${TAG}-cleanup`,
90
122
  start: () => {
91
123
  store.start();
92
- // Non-blocking connectivity check
93
- runStartupChecks(tg, log).catch(() => { });
124
+ runStartupChecks(tg, slack, log).catch(() => {});
94
125
  },
95
126
  stop: () => store.stop(),
96
127
  });
97
128
 
98
- // ─── 4. Register /approvalstatus command ─────────────────────────────
129
+ // ─── 5. Register /approvalstatus command ─────────────────────────────
99
130
 
100
131
  api.registerCommand({
101
132
  name: "approvalstatus",
@@ -103,12 +134,12 @@ function register(api: any): void {
103
134
  acceptsArgs: false,
104
135
  requireAuth: true,
105
136
  handler: async () => {
106
- const health = await runHealthCheck(config, tg, store, startedAt);
137
+ const health = await runHealthCheck(config, tg, slack, store, startedAt);
107
138
  return { text: formatHealthCheck(health) };
108
139
  },
109
140
  });
110
141
 
111
- // ─── 5. Register message_sending hook ────────────────────────────────
142
+ // ─── 6. Register message_sending hook ────────────────────────────────
112
143
 
113
144
  api.on(
114
145
  "message_sending",
@@ -116,78 +147,128 @@ function register(api: any): void {
116
147
  event: { to: string; content: string; metadata?: Record<string, unknown> },
117
148
  ctx: { channelId: string; accountId?: string },
118
149
  ) => {
119
- // Only intercept Telegram messages
120
- if (ctx.channelId !== "telegram") return;
121
-
122
- // ── 5a. Check for approval resolution ───────────────────────────
123
- const resolution = detectApprovalResult(event.content, store.entries());
124
- if (resolution) {
125
- const entry = store.resolve(resolution.id);
126
- if (entry) {
127
- log.info(
128
- `[${TAG}] resolved ${resolution.id.slice(0, 8)}… → ${resolution.action}`,
129
- );
130
- const edited = await tg.editMessageText(
131
- config.chatId,
132
- entry.messageId,
133
- formatApprovalResolved(entry.info, resolution.action),
134
- );
135
- if (edited && config.verbose) {
136
- log.info(`[${TAG}] edited msg=${entry.messageId}`);
137
- }
138
- }
139
- // Don't cancel — let resolution messages pass through
140
- return;
150
+ // ── Telegram ──────────────────────────────────────────────────
151
+ if (ctx.channelId === "telegram" && tg && config.telegram) {
152
+ return handleTelegram(event, config.telegram.chatId, tg, store, log);
141
153
  }
142
154
 
143
- // ── 5b. Check for new approval request ─────────────────────────
144
- const info = parseApprovalText(event.content);
145
- if (!info) return;
146
-
147
- // Duplicate guard
148
- if (store.has(info.id)) {
149
- if (config.verbose) {
150
- log.info(`[${TAG}] skipping duplicate ${info.id.slice(0, 8)}…`);
151
- }
152
- return { cancel: true };
155
+ // ── Slack ─────────────────────────────────────────────────────
156
+ if (ctx.channelId === "slack" && slack && config.slack) {
157
+ return handleSlack(event, config.slack.channelId, slack, store, log);
153
158
  }
159
+ },
160
+ );
154
161
 
155
- log.info(`[${TAG}] intercepting ${info.id.slice(0, 8)}…`);
162
+ // ─── Done ─────────────────────────────────────────────────────────────
163
+
164
+ const channels = [config.telegram && "Telegram", config.slack && "Slack"]
165
+ .filter(Boolean)
166
+ .join(" + ");
167
+ log.info(`[${TAG}] v${PLUGIN_VERSION} loaded ✓ (${channels})`);
168
+ }
156
169
 
157
- // Send formatted message with inline buttons
158
- const messageId = await tg.sendMessage(
159
- config.chatId,
160
- formatApprovalRequest(info),
161
- buildApprovalKeyboard(info.id),
170
+ // ─── Channel handlers ───────────────────────────────────────────────────────
171
+
172
+ async function handleTelegram(
173
+ event: { content: string },
174
+ chatId: string,
175
+ tg: TelegramApi,
176
+ store: ApprovalStore,
177
+ log: any,
178
+ ): Promise<{ cancel: true } | void> {
179
+ // Check for approval resolution
180
+ const resolution = detectApprovalResult(event.content, store.entries());
181
+ if (resolution) {
182
+ const entry = store.resolve(resolution.id);
183
+ if (entry && entry.channel === "telegram") {
184
+ log.info(`[${TAG}] telegram resolved ${resolution.id.slice(0, 8)}… → ${resolution.action}`);
185
+ await tg.editMessageText(
186
+ chatId,
187
+ entry.messageId,
188
+ formatApprovalResolved(entry.info, resolution.action),
162
189
  );
190
+ }
191
+ return;
192
+ }
163
193
 
164
- if (messageId === null) {
165
- log.warn(`[${TAG}] send failed for ${info.id.slice(0, 8)}… — falling back to plain text`);
166
- // Don't cancel: let the plain-text message through as fallback
167
- return;
168
- }
194
+ // Check for new approval request
195
+ const info = parseApprovalText(event.content);
196
+ if (!info) return;
169
197
 
170
- // Track it
171
- store.add(info.id, messageId, info);
172
- log.info(`[${TAG}] sent buttons for ${info.id.slice(0, 8)}… (msg=${messageId})`);
198
+ if (store.has(info.id)) return { cancel: true };
173
199
 
174
- // Cancel the original plain-text message
175
- return { cancel: true };
176
- },
200
+ log.info(`[${TAG}] telegram intercepting ${info.id.slice(0, 8)}…`);
201
+
202
+ const messageId = await tg.sendMessage(
203
+ chatId,
204
+ formatApprovalRequest(info),
205
+ buildApprovalKeyboard(info.id),
177
206
  );
178
207
 
179
- // ─── Done ─────────────────────────────────────────────────────────────
208
+ if (messageId === null) {
209
+ log.warn(`[${TAG}] telegram send failed for ${info.id.slice(0, 8)}… — falling back`);
210
+ return;
211
+ }
212
+
213
+ store.add(info.id, "telegram", { messageId }, info);
214
+ log.info(`[${TAG}] telegram sent buttons for ${info.id.slice(0, 8)}… (msg=${messageId})`);
215
+ return { cancel: true };
216
+ }
217
+
218
+ async function handleSlack(
219
+ event: { content: string },
220
+ channelId: string,
221
+ slackApi: SlackApi,
222
+ store: ApprovalStore,
223
+ log: any,
224
+ ): Promise<{ cancel: true } | void> {
225
+ // Check for approval resolution
226
+ const resolution = detectApprovalResult(event.content, store.entries());
227
+ if (resolution) {
228
+ const entry = store.resolve(resolution.id);
229
+ if (entry && entry.channel === "slack") {
230
+ log.info(`[${TAG}] slack resolved ${resolution.id.slice(0, 8)}… → ${resolution.action}`);
231
+ await slackApi.updateMessage(
232
+ channelId,
233
+ entry.slackTs,
234
+ `Exec ${resolution.action}`,
235
+ formatSlackApprovalResolved(entry.info, resolution.action),
236
+ );
237
+ }
238
+ return;
239
+ }
240
+
241
+ // Check for new approval request
242
+ const info = parseApprovalText(event.content);
243
+ if (!info) return;
244
+
245
+ if (store.has(info.id)) return { cancel: true };
246
+
247
+ log.info(`[${TAG}] slack intercepting ${info.id.slice(0, 8)}…`);
248
+
249
+ const ts = await slackApi.postMessage(
250
+ channelId,
251
+ slackFallbackText(info),
252
+ formatSlackApprovalRequest(info),
253
+ );
254
+
255
+ if (ts === null) {
256
+ log.warn(`[${TAG}] slack send failed for ${info.id.slice(0, 8)}… — falling back`);
257
+ return;
258
+ }
180
259
 
181
- log.info(`[${TAG}] v${PLUGIN_VERSION} loaded ✓`);
260
+ store.add(info.id, "slack", { slackTs: ts }, info);
261
+ log.info(`[${TAG}] slack sent buttons for ${info.id.slice(0, 8)}… (ts=${ts})`);
262
+ return { cancel: true };
182
263
  }
183
264
 
184
265
  // ─── Plugin export ──────────────────────────────────────────────────────────
185
266
 
186
267
  export default {
187
- id: "telegram-approval-buttons",
188
- name: "Telegram Approval Buttons",
268
+ id: "approval-buttons",
269
+ name: "Approval Buttons",
189
270
  description:
190
- "Adds inline keyboard buttons to exec approval messages in Telegram. " +
271
+ "Adds inline buttons to exec approval messages in Telegram and Slack. " +
191
272
  "Tap to approve/deny without typing commands.",
192
273
  version: PLUGIN_VERSION,
193
274
  kind: "extension" as const,
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
- // telegram-approval-buttons · lib/approval-parser.ts
2
+ // approval-buttons · lib/approval-parser.ts
3
3
  // Parse OpenClaw's plain-text exec approval format into structured data
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
@@ -9,6 +9,9 @@ import type { ApprovalAction, ApprovalInfo, ApprovalResolution, SentApproval } f
9
9
 
10
10
  const RE_APPROVAL_MARKER = /Exec approval required/i;
11
11
  const RE_ID = /ID:\s*([a-f0-9-]+)/i;
12
+ 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
+ const RE_SHORT_HEX = /\b([a-f0-9]{8,})\b/i;
14
+ const RE_GATEWAY_DENIAL = /Exec denied.*approval-timeout/i;
12
15
  const RE_COMMAND_BLOCK = /Command:\s*`{0,3}\n?(.+?)\n?`{0,3}(?:\n|$)/is;
13
16
  const RE_COMMAND_INLINE = /Command:\s*(.+)/i;
14
17
  const RE_CWD = /CWD:\s*(.+)/i;
@@ -50,25 +53,56 @@ export function parseApprovalText(text: string): ApprovalInfo | null {
50
53
  /**
51
54
  * Detect if an outgoing message indicates an approval was resolved.
52
55
  *
53
- * Checks whether the message text references any pending approval ID
54
- * (full UUID or first 8 chars) and attempts to determine the action.
56
+ * Uses O(1) Map lookup instead of iterating all pending entries:
57
+ * 1. Extract full UUID from text via regex direct Map.has() (O(1))
58
+ * 2. Fallback: extract short hex ID (8+ chars) → scan pending by prefix
59
+ *
60
+ * Also detects gateway-initiated denials (approval-timeout) so the
61
+ * plugin can immediately clean up stale buttons in Telegram.
55
62
  */
56
63
  export function detectApprovalResult(
57
64
  text: string,
58
65
  pending: ReadonlyMap<string, SentApproval>,
59
66
  ): ApprovalResolution | null {
60
- for (const [id] of pending) {
61
- const shortId = id.slice(0, 8);
62
- if (!text.includes(id) && !text.includes(shortId)) continue;
67
+ if (pending.size === 0) return null;
68
+
69
+ // Fast path: full UUID → O(1) Map lookup
70
+ const fullMatch = text.match(RE_UUID);
71
+ if (fullMatch) {
72
+ const id = fullMatch[1];
73
+ if (pending.has(id)) {
74
+ return { id, action: resolveAction(text) };
75
+ }
76
+ }
63
77
 
64
- const action = inferAction(text);
65
- return { id, action };
78
+ // Slow path: short hex ID (8+ chars) → prefix scan on pending keys
79
+ // This handles messages that only reference a truncated approval ID
80
+ const shortMatch = text.match(RE_SHORT_HEX);
81
+ if (shortMatch) {
82
+ const shortId = shortMatch[1];
83
+ for (const [pendingId] of pending) {
84
+ if (pendingId.startsWith(shortId)) {
85
+ return { id: pendingId, action: resolveAction(text) };
86
+ }
87
+ }
66
88
  }
89
+
67
90
  return null;
68
91
  }
69
92
 
70
93
  // ─── Internal ───────────────────────────────────────────────────────────────
71
94
 
95
+ /**
96
+ * Determine the approval action from message text.
97
+ * Checks gateway denial first, then infers from keywords.
98
+ * Order matters: check most specific patterns first.
99
+ */
100
+ function resolveAction(text: string): ApprovalAction {
101
+ // Gateway-initiated denial takes priority (unambiguous signal)
102
+ if (RE_GATEWAY_DENIAL.test(text)) return "deny";
103
+ return inferAction(text);
104
+ }
105
+
72
106
  /**
73
107
  * Infer the approval action from message text.
74
108
  * Order matters: check most specific patterns first.