telegram-approval-buttons 4.1.0 → 5.0.1
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 +22 -0
- package/README.md +23 -13
- package/index.ts +152 -71
- package/lib/approval-store.ts +10 -3
- package/lib/diagnostics.ts +125 -56
- package/lib/message-formatter.ts +34 -25
- package/lib/slack-api.ts +138 -0
- package/lib/slack-formatter.ts +137 -0
- package/lib/telegram-api.ts +1 -1
- package/openclaw.plugin.json +24 -4
- package/package.json +3 -2
- package/types.ts +34 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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.1] - 2026-03-01
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Reverted plugin ID to `telegram-approval-buttons`** — v5.0.0 renamed it to `approval-buttons`, causing a config warning on every startup. The plugin ID now matches the npm package name again. No config changes needed for users upgrading from v4.x.
|
|
12
|
+
- Normalized all file headers to use consistent naming
|
|
13
|
+
|
|
14
|
+
## [5.0.0] - 2026-02-24
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Slack inline button support** — approval buttons now work in Slack via Block Kit interactive messages
|
|
18
|
+
- New files: `lib/slack-api.ts`, `lib/slack-formatter.ts`, `tests/slack-formatter.test.ts`
|
|
19
|
+
- Auto-detects Slack credentials from `channels.slack` config or `SLACK_BOT_TOKEN` / `SLACK_CHANNEL_ID` env vars
|
|
20
|
+
- `/approvalstatus` now shows both Telegram and Slack connectivity
|
|
21
|
+
- Multi-channel architecture: either or both channels can be independently enabled
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **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
|
|
25
|
+
|
|
26
|
+
### Contributors
|
|
27
|
+
|
|
28
|
+
Thanks to [@sjkey](https://github.com/sjkey) for contributing Slack support and the message simplification improvements in this release 🙏
|
|
29
|
+
|
|
8
30
|
## [4.1.0] - 2026-02-20
|
|
9
31
|
|
|
10
32
|
### Changed
|
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
|
|
|
@@ -101,8 +102,10 @@ 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
|
```
|
|
@@ -162,10 +165,12 @@ The plugin **auto-detects** `botToken` and `chatId` from your Telegram channel c
|
|
|
162
165
|
"telegram-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
|
}
|
|
@@ -202,8 +207,6 @@ A: Yes, but the bot needs to be an admin or it needs permission to edit its own
|
|
|
202
207
|
| `/approvalstatus` says "unknown command" | Plugin didn't load. Run `openclaw plugins install telegram-approval-buttons` and restart the gateway. |
|
|
203
208
|
| No buttons appear | Check `tools.exec.ask` is not `"off"`. Run `/approvalstatus` to check config. |
|
|
204
209
|
| 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
210
|
| Buttons say "expired" | Approval timed out before you tapped. Adjust `staleMins` if needed. |
|
|
208
211
|
|
|
209
212
|
## Architecture
|
|
@@ -213,9 +216,11 @@ telegram-approval-buttons/
|
|
|
213
216
|
├── index.ts # Entry point — orchestration only
|
|
214
217
|
├── types.ts # Shared TypeScript interfaces
|
|
215
218
|
├── lib/
|
|
216
|
-
│ ├── telegram-api.ts # Telegram Bot API client
|
|
219
|
+
│ ├── telegram-api.ts # Telegram Bot API client
|
|
220
|
+
│ ├── slack-api.ts # Slack Web API client
|
|
217
221
|
│ ├── approval-parser.ts # Parse OpenClaw approval text format
|
|
218
222
|
│ ├── message-formatter.ts # HTML formatting for Telegram messages
|
|
223
|
+
│ ├── slack-formatter.ts # Block Kit formatting for Slack messages
|
|
219
224
|
│ ├── approval-store.ts # In-memory pending approval tracker
|
|
220
225
|
│ └── diagnostics.ts # Config resolution, health checks
|
|
221
226
|
├── openclaw.plugin.json # Plugin manifest
|
|
@@ -226,6 +231,11 @@ telegram-approval-buttons/
|
|
|
226
231
|
|
|
227
232
|
Issues and PRs welcome. Each file in `lib/` is self-contained with a single responsibility.
|
|
228
233
|
|
|
234
|
+
## Contributors
|
|
235
|
+
|
|
236
|
+
- [@JairFC](https://github.com/JairFC) — creator and maintainer
|
|
237
|
+
- [@sjkey](https://github.com/sjkey) — Slack support and message simplification (v5.0.0)
|
|
238
|
+
|
|
229
239
|
## License
|
|
230
240
|
|
|
231
241
|
[MIT](LICENSE)
|
package/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// telegram-approval-buttons · index.ts (
|
|
2
|
+
// telegram-approval-buttons · index.ts (v5.0.1)
|
|
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,7 +37,7 @@ import {
|
|
|
30
37
|
|
|
31
38
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
32
39
|
|
|
33
|
-
const PLUGIN_VERSION = "
|
|
40
|
+
const PLUGIN_VERSION = "5.0.1";
|
|
34
41
|
const TAG = "telegram-approval-buttons";
|
|
35
42
|
|
|
36
43
|
// ── Plugin registration ─────────────────────────────────────────────────────
|
|
@@ -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,69 +147,119 @@ 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 ──────────────────────────────────────────────────────────
|
|
@@ -187,7 +268,7 @@ export default {
|
|
|
187
268
|
id: "telegram-approval-buttons",
|
|
188
269
|
name: "Telegram 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-store.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// In-memory store for pending approvals with TTL-based cleanup
|
|
4
4
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { ApprovalChannel, ApprovalInfo, Logger, SentApproval } from "../types.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Manages the lifecycle of pending approval requests.
|
|
@@ -57,9 +57,16 @@ export class ApprovalStore {
|
|
|
57
57
|
/**
|
|
58
58
|
* Track a newly sent approval message.
|
|
59
59
|
*/
|
|
60
|
-
add(
|
|
60
|
+
add(
|
|
61
|
+
approvalId: string,
|
|
62
|
+
channel: ApprovalChannel,
|
|
63
|
+
ref: { messageId?: number; slackTs?: string },
|
|
64
|
+
info: ApprovalInfo,
|
|
65
|
+
): void {
|
|
61
66
|
this.pending.set(approvalId, {
|
|
62
|
-
|
|
67
|
+
channel,
|
|
68
|
+
messageId: ref.messageId ?? 0,
|
|
69
|
+
slackTs: ref.slackTs ?? "",
|
|
63
70
|
info,
|
|
64
71
|
sentAt: Date.now(),
|
|
65
72
|
});
|
package/lib/diagnostics.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { HealthCheck, Logger, PluginConfig, ResolvedConfig } from "../types.js";
|
|
7
7
|
import type { TelegramApi } from "./telegram-api.js";
|
|
8
|
+
import type { SlackApi } from "./slack-api.js";
|
|
8
9
|
import type { ApprovalStore } from "./approval-store.js";
|
|
9
10
|
|
|
10
11
|
// ─── Config resolution ─────────────────────────────────────────────────────
|
|
@@ -12,62 +13,96 @@ import type { ApprovalStore } from "./approval-store.js";
|
|
|
12
13
|
export interface ConfigSources {
|
|
13
14
|
pluginConfig: PluginConfig;
|
|
14
15
|
telegramChannelConfig: { token?: string; allowFrom?: (string | number)[] };
|
|
15
|
-
|
|
16
|
+
slackChannelConfig: { token?: string; botToken?: string; allowFrom?: (string | number)[] };
|
|
17
|
+
env: {
|
|
18
|
+
TELEGRAM_BOT_TOKEN?: string;
|
|
19
|
+
TELEGRAM_CHAT_ID?: string;
|
|
20
|
+
SLACK_BOT_TOKEN?: string;
|
|
21
|
+
SLACK_CHANNEL_ID?: string;
|
|
22
|
+
};
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* Resolve plugin configuration from multiple sources with priority:
|
|
20
27
|
* 1. pluginConfig (explicit config in openclaw.json)
|
|
21
|
-
* 2. channels.telegram (shared
|
|
28
|
+
* 2. channels.telegram / channels.slack (shared channel config)
|
|
22
29
|
* 3. Environment variables (fallback)
|
|
23
30
|
*
|
|
24
|
-
* Returns null
|
|
31
|
+
* Returns null only if BOTH Telegram and Slack are unconfigurable.
|
|
32
|
+
* Either channel can be independently enabled/disabled.
|
|
25
33
|
*/
|
|
26
34
|
export function resolveConfig(
|
|
27
35
|
sources: ConfigSources,
|
|
28
36
|
log: Logger,
|
|
29
37
|
): ResolvedConfig | null {
|
|
30
|
-
const { pluginConfig, telegramChannelConfig, env } = sources;
|
|
38
|
+
const { pluginConfig, telegramChannelConfig, slackChannelConfig, env } = sources;
|
|
31
39
|
|
|
32
|
-
// ──
|
|
33
|
-
const
|
|
40
|
+
// ── Telegram config ─────────────────────────────────────────────────
|
|
41
|
+
const tgBotToken =
|
|
34
42
|
pluginConfig.botToken ||
|
|
35
43
|
telegramChannelConfig.token ||
|
|
36
44
|
env.TELEGRAM_BOT_TOKEN ||
|
|
37
45
|
"";
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
log.warn(
|
|
41
|
-
"[diagnostics] No bot token found. Check: " +
|
|
42
|
-
"pluginConfig.botToken → channels.telegram.token → TELEGRAM_BOT_TOKEN env",
|
|
43
|
-
);
|
|
44
|
-
}
|
|
47
|
+
let tgChatId = pluginConfig.chatId || env.TELEGRAM_CHAT_ID || "";
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
let chatId = pluginConfig.chatId || env.TELEGRAM_CHAT_ID || "";
|
|
48
|
-
|
|
49
|
-
// Auto-repair: extract from allowFrom if not explicitly set
|
|
50
|
-
if (!chatId && Array.isArray(telegramChannelConfig.allowFrom)) {
|
|
49
|
+
if (!tgChatId && Array.isArray(telegramChannelConfig.allowFrom)) {
|
|
51
50
|
const first = telegramChannelConfig.allowFrom[0];
|
|
52
51
|
const candidate = String(first ?? "");
|
|
53
52
|
if (/^-?\d+$/.test(candidate)) {
|
|
54
|
-
|
|
53
|
+
tgChatId = candidate;
|
|
55
54
|
log.info(
|
|
56
|
-
`[diagnostics] Auto-resolved chatId from channels.telegram.allowFrom: ${
|
|
55
|
+
`[diagnostics] Auto-resolved Telegram chatId from channels.telegram.allowFrom: ${tgChatId}`,
|
|
57
56
|
);
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
const telegram =
|
|
61
|
+
tgBotToken && tgChatId
|
|
62
|
+
? { chatId: tgChatId, botToken: tgBotToken }
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
if (!telegram) {
|
|
66
|
+
log.info("[diagnostics] Telegram not configured (optional)");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Slack config ────────────────────────────────────────────────────
|
|
70
|
+
const slackBotToken =
|
|
71
|
+
pluginConfig.slackBotToken ||
|
|
72
|
+
slackChannelConfig.botToken ||
|
|
73
|
+
slackChannelConfig.token ||
|
|
74
|
+
env.SLACK_BOT_TOKEN ||
|
|
75
|
+
"";
|
|
76
|
+
|
|
77
|
+
let slackChannelId = pluginConfig.slackChannelId || env.SLACK_CHANNEL_ID || "";
|
|
78
|
+
|
|
79
|
+
// Auto-detect from Slack channel's allowFrom (user/channel ID)
|
|
80
|
+
if (!slackChannelId && Array.isArray(slackChannelConfig.allowFrom)) {
|
|
81
|
+
const first = slackChannelConfig.allowFrom[0];
|
|
82
|
+
const candidate = String(first ?? "");
|
|
83
|
+
// Slack IDs start with U (user), D (DM), C (channel), G (group)
|
|
84
|
+
if (/^[UDCGW][A-Z0-9]+$/.test(candidate)) {
|
|
85
|
+
slackChannelId = candidate;
|
|
86
|
+
log.info(
|
|
87
|
+
`[diagnostics] Auto-resolved Slack channelId from channels.slack.allowFrom: ${slackChannelId}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const slack =
|
|
93
|
+
slackBotToken && slackChannelId
|
|
94
|
+
? { channelId: slackChannelId, botToken: slackBotToken }
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
if (!slack) {
|
|
98
|
+
log.info("[diagnostics] Slack not configured (optional)");
|
|
66
99
|
}
|
|
67
100
|
|
|
68
|
-
// ──
|
|
69
|
-
if (!
|
|
70
|
-
log.error(
|
|
101
|
+
// ── At least one channel required ─────────────────────────────────
|
|
102
|
+
if (!telegram && !slack) {
|
|
103
|
+
log.error(
|
|
104
|
+
"[diagnostics] Plugin disabled — neither Telegram nor Slack is configured",
|
|
105
|
+
);
|
|
71
106
|
return null;
|
|
72
107
|
}
|
|
73
108
|
|
|
@@ -79,27 +114,31 @@ export function resolveConfig(
|
|
|
79
114
|
|
|
80
115
|
const verbose = pluginConfig.verbose === true;
|
|
81
116
|
|
|
82
|
-
return {
|
|
117
|
+
return { telegram, slack, staleMins, verbose };
|
|
83
118
|
}
|
|
84
119
|
|
|
85
120
|
// ─── Health check ───────────────────────────────────────────────────────────
|
|
86
121
|
|
|
87
122
|
/**
|
|
88
|
-
* Run a full health check: config
|
|
123
|
+
* Run a full health check: config + connectivity + store stats.
|
|
89
124
|
*/
|
|
90
125
|
export async function runHealthCheck(
|
|
91
126
|
config: ResolvedConfig | null,
|
|
92
127
|
tg: TelegramApi | null,
|
|
128
|
+
slackApi: SlackApi | null,
|
|
93
129
|
store: ApprovalStore,
|
|
94
130
|
startedAt: number,
|
|
95
131
|
): Promise<HealthCheck> {
|
|
96
132
|
const health: HealthCheck = {
|
|
97
133
|
ok: false,
|
|
98
134
|
config: {
|
|
99
|
-
|
|
100
|
-
|
|
135
|
+
telegramChatId: !!config?.telegram?.chatId,
|
|
136
|
+
telegramToken: !!config?.telegram?.botToken,
|
|
137
|
+
slackToken: !!config?.slack?.botToken,
|
|
138
|
+
slackChannel: !!config?.slack?.channelId,
|
|
101
139
|
},
|
|
102
140
|
telegram: { reachable: false },
|
|
141
|
+
slack: { reachable: false },
|
|
103
142
|
store: {
|
|
104
143
|
pending: store.pendingCount,
|
|
105
144
|
totalProcessed: store.processedCount,
|
|
@@ -107,22 +146,33 @@ export async function runHealthCheck(
|
|
|
107
146
|
uptime: Date.now() - startedAt,
|
|
108
147
|
};
|
|
109
148
|
|
|
110
|
-
//
|
|
111
|
-
if (
|
|
149
|
+
// Telegram connectivity
|
|
150
|
+
if (config?.telegram && tg) {
|
|
151
|
+
const me = await tg.getMe();
|
|
152
|
+
if (me.ok) {
|
|
153
|
+
health.telegram.reachable = true;
|
|
154
|
+
health.telegram.botUsername = me.username;
|
|
155
|
+
} else {
|
|
156
|
+
health.telegram.error = me.error;
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
112
159
|
health.telegram.error = "not configured";
|
|
113
|
-
return health;
|
|
114
160
|
}
|
|
115
161
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
162
|
+
// Slack connectivity
|
|
163
|
+
if (config?.slack && slackApi) {
|
|
164
|
+
const auth = await slackApi.authTest();
|
|
165
|
+
if (auth.ok) {
|
|
166
|
+
health.slack.reachable = true;
|
|
167
|
+
health.slack.teamName = auth.teamName;
|
|
168
|
+
} else {
|
|
169
|
+
health.slack.error = auth.error;
|
|
170
|
+
}
|
|
122
171
|
} else {
|
|
123
|
-
health.
|
|
172
|
+
health.slack.error = "not configured";
|
|
124
173
|
}
|
|
125
174
|
|
|
175
|
+
health.ok = health.telegram.reachable || health.slack.reachable;
|
|
126
176
|
return health;
|
|
127
177
|
}
|
|
128
178
|
|
|
@@ -135,30 +185,49 @@ export function logStartupDiagnostics(
|
|
|
135
185
|
config: ResolvedConfig,
|
|
136
186
|
log: Logger,
|
|
137
187
|
): void {
|
|
138
|
-
const
|
|
139
|
-
|
|
188
|
+
const channels: string[] = [];
|
|
189
|
+
|
|
190
|
+
if (config.telegram) {
|
|
191
|
+
const maskedToken = config.telegram.botToken.slice(0, 6) + "…" + config.telegram.botToken.slice(-4);
|
|
192
|
+
const maskedChatId = config.telegram.chatId.slice(0, 3) + "…" + config.telegram.chatId.slice(-2);
|
|
193
|
+
channels.push(`telegram(chatId=${maskedChatId}, token=${maskedToken})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (config.slack) {
|
|
197
|
+
const maskedToken = config.slack.botToken.slice(0, 8) + "…" + config.slack.botToken.slice(-4);
|
|
198
|
+
const maskedChannel = config.slack.channelId.slice(0, 3) + "…" + config.slack.channelId.slice(-2);
|
|
199
|
+
channels.push(`slack(channel=${maskedChannel}, token=${maskedToken})`);
|
|
200
|
+
}
|
|
201
|
+
|
|
140
202
|
log.info(
|
|
141
|
-
`[diagnostics] Config OK →
|
|
142
|
-
|
|
143
|
-
`verbose=${config.verbose}`,
|
|
203
|
+
`[diagnostics] Config OK → ${channels.join(", ")}, ` +
|
|
204
|
+
`staleMins=${config.staleMins}, verbose=${config.verbose}`,
|
|
144
205
|
);
|
|
145
206
|
}
|
|
146
207
|
|
|
147
208
|
/**
|
|
148
209
|
* Run async startup checks (non-blocking).
|
|
149
|
-
* Verifies Telegram API connectivity and logs the result.
|
|
150
210
|
*/
|
|
151
211
|
export async function runStartupChecks(
|
|
152
|
-
tg: TelegramApi,
|
|
212
|
+
tg: TelegramApi | null,
|
|
213
|
+
slackApi: SlackApi | null,
|
|
153
214
|
log: Logger,
|
|
154
215
|
): Promise<void> {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
`[diagnostics] Telegram unreachable: ${me.error}
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
if (tg) {
|
|
217
|
+
const me = await tg.getMe();
|
|
218
|
+
if (me.ok) {
|
|
219
|
+
log.info(`[diagnostics] Telegram connected → @${me.username}`);
|
|
220
|
+
} else {
|
|
221
|
+
log.warn(`[diagnostics] Telegram unreachable: ${me.error}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (slackApi) {
|
|
226
|
+
const auth = await slackApi.authTest();
|
|
227
|
+
if (auth.ok) {
|
|
228
|
+
log.info(`[diagnostics] Slack connected → ${auth.teamName}`);
|
|
229
|
+
} else {
|
|
230
|
+
log.warn(`[diagnostics] Slack unreachable: ${auth.error}`);
|
|
231
|
+
}
|
|
163
232
|
}
|
|
164
233
|
}
|
package/lib/message-formatter.ts
CHANGED
|
@@ -23,17 +23,12 @@ export function escapeHtml(text: string): string {
|
|
|
23
23
|
export function formatApprovalRequest(info: ApprovalInfo): string {
|
|
24
24
|
const e = escapeHtml;
|
|
25
25
|
return [
|
|
26
|
-
`🔐 <b>Exec Approval
|
|
27
|
-
``,
|
|
28
|
-
`🤖 Agent: <b>${e(info.agent)}</b>`,
|
|
29
|
-
`🖥️ Host: <b>${e(info.host)}</b>`,
|
|
30
|
-
`📁 CWD: <code>${e(info.cwd)}</code>`,
|
|
26
|
+
`🔐 <b>Exec Approval</b>`,
|
|
31
27
|
``,
|
|
32
28
|
`<pre>${e(info.command)}</pre>`,
|
|
33
29
|
``,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`⏱️ Expires: ${e(info.expires)}`,
|
|
30
|
+
`📁 <code>${e(info.cwd)}</code>`,
|
|
31
|
+
`🤖 ${e(info.agent)} · ⏱️ ${e(info.expires)}`,
|
|
37
32
|
`🆔 <code>${e(info.id)}</code>`,
|
|
38
33
|
].join("\n");
|
|
39
34
|
}
|
|
@@ -65,15 +60,11 @@ export function formatApprovalResolved(
|
|
|
65
60
|
const label = ACTION_LABELS[action] ?? action;
|
|
66
61
|
|
|
67
62
|
return [
|
|
68
|
-
`${icon} <b
|
|
69
|
-
``,
|
|
70
|
-
`🤖 Agent: <b>${e(info.agent)}</b>`,
|
|
71
|
-
`🖥️ Host: <b>${e(info.host)}</b>`,
|
|
72
|
-
`📁 CWD: <code>${e(info.cwd)}</code>`,
|
|
63
|
+
`${icon} <b>${label}</b>`,
|
|
73
64
|
``,
|
|
74
65
|
`<pre>${e(info.command)}</pre>`,
|
|
75
66
|
``,
|
|
76
|
-
|
|
67
|
+
`🤖 ${e(info.agent)} · 🆔 <code>${e(info.id)}</code>`,
|
|
77
68
|
].join("\n");
|
|
78
69
|
}
|
|
79
70
|
|
|
@@ -107,26 +98,24 @@ export function buildApprovalKeyboard(approvalId: string): object {
|
|
|
107
98
|
export function formatApprovalExpired(info: ApprovalInfo): string {
|
|
108
99
|
const e = escapeHtml;
|
|
109
100
|
return [
|
|
110
|
-
`⏰ <b>
|
|
111
|
-
``,
|
|
112
|
-
`🤖 Agent: <b>${e(info.agent)}</b>`,
|
|
113
|
-
`🖥️ Host: <b>${e(info.host)}</b>`,
|
|
101
|
+
`⏰ <b>Expired</b>`,
|
|
114
102
|
``,
|
|
115
103
|
`<pre>${e(info.command)}</pre>`,
|
|
116
104
|
``,
|
|
117
|
-
|
|
105
|
+
`🤖 ${e(info.agent)} · 🆔 <code>${e(info.id)}</code>`,
|
|
118
106
|
].join("\n");
|
|
119
107
|
}
|
|
120
108
|
|
|
121
109
|
// ─── Health / diagnostics format ────────────────────────────────────────────
|
|
122
110
|
|
|
123
111
|
/**
|
|
124
|
-
* Format a health check result for display
|
|
112
|
+
* Format a health check result for display.
|
|
125
113
|
*/
|
|
126
114
|
export function formatHealthCheck(health: {
|
|
127
115
|
ok: boolean;
|
|
128
|
-
config: {
|
|
116
|
+
config: { telegramChatId: boolean; telegramToken: boolean; slackToken: boolean; slackChannel: boolean };
|
|
129
117
|
telegram: { reachable: boolean; botUsername?: string; error?: string };
|
|
118
|
+
slack: { reachable: boolean; teamName?: string; error?: string };
|
|
130
119
|
store: { pending: number; totalProcessed: number };
|
|
131
120
|
uptime: number;
|
|
132
121
|
}): string {
|
|
@@ -134,16 +123,36 @@ export function formatHealthCheck(health: {
|
|
|
134
123
|
const lines = [
|
|
135
124
|
`${health.ok ? "🟢" : "🔴"} Approval Buttons Status`,
|
|
136
125
|
``,
|
|
137
|
-
`Config: chatId=${health.config.chatId ? "✓" : "✗"} · token=${health.config.botToken ? "✓" : "✗"}`,
|
|
138
126
|
];
|
|
139
127
|
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
// Telegram status
|
|
129
|
+
const tgConfigured = health.config.telegramChatId && health.config.telegramToken;
|
|
130
|
+
if (tgConfigured) {
|
|
131
|
+
lines.push(`Telegram: chatId=${health.config.telegramChatId ? "✓" : "✗"} · token=${health.config.telegramToken ? "✓" : "✗"}`);
|
|
132
|
+
if (health.telegram.reachable) {
|
|
133
|
+
lines.push(` ✓ connected (@${health.telegram.botUsername ?? "?"})`);
|
|
134
|
+
} else {
|
|
135
|
+
lines.push(` ✗ ${health.telegram.error ?? "unreachable"}`);
|
|
136
|
+
}
|
|
142
137
|
} else {
|
|
143
|
-
lines.push(`Telegram:
|
|
138
|
+
lines.push(`Telegram: not configured`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Slack status
|
|
142
|
+
const slackConfigured = health.config.slackToken && health.config.slackChannel;
|
|
143
|
+
if (slackConfigured) {
|
|
144
|
+
lines.push(`Slack: token=${health.config.slackToken ? "✓" : "✗"} · channel=${health.config.slackChannel ? "✓" : "✗"}`);
|
|
145
|
+
if (health.slack.reachable) {
|
|
146
|
+
lines.push(` ✓ connected (${health.slack.teamName ?? "?"})`);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(` ✗ ${health.slack.error ?? "unreachable"}`);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
lines.push(`Slack: not configured`);
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
lines.push(
|
|
155
|
+
``,
|
|
147
156
|
`Pending: ${health.store.pending} · Processed: ${health.store.totalProcessed}`,
|
|
148
157
|
`Uptime: ${uptimeMin}m`,
|
|
149
158
|
);
|
package/lib/slack-api.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// telegram-approval-buttons · lib/slack-api.ts
|
|
3
|
+
// Isolated Slack Web API wrapper — only depends on fetch (Node built-in)
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import type { Logger } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const API_BASE = "https://slack.com/api/";
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
// ─── Internal helpers ───────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
interface SlackResponse<T = unknown> {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
ts?: string;
|
|
17
|
+
channel?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function slackFetch<T = unknown>(
|
|
22
|
+
token: string,
|
|
23
|
+
method: string,
|
|
24
|
+
body: Record<string, unknown>,
|
|
25
|
+
log?: Logger,
|
|
26
|
+
): Promise<SlackResponse<T>> {
|
|
27
|
+
const url = `${API_BASE}${method}`;
|
|
28
|
+
try {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
31
|
+
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
|
|
44
|
+
const data = (await res.json()) as SlackResponse<T>;
|
|
45
|
+
if (!data.ok && log) {
|
|
46
|
+
log.warn(`[slack-api] ${method} failed: ${data.error}`);
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
+
log?.error(`[slack-api] ${method} network error: ${msg}`);
|
|
52
|
+
return { ok: false, error: msg };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Slack Web API client.
|
|
60
|
+
* Instantiate with a bot OAuth token; all methods are self-contained.
|
|
61
|
+
*/
|
|
62
|
+
export class SlackApi {
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly token: string,
|
|
65
|
+
private readonly log?: Logger,
|
|
66
|
+
) { }
|
|
67
|
+
|
|
68
|
+
// ── Connectivity ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Call auth.test to verify the bot token and get bot info.
|
|
72
|
+
*/
|
|
73
|
+
async authTest(): Promise<{ ok: true; botId: string; teamName: string } | { ok: false; error: string }> {
|
|
74
|
+
const res = await slackFetch<{ bot_id: string; team: string }>(
|
|
75
|
+
this.token,
|
|
76
|
+
"auth.test",
|
|
77
|
+
{},
|
|
78
|
+
this.log,
|
|
79
|
+
);
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
botId: (res as any).bot_id ?? (res as any).user_id ?? "unknown",
|
|
84
|
+
teamName: (res as any).team ?? "unknown",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, error: res.error ?? "unknown error" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Messaging ───────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send a message with Block Kit blocks to a channel or DM.
|
|
94
|
+
* For DMs, pass the user's Slack ID as channelId — Slack opens the DM automatically.
|
|
95
|
+
* Returns the message timestamp (ts) on success, null on failure.
|
|
96
|
+
*/
|
|
97
|
+
async postMessage(
|
|
98
|
+
channelId: string,
|
|
99
|
+
text: string,
|
|
100
|
+
blocks: object[],
|
|
101
|
+
): Promise<string | null> {
|
|
102
|
+
const res = await slackFetch(
|
|
103
|
+
this.token,
|
|
104
|
+
"chat.postMessage",
|
|
105
|
+
{
|
|
106
|
+
channel: channelId,
|
|
107
|
+
text, // fallback for notifications
|
|
108
|
+
blocks,
|
|
109
|
+
},
|
|
110
|
+
this.log,
|
|
111
|
+
);
|
|
112
|
+
return res.ok ? (res.ts as string ?? null) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Update an existing message's text and blocks.
|
|
117
|
+
* Returns true on success.
|
|
118
|
+
*/
|
|
119
|
+
async updateMessage(
|
|
120
|
+
channelId: string,
|
|
121
|
+
ts: string,
|
|
122
|
+
text: string,
|
|
123
|
+
blocks: object[],
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
const res = await slackFetch(
|
|
126
|
+
this.token,
|
|
127
|
+
"chat.update",
|
|
128
|
+
{
|
|
129
|
+
channel: channelId,
|
|
130
|
+
ts,
|
|
131
|
+
text,
|
|
132
|
+
blocks,
|
|
133
|
+
},
|
|
134
|
+
this.log,
|
|
135
|
+
);
|
|
136
|
+
return res.ok;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// telegram-approval-buttons · lib/slack-formatter.ts
|
|
3
|
+
// Block Kit message formatting for Slack (approval requests & resolutions)
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import type { ApprovalAction, ApprovalInfo } from "../types.js";
|
|
7
|
+
|
|
8
|
+
// ─── Approval request format ────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format an approval request as Slack Block Kit blocks.
|
|
12
|
+
*/
|
|
13
|
+
export function formatSlackApprovalRequest(info: ApprovalInfo): object[] {
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
type: "header",
|
|
17
|
+
text: { type: "plain_text", text: "Exec Approval", emoji: true },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: "section",
|
|
21
|
+
text: { type: "mrkdwn", text: `\`\`\`${info.command}\`\`\`` },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: "context",
|
|
25
|
+
elements: [
|
|
26
|
+
{ type: "mrkdwn", text: `${info.agent} · \`${info.cwd}\` · ${info.expires}` },
|
|
27
|
+
{ type: "mrkdwn", text: `ID: \`${info.id}\`` },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
...buildSlackApprovalActions(info.id),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the actions block with approval buttons.
|
|
36
|
+
*/
|
|
37
|
+
function buildSlackApprovalActions(approvalId: string): object[] {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
type: "actions",
|
|
41
|
+
block_id: `approval_${approvalId}`,
|
|
42
|
+
elements: [
|
|
43
|
+
{
|
|
44
|
+
type: "button",
|
|
45
|
+
text: { type: "plain_text", text: "Allow Once", emoji: true },
|
|
46
|
+
style: "primary",
|
|
47
|
+
action_id: "approval_allow_once",
|
|
48
|
+
value: `/approve ${approvalId} allow-once`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: "button",
|
|
52
|
+
text: { type: "plain_text", text: "Always Allow", emoji: true },
|
|
53
|
+
action_id: "approval_allow_always",
|
|
54
|
+
value: `/approve ${approvalId} allow-always`,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "button",
|
|
58
|
+
text: { type: "plain_text", text: "Deny", emoji: true },
|
|
59
|
+
style: "danger",
|
|
60
|
+
action_id: "approval_deny",
|
|
61
|
+
value: `/approve ${approvalId} deny`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Resolved approval format ───────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const ACTION_ICONS: Record<ApprovalAction, string> = {
|
|
71
|
+
"allow-once": ":white_check_mark:",
|
|
72
|
+
"allow-always": ":lock:",
|
|
73
|
+
deny: ":x:",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const ACTION_LABELS: Record<ApprovalAction, string> = {
|
|
77
|
+
"allow-once": "Allowed (once)",
|
|
78
|
+
"allow-always": "Always allowed",
|
|
79
|
+
deny: "Denied",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format a resolved approval as Slack Block Kit blocks (no buttons).
|
|
84
|
+
*/
|
|
85
|
+
export function formatSlackApprovalResolved(
|
|
86
|
+
info: ApprovalInfo,
|
|
87
|
+
action: ApprovalAction,
|
|
88
|
+
): object[] {
|
|
89
|
+
const icon = ACTION_ICONS[action] ?? ":white_check_mark:";
|
|
90
|
+
const label = ACTION_LABELS[action] ?? action;
|
|
91
|
+
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
type: "header",
|
|
95
|
+
text: { type: "plain_text", text: label, emoji: true },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: "section",
|
|
99
|
+
text: { type: "mrkdwn", text: `\`\`\`${info.command}\`\`\`` },
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: "context",
|
|
103
|
+
elements: [{ type: "mrkdwn", text: `${icon} ${info.agent} · ID: \`${info.id}\`` }],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Stale approval format ──────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Format a stale/expired approval as Slack Block Kit blocks (no buttons).
|
|
112
|
+
*/
|
|
113
|
+
export function formatSlackApprovalExpired(info: ApprovalInfo): object[] {
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
type: "header",
|
|
117
|
+
text: { type: "plain_text", text: "Expired", emoji: true },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "section",
|
|
121
|
+
text: { type: "mrkdwn", text: `\`\`\`${info.command}\`\`\`` },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: "context",
|
|
125
|
+
elements: [{ type: "mrkdwn", text: `:clock1: ${info.agent} · ID: \`${info.id}\`` }],
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Fallback text ──────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Plain-text fallback for Slack notifications (shown in push notifications).
|
|
134
|
+
*/
|
|
135
|
+
export function slackFallbackText(info: ApprovalInfo): string {
|
|
136
|
+
return `Exec Approval Request — ${info.command} (${info.agent}@${info.host})`;
|
|
137
|
+
}
|
package/lib/telegram-api.ts
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "telegram-approval-buttons",
|
|
3
3
|
"name": "Telegram Approval Buttons",
|
|
4
|
-
"description": "Adds inline
|
|
5
|
-
"version": "
|
|
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",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
"type": "string",
|
|
16
16
|
"description": "Telegram bot token. Falls back to channels.telegram.token or TELEGRAM_BOT_TOKEN env."
|
|
17
17
|
},
|
|
18
|
+
"slackBotToken": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Slack bot OAuth token. Falls back to channels.slack.token or SLACK_BOT_TOKEN env."
|
|
21
|
+
},
|
|
22
|
+
"slackChannelId": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Slack channel or DM ID for approval buttons. Falls back to channels.slack.allowFrom[0] or SLACK_CHANNEL_ID env."
|
|
25
|
+
},
|
|
18
26
|
"staleMins": {
|
|
19
27
|
"type": "number",
|
|
20
28
|
"description": "Minutes before a pending approval is considered stale and cleaned up.",
|
|
@@ -31,14 +39,26 @@
|
|
|
31
39
|
"chatId": {
|
|
32
40
|
"label": "Telegram Chat ID",
|
|
33
41
|
"placeholder": "123456789",
|
|
34
|
-
"help": "Auto-detected from channels.telegram.allowFrom if not set."
|
|
42
|
+
"help": "Auto-detected from channels.telegram.allowFrom if not set.",
|
|
43
|
+
"advanced": false
|
|
35
44
|
},
|
|
36
45
|
"botToken": {
|
|
37
|
-
"label": "Bot Token",
|
|
46
|
+
"label": "Telegram Bot Token",
|
|
38
47
|
"sensitive": true,
|
|
39
48
|
"placeholder": "Auto-detected from channels.telegram.token",
|
|
40
49
|
"help": "Only needed if different from the main Telegram channel token."
|
|
41
50
|
},
|
|
51
|
+
"slackBotToken": {
|
|
52
|
+
"label": "Slack Bot Token",
|
|
53
|
+
"sensitive": true,
|
|
54
|
+
"placeholder": "Auto-detected from channels.slack.token",
|
|
55
|
+
"help": "Only needed if different from the main Slack channel token."
|
|
56
|
+
},
|
|
57
|
+
"slackChannelId": {
|
|
58
|
+
"label": "Slack Channel/DM ID",
|
|
59
|
+
"placeholder": "U0123456789 or D0123456789",
|
|
60
|
+
"help": "Your Slack user ID for DM buttons, or a channel ID. Auto-detected from channels.slack.allowFrom if not set."
|
|
61
|
+
},
|
|
42
62
|
"staleMins": {
|
|
43
63
|
"label": "Stale Timeout (min)",
|
|
44
64
|
"placeholder": "10",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "telegram-approval-buttons",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Inline
|
|
3
|
+
"version": "5.0.1",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"openclaw",
|
|
17
17
|
"openclaw-plugin",
|
|
18
18
|
"telegram",
|
|
19
|
+
"slack",
|
|
19
20
|
"approval",
|
|
20
21
|
"exec",
|
|
21
22
|
"inline-keyboard",
|
package/types.ts
CHANGED
|
@@ -25,12 +25,21 @@ export interface ApprovalInfo {
|
|
|
25
25
|
expires: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Which channel the approval was sent on.
|
|
30
|
+
*/
|
|
31
|
+
export type ApprovalChannel = "telegram" | "slack";
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
34
|
* A tracked approval that was sent with inline buttons.
|
|
30
35
|
*/
|
|
31
36
|
export interface SentApproval {
|
|
32
|
-
/**
|
|
37
|
+
/** Which channel sent this approval */
|
|
38
|
+
channel: ApprovalChannel;
|
|
39
|
+
/** Telegram message_id (set when channel is "telegram") */
|
|
33
40
|
messageId: number;
|
|
41
|
+
/** Slack message timestamp (set when channel is "slack") */
|
|
42
|
+
slackTs: string;
|
|
34
43
|
/** Parsed approval details */
|
|
35
44
|
info: ApprovalInfo;
|
|
36
45
|
/** Unix timestamp (ms) when the message was sent */
|
|
@@ -60,6 +69,10 @@ export interface PluginConfig {
|
|
|
60
69
|
chatId?: string;
|
|
61
70
|
/** Telegram bot token (optional — falls back to channels.telegram.token) */
|
|
62
71
|
botToken?: string;
|
|
72
|
+
/** Slack bot OAuth token (optional — falls back to channels.slack.token) */
|
|
73
|
+
slackBotToken?: string;
|
|
74
|
+
/** Slack channel/DM ID to send approval buttons to (optional — falls back to channels.slack config) */
|
|
75
|
+
slackChannelId?: string;
|
|
63
76
|
/** Stale approval timeout in minutes (default: 10) */
|
|
64
77
|
staleMins?: number;
|
|
65
78
|
/** Enable verbose diagnostic logging (default: false) */
|
|
@@ -67,11 +80,27 @@ export interface PluginConfig {
|
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
/**
|
|
70
|
-
* Resolved
|
|
83
|
+
* Resolved Telegram configuration.
|
|
71
84
|
*/
|
|
72
|
-
export interface
|
|
85
|
+
export interface ResolvedTelegramConfig {
|
|
73
86
|
chatId: string;
|
|
74
87
|
botToken: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolved Slack configuration.
|
|
92
|
+
*/
|
|
93
|
+
export interface ResolvedSlackConfig {
|
|
94
|
+
channelId: string;
|
|
95
|
+
botToken: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolved (validated) configuration with all defaults applied.
|
|
100
|
+
*/
|
|
101
|
+
export interface ResolvedConfig {
|
|
102
|
+
telegram: ResolvedTelegramConfig | null;
|
|
103
|
+
slack: ResolvedSlackConfig | null;
|
|
75
104
|
staleMins: number;
|
|
76
105
|
verbose: boolean;
|
|
77
106
|
}
|
|
@@ -81,8 +110,9 @@ export interface ResolvedConfig {
|
|
|
81
110
|
*/
|
|
82
111
|
export interface HealthCheck {
|
|
83
112
|
ok: boolean;
|
|
84
|
-
config: {
|
|
113
|
+
config: { telegramChatId: boolean; telegramToken: boolean; slackToken: boolean; slackChannel: boolean };
|
|
85
114
|
telegram: { reachable: boolean; botUsername?: string; error?: string };
|
|
115
|
+
slack: { reachable: boolean; teamName?: string; error?: string };
|
|
86
116
|
store: { pending: number; totalProcessed: number };
|
|
87
117
|
uptime: number;
|
|
88
118
|
}
|