polygram 0.8.0-rc.19 → 0.8.0-rc.20

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.19",
4
+ "version": "0.8.0-rc.20",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure UI builders for the approval flow's Telegram surface.
3
+ *
4
+ * - 2-button keyboard (CLI pm IPC approval-hook flow)
5
+ * - 4-button keyboard (rc.6 SDK pm canUseTool flow with persisted
6
+ * "Always allow / Always deny" via chat_tool_decisions)
7
+ * - Card text with friendly heading + clipped tool_input body
8
+ *
9
+ * No runtime dependencies — these are pure transforms suitable for
10
+ * unit-testing in isolation. The polygram.js side wires them to
11
+ * `tg(bot, 'sendMessage', ...)` / `editMessageText`.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ /**
17
+ * 2-button keyboard for the legacy IPC approval flow.
18
+ * @param {number|string} approvalId
19
+ * @param {string} token
20
+ */
21
+ function buildApprovalKeyboard(approvalId, token) {
22
+ return {
23
+ inline_keyboard: [[
24
+ { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
25
+ { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
26
+ ]],
27
+ };
28
+ }
29
+
30
+ /**
31
+ * 4-button keyboard for the SDK canUseTool flow (rc.6 Phase 2 step 6).
32
+ * "Always allow" / "Always deny" rows persist the decision into
33
+ * `chat_tool_decisions` so subsequent invocations of the same tool
34
+ * with the same input short-circuit.
35
+ *
36
+ * Callback_data conventions:
37
+ * approve:<id>:<token> — one-time allow
38
+ * deny:<id>:<token> — one-time deny
39
+ * approve-always:<id>:<token> — allow + persist
40
+ * deny-always:<id>:<token> — deny + persist
41
+ *
42
+ * @param {number|string} approvalId
43
+ * @param {string} token
44
+ */
45
+ function buildApprovalKeyboardWithAlways(approvalId, token) {
46
+ return {
47
+ inline_keyboard: [
48
+ [
49
+ { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
50
+ { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
51
+ ],
52
+ [
53
+ { text: '🔁 Always allow', callback_data: `approve-always:${approvalId}:${token}` },
54
+ { text: '🚫 Always deny', callback_data: `deny-always:${approvalId}:${token}` },
55
+ ],
56
+ ],
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Format a tool_input value for the inline-keyboard card body.
62
+ * Clips aggressively so the whole card stays under Telegram's
63
+ * 4096-char limit (approval card has surrounding metadata too).
64
+ *
65
+ * @param {unknown} input — string OR any JSON-able object
66
+ * @returns {string}
67
+ */
68
+ function formatToolInputForCard(input) {
69
+ let s;
70
+ try {
71
+ s = typeof input === 'string' ? input : JSON.stringify(input, null, 2);
72
+ } catch {
73
+ s = String(input);
74
+ }
75
+ // JSON.stringify(undefined) returns undefined, and objects with
76
+ // a circular toJSON could surface odd values too. Fall back to
77
+ // String() so we always operate on a real string.
78
+ if (typeof s !== 'string') s = String(input);
79
+ if (s.length <= 1200) return s;
80
+ return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
81
+ }
82
+
83
+ /**
84
+ * Approval card text. Plain-text only (NO parse_mode) — tool_input
85
+ * originates from Claude and could contain Markdown specials or
86
+ * tg:// links crafted for phishing.
87
+ *
88
+ * @param {object} row — approval row from the approvals store
89
+ * @param {string} row.tool_name
90
+ * @param {number|string|null} row.turn_id
91
+ * @param {string} row.requester_chat_id
92
+ * @param {object|string|null} [row.tool_input_json]
93
+ * @param {object|string|null} [row.tool_input] — alias for tool_input_json
94
+ * @param {number} row.timeout_ts — unix ms when the row expires
95
+ * @param {object} [opts]
96
+ * @param {string} [opts.resolvedBy] — heading override for resolved cards
97
+ * (e.g. "✓ Approved by ivan").
98
+ * When set, footer is dropped.
99
+ * When unset, heading is "Approval needed — <tool>"
100
+ * and footer shows seconds-to-expire.
101
+ * @param {() => number} [opts.now] — clock injection for tests
102
+ *
103
+ * @returns {string}
104
+ */
105
+ function approvalCardText(row, opts = {}) {
106
+ const now = (typeof opts.now === 'function' ? opts.now : Date.now)();
107
+ const heading = opts.resolvedBy
108
+ ? opts.resolvedBy
109
+ : `Approval needed — ${row.tool_name}`;
110
+ // tool_input may arrive as a parsed object OR a JSON string under
111
+ // either key name depending on the call site.
112
+ const inputSource = row.tool_input_json !== undefined
113
+ ? row.tool_input_json
114
+ : row.tool_input;
115
+ const parsed = typeof inputSource === 'string'
116
+ ? safeParse(inputSource)
117
+ : inputSource;
118
+ const body = formatToolInputForCard(parsed);
119
+ const ttl = Math.max(0, Math.round((row.timeout_ts - now) / 1000));
120
+ const footer = opts.resolvedBy ? '' : `\n\n⏱ expires in ${ttl}s`;
121
+ return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
122
+ }
123
+
124
+ function safeParse(s) {
125
+ try { return JSON.parse(s); } catch { return s; }
126
+ }
127
+
128
+ module.exports = {
129
+ buildApprovalKeyboard,
130
+ buildApprovalKeyboardWithAlways,
131
+ formatToolInputForCard,
132
+ approvalCardText,
133
+ // Internals exposed for tests
134
+ _safeParse: safeParse,
135
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.19",
3
+ "version": "0.8.0-rc.20",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -34,6 +34,12 @@ const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
34
34
  const { createAutosteerBuffer, makePostToolBatchHook } = require('./lib/autosteer-buffer');
35
35
  const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
36
36
  const { canonicalizeToolInput } = require('./lib/canonical-json');
37
+ const {
38
+ buildApprovalKeyboard,
39
+ buildApprovalKeyboardWithAlways,
40
+ formatToolInputForCard,
41
+ approvalCardText,
42
+ } = require('./lib/approval-ui');
37
43
  const agentLoader = require('./lib/agent-loader');
38
44
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
39
45
  const { createSender } = require('./lib/telegram');
@@ -1232,51 +1238,9 @@ async function handleSendOverIpc(req) {
1232
1238
  }
1233
1239
 
1234
1240
  // ─── Approvals ─────────────────────────────────────────────────────
1235
-
1236
- // Format a tool_input for the inline keyboard card. Clip aggressively so
1237
- // the card doesn't exceed Telegram's 4096-char limit.
1238
- function formatToolInputForCard(input) {
1239
- let s;
1240
- try { s = typeof input === 'string' ? input : JSON.stringify(input, null, 2); }
1241
- catch { s = String(input); }
1242
- if (s.length <= 1200) return s;
1243
- return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
1244
- }
1245
-
1246
- function buildApprovalKeyboard(approvalId, token) {
1247
- return {
1248
- inline_keyboard: [[
1249
- { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
1250
- { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
1251
- ]],
1252
- };
1253
- }
1254
-
1255
- // 0.8.0 Phase 2 step 6: 4-button approval keyboard for SDK canUseTool
1256
- // flow. Adds "Always allow" and "Always deny" rows that persist the
1257
- // decision into chat_tool_decisions (via callback_query handler),
1258
- // so subsequent invocations of the same tool with the same input
1259
- // short-circuit without prompting.
1260
- //
1261
- // Callback_data conventions:
1262
- // approve:<id>:<token> — one-time allow
1263
- // deny:<id>:<token> — one-time deny
1264
- // approve-always:<id>:<token> — allow + persist
1265
- // deny-always:<id>:<token> — deny + persist
1266
- function buildApprovalKeyboardWithAlways(approvalId, token) {
1267
- return {
1268
- inline_keyboard: [
1269
- [
1270
- { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
1271
- { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
1272
- ],
1273
- [
1274
- { text: '🔁 Always allow', callback_data: `approve-always:${approvalId}:${token}` },
1275
- { text: '🚫 Always deny', callback_data: `deny-always:${approvalId}:${token}` },
1276
- ],
1277
- ],
1278
- };
1279
- }
1241
+ // rc.20: pure UI builders moved to lib/approval-ui.js for testability.
1242
+ // Imported above (buildApprovalKeyboard, buildApprovalKeyboardWithAlways,
1243
+ // approvalCardText, formatToolInputForCard).
1280
1244
 
1281
1245
  // /model and /effort inline keyboard. `show` controls which row(s) appear:
1282
1246
  // 'model', 'effort', or 'all'. The current value gets a ✓ marker so the
@@ -1345,28 +1309,7 @@ function formatConfigInfoText(chatConfig, show, sessionKey) {
1345
1309
  return body;
1346
1310
  }
1347
1311
 
1348
- function approvalCardText(row, opts = {}) {
1349
- // No parse_mode is used on this card — tool_name/turn_id/tool_input
1350
- // originate from the Claude subprocess and could contain Markdown special
1351
- // chars or tg:// links crafted for phishing. Plain text renders as-is.
1352
- const heading = opts.resolvedBy
1353
- ? opts.resolvedBy
1354
- : `Approval needed — ${row.tool_name}`;
1355
- const body = formatToolInputForCard(
1356
- typeof row.tool_input_json === 'string'
1357
- ? safeParse(row.tool_input_json)
1358
- : row.tool_input_json,
1359
- );
1360
- const ttl = Math.max(0, Math.round((row.timeout_ts - Date.now()) / 1000));
1361
- const footer = opts.resolvedBy
1362
- ? ''
1363
- : `\n\n⏱ expires in ${ttl}s`;
1364
- return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
1365
- }
1366
-
1367
- function safeParse(s) {
1368
- try { return JSON.parse(s); } catch { return s; }
1369
- }
1312
+ // rc.20: approvalCardText + safeParse moved to lib/approval-ui.js.
1370
1313
 
1371
1314
  // 0.8.0-rc.18+: canonicalizeToolInput moved to lib/canonical-json.js
1372
1315
  // for testability. Same function, no behavior change.