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 +56 -0
- package/README.md +48 -19
- package/SECURITY.md +39 -0
- package/index.ts +155 -74
- package/lib/approval-parser.ts +42 -8
- package/lib/approval-store.ts +11 -4
- package/lib/diagnostics.ts +126 -57
- package/lib/message-formatter.ts +35 -26
- package/lib/slack-api.ts +138 -0
- package/lib/slack-formatter.ts +137 -0
- package/lib/telegram-api.ts +2 -2
- package/openclaw.plugin.json +27 -7
- package/package.json +8 -3
- package/types.ts +35 -5
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
|
-
# 🔐
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
105
|
-
|
|
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.
|
|
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 ≥
|
|
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
|
-
"
|
|
165
|
+
"approval-buttons": {
|
|
163
166
|
"enabled": true,
|
|
164
167
|
"config": {
|
|
165
|
-
"chatId": "123456789",
|
|
166
|
-
"botToken": "123:ABC...",
|
|
167
|
-
"
|
|
168
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 = "
|
|
34
|
-
const TAG = "
|
|
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 —
|
|
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
|
|
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
|
|
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
|
|
99
|
+
// onExpired: edit the message to show "expired"
|
|
77
100
|
(entry) => {
|
|
78
|
-
tg.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
// ───
|
|
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
|
-
|
|
93
|
-
runStartupChecks(tg, log).catch(() => { });
|
|
124
|
+
runStartupChecks(tg, slack, log).catch(() => {});
|
|
94
125
|
},
|
|
95
126
|
stop: () => store.stop(),
|
|
96
127
|
});
|
|
97
128
|
|
|
98
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
//
|
|
120
|
-
if (ctx.channelId
|
|
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
|
-
// ──
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
194
|
+
// Check for new approval request
|
|
195
|
+
const info = parseApprovalText(event.content);
|
|
196
|
+
if (!info) return;
|
|
169
197
|
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
188
|
-
name: "
|
|
268
|
+
id: "approval-buttons",
|
|
269
|
+
name: "Approval Buttons",
|
|
189
270
|
description:
|
|
190
|
-
"Adds inline
|
|
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,
|
package/lib/approval-parser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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.
|