telegram-approval-buttons 4.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 ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [4.0.1] - 2026-02-15
9
+
10
+ ### Added
11
+ - Composite banner image showing the full approval workflow
12
+ - `tsconfig.json` for IDE TypeScript support
13
+ - `uiHints` in manifest for better config UI labels
14
+
15
+ ### Changed
16
+ - Rewritten README with simplified 3-step Quick Start for beginners
17
+ - FAQ section with 5 common questions
18
+ - Cleaner troubleshooting table with less jargon
19
+
20
+ ### Fixed
21
+ - `/approvalstatus` command showing raw HTML `<b>` tags instead of plain text
22
+ - Plugin ID mismatch warning on gateway startup (aligned `package.json` name with manifest `id`)
23
+
24
+ ## [4.0.0] - 2026-02-14
25
+
26
+ ### Added
27
+ - Initial public release
28
+ - One-tap inline keyboard buttons: โœ… Allow Once ยท ๐Ÿ” Always ยท โŒ Deny
29
+ - Auto-resolve: edits message after decision, removes buttons
30
+ - Expiry handling: stale approvals auto-cleaned after configurable timeout
31
+ - `/approvalstatus` diagnostic command with health check
32
+ - Auto-detection of `chatId` and `botToken` from `channels.telegram` config
33
+ - Modular architecture: `index.ts` orchestration + 5 lib modules
34
+ - Full `configSchema` with JSON Schema validation
35
+ - MIT license
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jair Franco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # ๐Ÿ” Telegram Approval Buttons for OpenClaw
2
+
3
+ > One-tap `exec` approvals in Telegram โ€” no more typing `/approve <uuid> allow-once`.
4
+
5
+ ## What does this look like?
6
+
7
+ <p align="center">
8
+ <img src="docs/banner.png" alt="Plugin workflow: approval request โ†’ allowed โ†’ health check" />
9
+ </p>
10
+
11
+ ## What does this do?
12
+
13
+ OpenClaw's Discord has built-in approval buttons. **Telegram doesn't** โ€” you're stuck typing long `/approve` commands. This plugin fixes that.
14
+
15
+ **Features:**
16
+ - โœ… **One-tap approvals** โ€” Allow Once ยท ๐Ÿ” Always ยท โŒ Deny
17
+ - ๐Ÿ”„ **Auto-resolve** โ€” edits the message after decision (removes buttons, shows result)
18
+ - โฐ **Expiry handling** โ€” stale approvals auto-cleaned and marked as expired
19
+ - ๐Ÿฉบ **Self-diagnostics** โ€” `/approvalstatus` checks health and stats
20
+ - ๐Ÿ›ก๏ธ **Graceful fallback** โ€” if buttons fail, the original text goes through
21
+ - ๐Ÿ“ฆ **Zero dependencies** โ€” uses only Node.js built-in `fetch`
22
+
23
+ ## Quick Start
24
+
25
+ ### Step 1: Download the plugin
26
+
27
+ ```bash
28
+ git clone https://github.com/JairFC/openclaw-telegram-approval-buttons.git
29
+ ```
30
+
31
+ ### Step 2: Add to your config
32
+
33
+ Open your `openclaw.json` and add this block. If the `"plugins"` section already exists, just merge the contents:
34
+
35
+ ```jsonc
36
+ {
37
+ "plugins": {
38
+ "load": {
39
+ "paths": ["/path/to/openclaw-telegram-approval-buttons"]
40
+ },
41
+ "entries": {
42
+ "telegram-approval-buttons": {
43
+ "enabled": true
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ > ๐Ÿ’ก **Replace `/path/to/`** with the actual path where you cloned the repo.
51
+ > Example: `"/home/jair/Projects/openclaw-telegram-approval-buttons"`
52
+
53
+ ### Step 3: Restart and verify
54
+
55
+ ```bash
56
+ openclaw gateway restart
57
+ ```
58
+
59
+ Then send `/approvalstatus` in your Telegram chat. You should see:
60
+
61
+ ```
62
+ ๐ŸŸข Approval Buttons Status
63
+
64
+ Config: chatId=โœ“ ยท token=โœ“
65
+ Telegram: โœ“ connected (@your_bot)
66
+ Pending: 0 ยท Processed: 0
67
+ Uptime: 1m
68
+ ```
69
+
70
+ **That's it!** Next time the AI triggers an `exec` approval, you'll get buttons.
71
+
72
+ ## Prerequisites
73
+
74
+ - **OpenClaw โ‰ฅ 2026.2.9** installed and running
75
+ - **Node.js โ‰ฅ 22** (uses built-in `fetch`)
76
+ - **Telegram configured** in your `openclaw.json`
77
+ - **Exec approvals enabled** โ€” `tools.exec.ask` must NOT be `"off"`
78
+
79
+ ## How it works
80
+
81
+ ```
82
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” message_sending โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
83
+ โ”‚ OpenClaw โ”‚ โ”€โ”€ approval text โ”€โ”€โ†’ โ”‚ Plugin โ”‚
84
+ โ”‚ Gateway โ”‚ โ”‚ โ”‚
85
+ โ”‚ โ”‚ cancel original โ”‚ 1. Parse text โ”‚
86
+ โ”‚ โ”‚ โ†โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ 2. Send buttons โ”‚
87
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ 3. Track pending โ”‚
88
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
89
+ โ”‚
90
+ Telegram Bot API
91
+ โ”‚
92
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
93
+ โ”‚ Telegram Chat โ”‚
94
+ โ”‚ โ”‚
95
+ โ”‚ ๐Ÿ” Exec Approval โ”‚
96
+ โ”‚ [โœ… Allow] [๐Ÿ”] โ”‚
97
+ โ”‚ [โŒ Deny] โ”‚
98
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
99
+ ```
100
+
101
+ When you tap a button, OpenClaw converts the `callback_data` into a synthetic text message โ€” **no webhook needed**.
102
+
103
+ ## Configuration
104
+
105
+ The plugin **auto-detects** `botToken` and `chatId` from your Telegram channel config. Most setups need zero extra configuration.
106
+
107
+ ### Config resolution order
108
+
109
+ | Setting | Priority 1 (explicit) | Priority 2 (shared config) | Priority 3 (env) |
110
+ |------------|-----------------------------|------------------------------------|---------------------------|
111
+ | `botToken` | `pluginConfig.botToken` | `channels.telegram.token` | `TELEGRAM_BOT_TOKEN` |
112
+ | `chatId` | `pluginConfig.chatId` | `channels.telegram.allowFrom[0]` | `TELEGRAM_CHAT_ID` |
113
+
114
+ ### Advanced options
115
+
116
+ ```jsonc
117
+ {
118
+ "plugins": {
119
+ "entries": {
120
+ "telegram-approval-buttons": {
121
+ "enabled": true,
122
+ "config": {
123
+ "chatId": "123456789", // Override auto-detected chat ID
124
+ "botToken": "123:ABC...", // Override auto-detected bot token
125
+ "staleMins": 10, // Minutes before stale cleanup (default: 10)
126
+ "verbose": false // Diagnostic logging (default: false)
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ ## FAQ
135
+
136
+ **Q: I installed the plugin but no buttons appear.**
137
+ A: Make sure `tools.exec.ask` is NOT set to `"off"` in your config. If it's `"off"`, there are no approvals to buttonize. Set it to `"on-miss"` or `"always"`.
138
+
139
+ **Q: How do I find my Telegram Chat ID?**
140
+ A: Send `/start` to [@userinfobot](https://t.me/userinfobot) on Telegram โ€” it replies with your ID. Alternatively, check `https://api.telegram.org/bot<TOKEN>/getUpdates` after sending a message to your bot.
141
+
142
+ **Q: Do I need to set up a webhook?**
143
+ A: No! OpenClaw's Telegram integration automatically converts button taps into synthetic text messages. No extra setup needed.
144
+
145
+ **Q: What happens if the plugin fails to send buttons?**
146
+ A: The original plain-text approval message goes through normally. The plugin never blocks approvals.
147
+
148
+ **Q: Does this work in group chats?**
149
+ A: Yes, but the bot needs to be an admin or it needs permission to edit its own messages.
150
+
151
+ ## Troubleshooting
152
+
153
+ | Problem | Fix |
154
+ |---------|-----|
155
+ | No buttons appear | Check `tools.exec.ask` is not `"off"`. Run `/approvalstatus` to check config. |
156
+ | Buttons show but nothing happens | Bot needs message editing permission. Use a private chat or make bot admin. |
157
+ | `/approvalstatus` says "token=โœ—" | Set `botToken` in plugin config, or check `channels.telegram.token`. |
158
+ | `/approvalstatus` says "chatId=โœ—" | Set `chatId` in plugin config, or add your ID to `channels.telegram.allowFrom`. |
159
+ | Buttons say "expired" | Approval timed out before you tapped. Adjust `staleMins` if needed. |
160
+
161
+ ## Architecture
162
+
163
+ ```
164
+ telegram-approval-buttons/
165
+ โ”œโ”€โ”€ index.ts # Entry point โ€” orchestration only
166
+ โ”œโ”€โ”€ types.ts # Shared TypeScript interfaces
167
+ โ”œโ”€โ”€ lib/
168
+ โ”‚ โ”œโ”€โ”€ telegram-api.ts # Telegram Bot API client (isolated)
169
+ โ”‚ โ”œโ”€โ”€ approval-parser.ts # Parse OpenClaw approval text format
170
+ โ”‚ โ”œโ”€โ”€ message-formatter.ts # HTML formatting for Telegram messages
171
+ โ”‚ โ”œโ”€โ”€ approval-store.ts # In-memory pending approval tracker
172
+ โ”‚ โ””โ”€โ”€ diagnostics.ts # Config resolution, health checks
173
+ โ”œโ”€โ”€ openclaw.plugin.json # Plugin manifest
174
+ โ””โ”€โ”€ package.json
175
+ ```
176
+
177
+ ## Contributing
178
+
179
+ Issues and PRs welcome. Each file in `lib/` is self-contained with a single responsibility.
180
+
181
+ ## License
182
+
183
+ [MIT](LICENSE)
package/index.ts ADDED
@@ -0,0 +1,195 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท index.ts (v4.0.0)
3
+ // Plugin entry point โ€” orchestration only, all logic lives in lib/
4
+ //
5
+ // Adds inline keyboard buttons to exec approval messages in Telegram.
6
+ // When a user taps a button, OpenClaw processes the /approve command
7
+ // automatically via its callback_query โ†’ synthetic text message pipeline.
8
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
9
+
10
+ import type { PluginConfig } from "./types.js";
11
+
12
+ // โ”€โ”€ Modules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
13
+
14
+ import { TelegramApi } from "./lib/telegram-api.js";
15
+ import { ApprovalStore } from "./lib/approval-store.js";
16
+ import { parseApprovalText, detectApprovalResult } from "./lib/approval-parser.js";
17
+ import {
18
+ formatApprovalRequest,
19
+ formatApprovalResolved,
20
+ formatApprovalExpired,
21
+ buildApprovalKeyboard,
22
+ formatHealthCheck,
23
+ } from "./lib/message-formatter.js";
24
+ import {
25
+ resolveConfig,
26
+ runHealthCheck,
27
+ logStartupDiagnostics,
28
+ runStartupChecks,
29
+ } from "./lib/diagnostics.js";
30
+
31
+ // โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
32
+
33
+ const PLUGIN_VERSION = "4.0.1";
34
+ const TAG = "telegram-approval-buttons";
35
+
36
+ // โ”€โ”€ Plugin registration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
37
+
38
+ function register(api: any): void {
39
+ const log = api.logger;
40
+ const startedAt = Date.now();
41
+
42
+ // โ”€โ”€โ”€ 1. Resolve config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
43
+
44
+ const pluginCfg: PluginConfig = api.pluginConfig ?? {};
45
+ const telegramCfg = api.config?.channels?.telegram ?? {};
46
+
47
+ const config = resolveConfig(
48
+ {
49
+ pluginConfig: pluginCfg,
50
+ telegramChannelConfig: {
51
+ token: telegramCfg.token,
52
+ allowFrom: telegramCfg.allowFrom,
53
+ },
54
+ env: {
55
+ TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN,
56
+ TELEGRAM_CHAT_ID: process.env.TELEGRAM_CHAT_ID,
57
+ },
58
+ },
59
+ log,
60
+ );
61
+
62
+ if (!config) {
63
+ log.warn(`[${TAG}] v${PLUGIN_VERSION} loaded (DISABLED โ€” missing config)`);
64
+ return;
65
+ }
66
+
67
+ logStartupDiagnostics(config, log);
68
+
69
+ // โ”€โ”€โ”€ 2. Initialize modules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
70
+
71
+ const tg = new TelegramApi(config.botToken, config.verbose ? log : undefined);
72
+
73
+ const store = new ApprovalStore(
74
+ config.staleMins * 60_000,
75
+ config.verbose ? log : undefined,
76
+ // onExpired: edit the Telegram message to show "expired"
77
+ (entry) => {
78
+ tg.editMessageText(
79
+ config.chatId,
80
+ entry.messageId,
81
+ formatApprovalExpired(entry.info),
82
+ ).catch(() => { });
83
+ },
84
+ );
85
+
86
+ // โ”€โ”€โ”€ 3. Register background service (cleanup timer) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87
+
88
+ api.registerService({
89
+ id: `${TAG}-cleanup`,
90
+ start: () => {
91
+ store.start();
92
+ // Non-blocking connectivity check
93
+ runStartupChecks(tg, log).catch(() => { });
94
+ },
95
+ stop: () => store.stop(),
96
+ });
97
+
98
+ // โ”€โ”€โ”€ 4. Register /approvalstatus command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
99
+
100
+ api.registerCommand({
101
+ name: "approvalstatus",
102
+ description: "Show approval buttons plugin health and stats",
103
+ acceptsArgs: false,
104
+ requireAuth: true,
105
+ handler: async () => {
106
+ const health = await runHealthCheck(config, tg, store, startedAt);
107
+ return { text: formatHealthCheck(health) };
108
+ },
109
+ });
110
+
111
+ // โ”€โ”€โ”€ 5. Register message_sending hook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
112
+
113
+ api.on(
114
+ "message_sending",
115
+ async (
116
+ event: { to: string; content: string; metadata?: Record<string, unknown> },
117
+ ctx: { channelId: string; accountId?: string },
118
+ ) => {
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;
141
+ }
142
+
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 };
153
+ }
154
+
155
+ log.info(`[${TAG}] intercepting ${info.id.slice(0, 8)}โ€ฆ`);
156
+
157
+ // Send formatted message with inline buttons
158
+ const messageId = await tg.sendMessage(
159
+ config.chatId,
160
+ formatApprovalRequest(info),
161
+ buildApprovalKeyboard(info.id),
162
+ );
163
+
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
+ }
169
+
170
+ // Track it
171
+ store.add(info.id, messageId, info);
172
+ log.info(`[${TAG}] sent buttons for ${info.id.slice(0, 8)}โ€ฆ (msg=${messageId})`);
173
+
174
+ // Cancel the original plain-text message
175
+ return { cancel: true };
176
+ },
177
+ );
178
+
179
+ // โ”€โ”€โ”€ Done โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
180
+
181
+ log.info(`[${TAG}] v${PLUGIN_VERSION} loaded โœ“`);
182
+ }
183
+
184
+ // โ”€โ”€โ”€ Plugin export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
185
+
186
+ export default {
187
+ id: "telegram-approval-buttons",
188
+ name: "Telegram Approval Buttons",
189
+ description:
190
+ "Adds inline keyboard buttons to exec approval messages in Telegram. " +
191
+ "Tap to approve/deny without typing commands.",
192
+ version: PLUGIN_VERSION,
193
+ kind: "extension" as const,
194
+ register,
195
+ };
@@ -0,0 +1,84 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท lib/approval-parser.ts
3
+ // Parse OpenClaw's plain-text exec approval format into structured data
4
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+
6
+ import type { ApprovalAction, ApprovalInfo, ApprovalResolution, SentApproval } from "../types.js";
7
+
8
+ // โ”€โ”€โ”€ Regex patterns (compiled once) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
9
+
10
+ const RE_APPROVAL_MARKER = /Exec approval required/i;
11
+ const RE_ID = /ID:\s*([a-f0-9-]+)/i;
12
+ const RE_COMMAND_BLOCK = /Command:\s*`{0,3}\n?(.+?)\n?`{0,3}(?:\n|$)/is;
13
+ const RE_COMMAND_INLINE = /Command:\s*(.+)/i;
14
+ const RE_CWD = /CWD:\s*(.+)/i;
15
+ const RE_HOST = /Host:\s*(.+)/i;
16
+ const RE_AGENT = /Agent:\s*(.+)/i;
17
+ const RE_SECURITY = /Security:\s*(.+)/i;
18
+ const RE_ASK = /Ask:\s*(.+)/i;
19
+ const RE_EXPIRES = /Expires in:\s*(.+)/i;
20
+
21
+ /**
22
+ * Parse OpenClaw's plain-text approval message into an ApprovalInfo object.
23
+ *
24
+ * Returns null if the text doesn't match the approval format.
25
+ * This function is intentionally lenient โ€” it extracts what it can and
26
+ * falls back to sensible defaults for missing fields.
27
+ */
28
+ export function parseApprovalText(text: string): ApprovalInfo | null {
29
+ if (!RE_APPROVAL_MARKER.test(text)) return null;
30
+
31
+ const id = text.match(RE_ID)?.[1]?.trim();
32
+ if (!id) return null;
33
+
34
+ // Try block format first (```command```), then inline
35
+ let command = text.match(RE_COMMAND_BLOCK)?.[1]?.trim();
36
+ if (!command) command = text.match(RE_COMMAND_INLINE)?.[1]?.trim() ?? "unknown";
37
+
38
+ return {
39
+ id,
40
+ command,
41
+ cwd: text.match(RE_CWD)?.[1]?.trim() ?? "unknown",
42
+ host: text.match(RE_HOST)?.[1]?.trim() ?? "gateway",
43
+ agent: text.match(RE_AGENT)?.[1]?.trim() ?? "main",
44
+ security: text.match(RE_SECURITY)?.[1]?.trim() ?? "allowlist",
45
+ ask: text.match(RE_ASK)?.[1]?.trim() ?? "on-miss",
46
+ expires: text.match(RE_EXPIRES)?.[1]?.trim() ?? "120s",
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Detect if an outgoing message indicates an approval was resolved.
52
+ *
53
+ * Checks whether the message text references any pending approval ID
54
+ * (full UUID or first 8 chars) and attempts to determine the action.
55
+ */
56
+ export function detectApprovalResult(
57
+ text: string,
58
+ pending: ReadonlyMap<string, SentApproval>,
59
+ ): ApprovalResolution | null {
60
+ for (const [id] of pending) {
61
+ const shortId = id.slice(0, 8);
62
+ if (!text.includes(id) && !text.includes(shortId)) continue;
63
+
64
+ const action = inferAction(text);
65
+ return { id, action };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // โ”€โ”€โ”€ Internal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
71
+
72
+ /**
73
+ * Infer the approval action from message text.
74
+ * Order matters: check most specific patterns first.
75
+ */
76
+ function inferAction(text: string): ApprovalAction {
77
+ const lower = text.toLowerCase();
78
+ if (lower.includes("allow-always") || lower.includes("always allow")) return "allow-always";
79
+ if (lower.includes("deny") || lower.includes("denied") || lower.includes("rejected")) return "deny";
80
+ if (lower.includes("allow-once") || lower.includes("allowed")) return "allow-once";
81
+ if (lower.includes("approved")) return "allow-once";
82
+ // Default when we see the ID but can't determine action
83
+ return "allow-once";
84
+ }
@@ -0,0 +1,143 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท lib/approval-store.ts
3
+ // In-memory store for pending approvals with TTL-based cleanup
4
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+
6
+ import type { ApprovalAction, ApprovalInfo, Logger, SentApproval } from "../types.js";
7
+
8
+ /**
9
+ * Manages the lifecycle of pending approval requests.
10
+ *
11
+ * Responsibilities:
12
+ * - Track sent approval messages (approval ID โ†’ SentApproval)
13
+ * - Auto-purge stale entries after configurable TTL
14
+ * - Provide stats for diagnostics
15
+ *
16
+ * All state is in-memory โ€” approvals are ephemeral by nature
17
+ * and don't survive gateway restarts (by design).
18
+ */
19
+ export class ApprovalStore {
20
+ private readonly pending = new Map<string, SentApproval>();
21
+ private totalProcessed = 0;
22
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
23
+
24
+ constructor(
25
+ private readonly staleTtlMs: number,
26
+ private readonly log?: Logger,
27
+ private readonly onExpired?: (entry: SentApproval) => void,
28
+ ) { }
29
+
30
+ // โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+
32
+ /**
33
+ * Start periodic stale-entry cleanup (runs every staleTtlMs / 2).
34
+ */
35
+ start(): void {
36
+ if (this.cleanupTimer) return;
37
+ const interval = Math.max(this.staleTtlMs / 2, 30_000);
38
+ this.cleanupTimer = setInterval(() => this.cleanStale(), interval);
39
+ // Prevent the timer from keeping the process alive
40
+ if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
41
+ (this.cleanupTimer as NodeJS.Timeout).unref();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Stop the cleanup timer.
47
+ */
48
+ stop(): void {
49
+ if (this.cleanupTimer) {
50
+ clearInterval(this.cleanupTimer);
51
+ this.cleanupTimer = null;
52
+ }
53
+ }
54
+
55
+ // โ”€โ”€ Core operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56
+
57
+ /**
58
+ * Track a newly sent approval message.
59
+ */
60
+ add(approvalId: string, messageId: number, info: ApprovalInfo): void {
61
+ this.pending.set(approvalId, {
62
+ messageId,
63
+ info,
64
+ sentAt: Date.now(),
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Check if an approval is already being tracked.
70
+ */
71
+ has(approvalId: string): boolean {
72
+ return this.pending.has(approvalId);
73
+ }
74
+
75
+ /**
76
+ * Get a pending approval entry.
77
+ */
78
+ get(approvalId: string): SentApproval | undefined {
79
+ return this.pending.get(approvalId);
80
+ }
81
+
82
+ /**
83
+ * Resolve (remove) a pending approval and increment the processed counter.
84
+ * Returns the entry if it existed.
85
+ */
86
+ resolve(approvalId: string): SentApproval | undefined {
87
+ const entry = this.pending.get(approvalId);
88
+ if (entry) {
89
+ this.pending.delete(approvalId);
90
+ this.totalProcessed++;
91
+ }
92
+ return entry;
93
+ }
94
+
95
+ /**
96
+ * Get a read-only view of all pending approvals.
97
+ */
98
+ entries(): ReadonlyMap<string, SentApproval> {
99
+ return this.pending;
100
+ }
101
+
102
+ // โ”€โ”€ Stats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
103
+
104
+ get pendingCount(): number {
105
+ return this.pending.size;
106
+ }
107
+
108
+ get processedCount(): number {
109
+ return this.totalProcessed;
110
+ }
111
+
112
+ // โ”€โ”€ Cleanup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
113
+
114
+ /**
115
+ * Remove entries older than staleTtlMs.
116
+ * Calls onExpired callback for each removed entry (e.g., to edit the Telegram message).
117
+ */
118
+ cleanStale(): number {
119
+ const now = Date.now();
120
+ let removed = 0;
121
+
122
+ for (const [id, entry] of this.pending) {
123
+ if (now - entry.sentAt > this.staleTtlMs) {
124
+ this.pending.delete(id);
125
+ removed++;
126
+ this.log?.debug?.(
127
+ `[approval-store] purged stale: ${id.slice(0, 8)}โ€ฆ (age=${Math.floor((now - entry.sentAt) / 1000)}s)`,
128
+ );
129
+ try {
130
+ this.onExpired?.(entry);
131
+ } catch {
132
+ // Non-critical โ€” just log and continue
133
+ }
134
+ }
135
+ }
136
+
137
+ if (removed > 0) {
138
+ this.log?.info(`[approval-store] cleaned ${removed} stale entries`);
139
+ }
140
+
141
+ return removed;
142
+ }
143
+ }
@@ -0,0 +1,164 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท lib/diagnostics.ts
3
+ // Self-diagnostics: config validation, connectivity check, auto-repair
4
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+
6
+ import type { HealthCheck, Logger, PluginConfig, ResolvedConfig } from "../types.js";
7
+ import type { TelegramApi } from "./telegram-api.js";
8
+ import type { ApprovalStore } from "./approval-store.js";
9
+
10
+ // โ”€โ”€โ”€ Config resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
11
+
12
+ export interface ConfigSources {
13
+ pluginConfig: PluginConfig;
14
+ telegramChannelConfig: { token?: string; allowFrom?: (string | number)[] };
15
+ env: { TELEGRAM_BOT_TOKEN?: string; TELEGRAM_CHAT_ID?: string };
16
+ }
17
+
18
+ /**
19
+ * Resolve plugin configuration from multiple sources with priority:
20
+ * 1. pluginConfig (explicit config in openclaw.json)
21
+ * 2. channels.telegram (shared Telegram config)
22
+ * 3. Environment variables (fallback)
23
+ *
24
+ * Returns null with diagnostic messages if critical config is missing.
25
+ */
26
+ export function resolveConfig(
27
+ sources: ConfigSources,
28
+ log: Logger,
29
+ ): ResolvedConfig | null {
30
+ const { pluginConfig, telegramChannelConfig, env } = sources;
31
+
32
+ // โ”€โ”€ Bot token resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+ const botToken =
34
+ pluginConfig.botToken ||
35
+ telegramChannelConfig.token ||
36
+ env.TELEGRAM_BOT_TOKEN ||
37
+ "";
38
+
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
+ }
45
+
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)) {
51
+ const first = telegramChannelConfig.allowFrom[0];
52
+ const candidate = String(first ?? "");
53
+ if (/^-?\d+$/.test(candidate)) {
54
+ chatId = candidate;
55
+ log.info(
56
+ `[diagnostics] Auto-resolved chatId from channels.telegram.allowFrom: ${chatId}`,
57
+ );
58
+ }
59
+ }
60
+
61
+ if (!chatId) {
62
+ log.warn(
63
+ "[diagnostics] No chatId found. Set pluginConfig.chatId, " +
64
+ "TELEGRAM_CHAT_ID env, or channels.telegram.allowFrom",
65
+ );
66
+ }
67
+
68
+ // โ”€โ”€ Missing critical config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
69
+ if (!botToken || !chatId) {
70
+ log.error("[diagnostics] Plugin disabled due to missing configuration");
71
+ return null;
72
+ }
73
+
74
+ // โ”€โ”€ Optional config with defaults โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
75
+ const staleMins =
76
+ typeof pluginConfig.staleMins === "number" && pluginConfig.staleMins > 0
77
+ ? pluginConfig.staleMins
78
+ : 10;
79
+
80
+ const verbose = pluginConfig.verbose === true;
81
+
82
+ return { chatId, botToken, staleMins, verbose };
83
+ }
84
+
85
+ // โ”€โ”€โ”€ Health check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
86
+
87
+ /**
88
+ * Run a full health check: config validation + Telegram connectivity + store stats.
89
+ */
90
+ export async function runHealthCheck(
91
+ config: ResolvedConfig | null,
92
+ tg: TelegramApi | null,
93
+ store: ApprovalStore,
94
+ startedAt: number,
95
+ ): Promise<HealthCheck> {
96
+ const health: HealthCheck = {
97
+ ok: false,
98
+ config: {
99
+ chatId: !!config?.chatId,
100
+ botToken: !!config?.botToken,
101
+ },
102
+ telegram: { reachable: false },
103
+ store: {
104
+ pending: store.pendingCount,
105
+ totalProcessed: store.processedCount,
106
+ },
107
+ uptime: Date.now() - startedAt,
108
+ };
109
+
110
+ // Config check
111
+ if (!config || !tg) {
112
+ health.telegram.error = "not configured";
113
+ return health;
114
+ }
115
+
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;
122
+ } else {
123
+ health.telegram.error = me.error;
124
+ }
125
+
126
+ return health;
127
+ }
128
+
129
+ // โ”€โ”€โ”€ Startup diagnostics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
130
+
131
+ /**
132
+ * Log startup diagnostic summary.
133
+ */
134
+ export function logStartupDiagnostics(
135
+ config: ResolvedConfig,
136
+ log: Logger,
137
+ ): 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);
140
+ log.info(
141
+ `[diagnostics] Config OK โ†’ chatId=${maskedChatId}, ` +
142
+ `token=${maskedToken}, staleMins=${config.staleMins}, ` +
143
+ `verbose=${config.verbose}`,
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Run async startup checks (non-blocking).
149
+ * Verifies Telegram API connectivity and logs the result.
150
+ */
151
+ export async function runStartupChecks(
152
+ tg: TelegramApi,
153
+ log: Logger,
154
+ ): 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
+ );
163
+ }
164
+ }
@@ -0,0 +1,152 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท lib/message-formatter.ts
3
+ // HTML message formatting for Telegram (approval requests & resolutions)
4
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+
6
+ import type { ApprovalAction, ApprovalInfo } from "../types.js";
7
+
8
+ // โ”€โ”€โ”€ HTML escaping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
9
+
10
+ /** Escape text for Telegram HTML parse mode. */
11
+ export function escapeHtml(text: string): string {
12
+ return text
13
+ .replace(/&/g, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;");
16
+ }
17
+
18
+ // โ”€โ”€โ”€ Approval request format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
+
20
+ /**
21
+ * Format an approval request as a rich HTML message for Telegram.
22
+ */
23
+ export function formatApprovalRequest(info: ApprovalInfo): string {
24
+ const e = escapeHtml;
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>`,
31
+ ``,
32
+ `<pre>${e(info.command)}</pre>`,
33
+ ``,
34
+ `๐Ÿ›ก๏ธ Security: ${e(info.security)}`,
35
+ `โ“ Ask: ${e(info.ask)}`,
36
+ `โฑ๏ธ Expires: ${e(info.expires)}`,
37
+ `๐Ÿ†” <code>${e(info.id)}</code>`,
38
+ ].join("\n");
39
+ }
40
+
41
+ // โ”€โ”€โ”€ Resolved approval format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
+
43
+ const ACTION_ICONS: Record<ApprovalAction, string> = {
44
+ "allow-once": "โœ…",
45
+ "allow-always": "๐Ÿ”",
46
+ deny: "โŒ",
47
+ };
48
+
49
+ const ACTION_LABELS: Record<ApprovalAction, string> = {
50
+ "allow-once": "Allowed (once)",
51
+ "allow-always": "Always allowed",
52
+ deny: "Denied",
53
+ };
54
+
55
+ /**
56
+ * Format a resolved approval (post-decision) as an HTML message.
57
+ * Buttons are removed and the header shows the resolution.
58
+ */
59
+ export function formatApprovalResolved(
60
+ info: ApprovalInfo,
61
+ action: ApprovalAction,
62
+ ): string {
63
+ const e = escapeHtml;
64
+ const icon = ACTION_ICONS[action] ?? "โœ…";
65
+ const label = ACTION_LABELS[action] ?? action;
66
+
67
+ 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>`,
73
+ ``,
74
+ `<pre>${e(info.command)}</pre>`,
75
+ ``,
76
+ `๐Ÿ†” <code>${e(info.id)}</code>`,
77
+ ].join("\n");
78
+ }
79
+
80
+ // โ”€โ”€โ”€ Inline keyboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
81
+
82
+ /**
83
+ * Build the inline keyboard markup for an approval request.
84
+ *
85
+ * Each button uses `/approve <id> <action>` as callback_data.
86
+ * OpenClaw's Telegram integration converts unknown callback_data
87
+ * into synthetic text messages, so these are processed as commands
88
+ * automatically โ€” no webhook needed.
89
+ */
90
+ export function buildApprovalKeyboard(approvalId: string): object {
91
+ return {
92
+ inline_keyboard: [
93
+ [
94
+ { text: "โœ… Allow Once", callback_data: `/approve ${approvalId} allow-once` },
95
+ { text: "๐Ÿ” Always", callback_data: `/approve ${approvalId} allow-always` },
96
+ ],
97
+ [{ text: "โŒ Deny", callback_data: `/approve ${approvalId} deny` }],
98
+ ],
99
+ };
100
+ }
101
+
102
+ // โ”€โ”€โ”€ Stale approval format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
103
+
104
+ /**
105
+ * Format a stale/expired approval message.
106
+ */
107
+ export function formatApprovalExpired(info: ApprovalInfo): string {
108
+ const e = escapeHtml;
109
+ return [
110
+ `โฐ <b>Exec Approval Expired</b>`,
111
+ ``,
112
+ `๐Ÿค– Agent: <b>${e(info.agent)}</b>`,
113
+ `๐Ÿ–ฅ๏ธ Host: <b>${e(info.host)}</b>`,
114
+ ``,
115
+ `<pre>${e(info.command)}</pre>`,
116
+ ``,
117
+ `๐Ÿ†” <code>${e(info.id)}</code>`,
118
+ ].join("\n");
119
+ }
120
+
121
+ // โ”€โ”€โ”€ Health / diagnostics format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
122
+
123
+ /**
124
+ * Format a health check result for display in Telegram.
125
+ */
126
+ export function formatHealthCheck(health: {
127
+ ok: boolean;
128
+ config: { chatId: boolean; botToken: boolean };
129
+ telegram: { reachable: boolean; botUsername?: string; error?: string };
130
+ store: { pending: number; totalProcessed: number };
131
+ uptime: number;
132
+ }): string {
133
+ const uptimeMin = Math.floor(health.uptime / 60_000);
134
+ const lines = [
135
+ `${health.ok ? "๐ŸŸข" : "๐Ÿ”ด"} Approval Buttons Status`,
136
+ ``,
137
+ `Config: chatId=${health.config.chatId ? "โœ“" : "โœ—"} ยท token=${health.config.botToken ? "โœ“" : "โœ—"}`,
138
+ ];
139
+
140
+ if (health.telegram.reachable) {
141
+ lines.push(`Telegram: โœ“ connected (@${health.telegram.botUsername ?? "?"})`);
142
+ } else {
143
+ lines.push(`Telegram: โœ— ${health.telegram.error ?? "unreachable"}`);
144
+ }
145
+
146
+ lines.push(
147
+ `Pending: ${health.store.pending} ยท Processed: ${health.store.totalProcessed}`,
148
+ `Uptime: ${uptimeMin}m`,
149
+ );
150
+
151
+ return lines.join("\n");
152
+ }
@@ -0,0 +1,165 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท lib/telegram-api.ts
3
+ // Isolated Telegram Bot API wrapper โ€” only depends on fetch (Node built-in)
4
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+
6
+ import type { Logger } from "../types.js";
7
+
8
+ const API_BASE = "https://api.telegram.org/bot";
9
+ const REQUEST_TIMEOUT_MS = 10_000;
10
+
11
+ // โ”€โ”€โ”€ Internal helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
12
+
13
+ interface TgResponse<T = unknown> {
14
+ ok: boolean;
15
+ result?: T;
16
+ description?: string;
17
+ error_code?: number;
18
+ }
19
+
20
+ async function tgFetch<T = unknown>(
21
+ token: string,
22
+ method: string,
23
+ body: Record<string, unknown>,
24
+ log?: Logger,
25
+ ): Promise<TgResponse<T>> {
26
+ const url = `${API_BASE}${token}/${method}`;
27
+ try {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
30
+
31
+ const res = await fetch(url, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify(body),
35
+ signal: controller.signal,
36
+ });
37
+
38
+ clearTimeout(timer);
39
+
40
+ const data = (await res.json()) as TgResponse<T>;
41
+ if (!data.ok && log) {
42
+ log.warn(
43
+ `[telegram-api] ${method} failed: ${data.error_code} ${data.description}`,
44
+ );
45
+ }
46
+ return data;
47
+ } catch (err: unknown) {
48
+ const msg = err instanceof Error ? err.message : String(err);
49
+ log?.error(`[telegram-api] ${method} network error: ${msg}`);
50
+ return { ok: false, description: msg };
51
+ }
52
+ }
53
+
54
+ // โ”€โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
55
+
56
+ /**
57
+ * Telegram Bot API client.
58
+ * Instantiate with a bot token; all methods are self-contained.
59
+ */
60
+ export class TelegramApi {
61
+ constructor(
62
+ private readonly token: string,
63
+ private readonly log?: Logger,
64
+ ) {}
65
+
66
+ // โ”€โ”€ Connectivity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
+
68
+ /**
69
+ * Call getMe to verify the bot token and get bot info.
70
+ * Useful for diagnostics.
71
+ */
72
+ async getMe(): Promise<{ ok: true; username: string } | { ok: false; error: string }> {
73
+ const res = await tgFetch<{ username: string }>(
74
+ this.token,
75
+ "getMe",
76
+ {},
77
+ this.log,
78
+ );
79
+ if (res.ok && res.result?.username) {
80
+ return { ok: true, username: res.result.username };
81
+ }
82
+ return { ok: false, error: res.description ?? "unknown error" };
83
+ }
84
+
85
+ // โ”€โ”€ Messaging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
86
+
87
+ /**
88
+ * Send an HTML-formatted message, optionally with inline keyboard.
89
+ * Returns the message_id on success, null on failure.
90
+ */
91
+ async sendMessage(
92
+ chatId: string,
93
+ text: string,
94
+ replyMarkup?: object,
95
+ ): Promise<number | null> {
96
+ const body: Record<string, unknown> = {
97
+ chat_id: chatId,
98
+ text,
99
+ parse_mode: "HTML",
100
+ };
101
+ if (replyMarkup) body.reply_markup = replyMarkup;
102
+
103
+ const res = await tgFetch<{ message_id: number }>(
104
+ this.token,
105
+ "sendMessage",
106
+ body,
107
+ this.log,
108
+ );
109
+ return res.ok ? (res.result?.message_id ?? null) : null;
110
+ }
111
+
112
+ /**
113
+ * Edit an existing message's text and remove inline keyboard.
114
+ * Returns true on success.
115
+ */
116
+ async editMessageText(
117
+ chatId: string,
118
+ messageId: number,
119
+ text: string,
120
+ replyMarkup?: object,
121
+ ): Promise<boolean> {
122
+ const body: Record<string, unknown> = {
123
+ chat_id: chatId,
124
+ message_id: messageId,
125
+ text,
126
+ parse_mode: "HTML",
127
+ };
128
+ // If replyMarkup is explicitly provided, include it; otherwise omit
129
+ // (omitting reply_markup removes all buttons)
130
+ if (replyMarkup) body.reply_markup = replyMarkup;
131
+
132
+ const res = await tgFetch(this.token, "editMessageText", body, this.log);
133
+ return res.ok;
134
+ }
135
+
136
+ /**
137
+ * Answer a callback query (acknowledges button press in Telegram UI).
138
+ * Optional text shows as a toast notification to the user.
139
+ */
140
+ async answerCallbackQuery(
141
+ callbackQueryId: string,
142
+ text?: string,
143
+ ): Promise<boolean> {
144
+ const body: Record<string, unknown> = {
145
+ callback_query_id: callbackQueryId,
146
+ };
147
+ if (text) body.text = text;
148
+
149
+ const res = await tgFetch(this.token, "answerCallbackQuery", body, this.log);
150
+ return res.ok;
151
+ }
152
+
153
+ /**
154
+ * Delete a message from a chat.
155
+ */
156
+ async deleteMessage(chatId: string, messageId: number): Promise<boolean> {
157
+ const res = await tgFetch(
158
+ this.token,
159
+ "deleteMessage",
160
+ { chat_id: chatId, message_id: messageId },
161
+ this.log,
162
+ );
163
+ return res.ok;
164
+ }
165
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "telegram-approval-buttons",
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.0.1",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "chatId": {
11
+ "type": "string",
12
+ "description": "Telegram chat ID where approval buttons are sent. Falls back to channels.telegram.allowFrom[0]."
13
+ },
14
+ "botToken": {
15
+ "type": "string",
16
+ "description": "Telegram bot token. Falls back to channels.telegram.token or TELEGRAM_BOT_TOKEN env."
17
+ },
18
+ "staleMins": {
19
+ "type": "number",
20
+ "description": "Minutes before a pending approval is considered stale and cleaned up.",
21
+ "default": 10
22
+ },
23
+ "verbose": {
24
+ "type": "boolean",
25
+ "description": "Enable verbose diagnostic logging.",
26
+ "default": false
27
+ }
28
+ }
29
+ },
30
+ "uiHints": {
31
+ "chatId": {
32
+ "label": "Telegram Chat ID",
33
+ "placeholder": "123456789",
34
+ "help": "Auto-detected from channels.telegram.allowFrom if not set."
35
+ },
36
+ "botToken": {
37
+ "label": "Bot Token",
38
+ "sensitive": true,
39
+ "placeholder": "Auto-detected from channels.telegram.token",
40
+ "help": "Only needed if different from the main Telegram channel token."
41
+ },
42
+ "staleMins": {
43
+ "label": "Stale Timeout (min)",
44
+ "placeholder": "10",
45
+ "advanced": true
46
+ },
47
+ "verbose": {
48
+ "label": "Verbose Logging",
49
+ "advanced": true
50
+ }
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "telegram-approval-buttons",
3
+ "version": "4.0.1",
4
+ "description": "Inline keyboard buttons for exec approval messages in Telegram โ€” tap to approve/deny without typing commands",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "scripts": {
9
+ "test": "vitest run",
10
+ "test:watch": "vitest"
11
+ },
12
+ "keywords": [
13
+ "openclaw",
14
+ "openclaw-plugin",
15
+ "telegram",
16
+ "approval",
17
+ "exec",
18
+ "inline-keyboard",
19
+ "buttons"
20
+ ],
21
+ "openclaw": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ },
26
+ "author": "Jair Franco (https://github.com/JairFC)",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/JairFC/openclaw-telegram-approval-buttons"
30
+ },
31
+ "files": [
32
+ "index.ts",
33
+ "types.ts",
34
+ "lib/",
35
+ "openclaw.plugin.json",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "devDependencies": {
40
+ "@types/node": "^25.2.3",
41
+ "typescript": "^5.7.0",
42
+ "vitest": "^3.0.0"
43
+ }
44
+ }
package/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2
+ // telegram-approval-buttons ยท types.ts
3
+ // Shared TypeScript interfaces for the plugin
4
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+
6
+ /**
7
+ * Parsed representation of an OpenClaw exec approval request.
8
+ */
9
+ export interface ApprovalInfo {
10
+ /** UUID of the approval request */
11
+ id: string;
12
+ /** Shell command requiring approval */
13
+ command: string;
14
+ /** Working directory where the command would execute */
15
+ cwd: string;
16
+ /** Host machine name */
17
+ host: string;
18
+ /** Agent that requested the command */
19
+ agent: string;
20
+ /** Security policy that triggered the approval */
21
+ security: string;
22
+ /** Ask mode (e.g., "on-miss", "always") */
23
+ ask: string;
24
+ /** Time until approval expires (e.g., "120s") */
25
+ expires: string;
26
+ }
27
+
28
+ /**
29
+ * A tracked approval that was sent with inline buttons.
30
+ */
31
+ export interface SentApproval {
32
+ /** Telegram message_id of our formatted message */
33
+ messageId: number;
34
+ /** Parsed approval details */
35
+ info: ApprovalInfo;
36
+ /** Unix timestamp (ms) when the message was sent */
37
+ sentAt: number;
38
+ }
39
+
40
+ /**
41
+ * Resolution of an approval (allow-once, allow-always, deny).
42
+ */
43
+ export type ApprovalAction = "allow-once" | "allow-always" | "deny";
44
+
45
+ /**
46
+ * Result of detecting an approval resolution in a message.
47
+ */
48
+ export interface ApprovalResolution {
49
+ /** Approval UUID */
50
+ id: string;
51
+ /** Action that was taken */
52
+ action: ApprovalAction;
53
+ }
54
+
55
+ /**
56
+ * Plugin configuration (from openclaw.json โ†’ plugins.entries.<id>.config).
57
+ */
58
+ export interface PluginConfig {
59
+ /** Telegram chat ID to send approval buttons to */
60
+ chatId?: string;
61
+ /** Telegram bot token (optional โ€” falls back to channels.telegram.token) */
62
+ botToken?: string;
63
+ /** Stale approval timeout in minutes (default: 10) */
64
+ staleMins?: number;
65
+ /** Enable verbose diagnostic logging (default: false) */
66
+ verbose?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Resolved (validated) configuration with all defaults applied.
71
+ */
72
+ export interface ResolvedConfig {
73
+ chatId: string;
74
+ botToken: string;
75
+ staleMins: number;
76
+ verbose: boolean;
77
+ }
78
+
79
+ /**
80
+ * Diagnostic health check result.
81
+ */
82
+ export interface HealthCheck {
83
+ ok: boolean;
84
+ config: { chatId: boolean; botToken: boolean };
85
+ telegram: { reachable: boolean; botUsername?: string; error?: string };
86
+ store: { pending: number; totalProcessed: number };
87
+ uptime: number;
88
+ }
89
+
90
+ /**
91
+ * Minimal logger interface matching OpenClaw's plugin logger.
92
+ */
93
+ export interface Logger {
94
+ debug?: (message: string) => void;
95
+ info: (message: string) => void;
96
+ warn: (message: string) => void;
97
+ error: (message: string) => void;
98
+ }