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 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
- # 🔐 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
 
@@ -101,8 +102,10 @@ 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
  ```
@@ -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", // 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
  }
@@ -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 (isolated)
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 (v4.1.0)
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 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,7 +37,7 @@ import {
30
37
 
31
38
  // ── Constants ───────────────────────────────────────────────────────────────
32
39
 
33
- const PLUGIN_VERSION = "4.1.0";
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 — 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,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
- // 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 ──────────────────────────────────────────────────────────
@@ -187,7 +268,7 @@ export default {
187
268
  id: "telegram-approval-buttons",
188
269
  name: "Telegram 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,
@@ -3,7 +3,7 @@
3
3
  // In-memory store for pending approvals with TTL-based cleanup
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
6
- import type { ApprovalAction, ApprovalInfo, Logger, SentApproval } from "../types.js";
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(approvalId: string, messageId: number, info: ApprovalInfo): void {
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
- messageId,
67
+ channel,
68
+ messageId: ref.messageId ?? 0,
69
+ slackTs: ref.slackTs ?? "",
63
70
  info,
64
71
  sentAt: Date.now(),
65
72
  });
@@ -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
- env: { TELEGRAM_BOT_TOKEN?: string; TELEGRAM_CHAT_ID?: string };
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 Telegram config)
28
+ * 2. channels.telegram / channels.slack (shared channel config)
22
29
  * 3. Environment variables (fallback)
23
30
  *
24
- * Returns null with diagnostic messages if critical config is missing.
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
- // ── Bot token resolution ────────────────────────────────────────────
33
- const botToken =
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
- if (!botToken) {
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
- // ── Chat ID resolution ──────────────────────────────────────────────
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
- chatId = candidate;
53
+ tgChatId = candidate;
55
54
  log.info(
56
- `[diagnostics] Auto-resolved chatId from channels.telegram.allowFrom: ${chatId}`,
55
+ `[diagnostics] Auto-resolved Telegram chatId from channels.telegram.allowFrom: ${tgChatId}`,
57
56
  );
58
57
  }
59
58
  }
60
59
 
61
- if (!chatId) {
62
- log.warn(
63
- "[diagnostics] No chatId found. Set pluginConfig.chatId, " +
64
- "TELEGRAM_CHAT_ID env, or channels.telegram.allowFrom",
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
- // ── Missing critical config ─────────────────────────────────────────
69
- if (!botToken || !chatId) {
70
- log.error("[diagnostics] Plugin disabled due to missing configuration");
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 { chatId, botToken, staleMins, verbose };
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 validation + Telegram connectivity + store stats.
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
- chatId: !!config?.chatId,
100
- botToken: !!config?.botToken,
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
- // Config check
111
- if (!config || !tg) {
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
- // Telegram connectivity check
117
- const me = await tg.getMe();
118
- if (me.ok) {
119
- health.telegram.reachable = true;
120
- health.telegram.botUsername = me.username;
121
- health.ok = true;
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.telegram.error = me.error;
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 maskedToken = config.botToken.slice(0, 6) + "…" + config.botToken.slice(-4);
139
- const maskedChatId = config.chatId.slice(0, 3) + "…" + config.chatId.slice(-2);
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 → chatId=${maskedChatId}, ` +
142
- `token=${maskedToken}, staleMins=${config.staleMins}, ` +
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
- const me = await tg.getMe();
156
- if (me.ok) {
157
- log.info(`[diagnostics] Telegram connected → @${me.username}`);
158
- } else {
159
- log.warn(
160
- `[diagnostics] Telegram unreachable: ${me.error}. ` +
161
- "Plugin will still attempt to send messages.",
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
  }
@@ -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 Request</b>`,
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
- `🛡️ Security: ${e(info.security)}`,
35
- `❓ Ask: ${e(info.ask)}`,
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>Exec ${label}</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
- `🆔 <code>${e(info.id)}</code>`,
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>Exec Approval Expired</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
- `🆔 <code>${e(info.id)}</code>`,
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 in Telegram.
112
+ * Format a health check result for display.
125
113
  */
126
114
  export function formatHealthCheck(health: {
127
115
  ok: boolean;
128
- config: { chatId: boolean; botToken: boolean };
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
- if (health.telegram.reachable) {
141
- lines.push(`Telegram: connected (@${health.telegram.botUsername ?? "?"})`);
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: ${health.telegram.error ?? "unreachable"}`);
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
  );
@@ -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
+ }
@@ -61,7 +61,7 @@ export class TelegramApi {
61
61
  constructor(
62
62
  private readonly token: string,
63
63
  private readonly log?: Logger,
64
- ) {}
64
+ ) { }
65
65
 
66
66
  // ── Connectivity ────────────────────────────────────────────────────────
67
67
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "telegram-approval-buttons",
3
3
  "name": "Telegram Approval Buttons",
4
- "description": "Adds inline keyboard buttons to exec approval messages in Telegram. Tap to approve/deny without typing commands.",
5
- "version": "4.1.0",
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.1.0",
4
- "description": "Inline keyboard buttons for exec approval messages in Telegram — tap to approve/deny without typing commands",
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
- /** Telegram message_id of our formatted message */
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 (validated) configuration with all defaults applied.
83
+ * Resolved Telegram configuration.
71
84
  */
72
- export interface ResolvedConfig {
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: { chatId: boolean; botToken: boolean };
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
  }