polygram 0.4.13 → 0.5.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/.claude-plugin/plugin.json +1 -1
- package/lib/telegram-format.js +262 -23
- package/lib/telegram.js +2 -2
- package/package.json +3 -2
- package/polygram.js +117 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.1",
|
|
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",
|
package/lib/telegram-format.js
CHANGED
|
@@ -1,36 +1,275 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Markdown → Telegram HTML.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Replaces the 0.4.x telegramify-markdown / MarkdownV2 pipeline with HTML
|
|
5
|
+
* output. Reasons for the switch:
|
|
6
|
+
* - Native syntax highlighting via `<pre><code class="language-X">`
|
|
7
|
+
* - `<blockquote expandable>` for collapsible long output
|
|
8
|
+
* - Only 3 chars need escaping (`<`, `>`, `&`) vs MarkdownV2's 18
|
|
9
|
+
* - Cleaner nesting (HTML tag pairs vs MarkdownV2's brittle ordering)
|
|
10
|
+
* - Tables become `<pre>` blocks with column alignment, no regex hacks
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - Fallback-on-throw is centralised (if conversion explodes, we send
|
|
18
|
-
* the original text with no parse_mode — worse formatting, but the
|
|
19
|
-
* message still arrives).
|
|
12
|
+
* Implementation: `marked` parses CommonMark into tokens, our custom
|
|
13
|
+
* renderer emits Telegram-compatible HTML for each token type. The
|
|
14
|
+
* `wrapFileReferencesInHtml` post-processor (adapted from OpenClaw,
|
|
15
|
+
* MIT) wraps `README.md`-style references in `<code>` so Telegram
|
|
16
|
+
* doesn't auto-linkify them as bogus domains.
|
|
20
17
|
*/
|
|
21
18
|
|
|
22
|
-
const
|
|
19
|
+
const { Marked } = require('marked');
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// File extensions that are ALSO TLDs and commonly appear in chat (where
|
|
22
|
+
// auto-linking them as `http://README.md` would generate spam preview
|
|
23
|
+
// cards). Adapted from openclaw/openclaw (MIT) src/shared/text/
|
|
24
|
+
// auto-linked-file-ref.ts. Excludes `.ai`, `.io`, `.tv`, `.fm` which
|
|
25
|
+
// are popular real-domain TLDs.
|
|
26
|
+
const FILE_REF_EXTENSIONS = ['md', 'go', 'py', 'pl', 'sh', 'am', 'at', 'be', 'cc'];
|
|
27
|
+
|
|
28
|
+
function escapeHtml(text) {
|
|
29
|
+
if (typeof text !== 'string') return '';
|
|
30
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function escapeHtmlAttr(text) {
|
|
34
|
+
return escapeHtml(text).replace(/"/g, '"');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Trigger expandable blockquote when content exceeds either threshold.
|
|
38
|
+
// `<blockquote expandable>` renders as a 3-line preview with "Show more"
|
|
39
|
+
// — useful for long quoted reasoning the user usually skips.
|
|
40
|
+
const EXPANDABLE_BLOCKQUOTE_CHARS = 240;
|
|
41
|
+
const EXPANDABLE_BLOCKQUOTE_LINES = 4;
|
|
42
|
+
|
|
43
|
+
function shouldExpandQuote(innerHtml) {
|
|
44
|
+
const plain = innerHtml.replace(/<[^>]+>/g, '');
|
|
45
|
+
return plain.length > EXPANDABLE_BLOCKQUOTE_CHARS
|
|
46
|
+
|| plain.split('\n').length > EXPANDABLE_BLOCKQUOTE_LINES;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Split a list item's tokens into the leading inline run and any
|
|
50
|
+
// block-level tokens that follow (nested list, code block, etc.). Without
|
|
51
|
+
// this split, `parser.parse(item.tokens)` glues inline text directly
|
|
52
|
+
// against the nested list's first marker — "top• nested 1" with no
|
|
53
|
+
// separator — because text() does not emit a trailing newline.
|
|
54
|
+
const BLOCK_TYPES = new Set(['list', 'blockquote', 'code', 'table', 'paragraph', 'space', 'html', 'hr']);
|
|
55
|
+
|
|
56
|
+
function splitItemTokens(tokens) {
|
|
57
|
+
const inline = [];
|
|
58
|
+
const blocks = [];
|
|
59
|
+
let crossedToBlock = false;
|
|
60
|
+
for (const tok of tokens) {
|
|
61
|
+
if (BLOCK_TYPES.has(tok.type)) {
|
|
62
|
+
crossedToBlock = true;
|
|
63
|
+
blocks.push(tok);
|
|
64
|
+
} else if (crossedToBlock) {
|
|
65
|
+
blocks.push({ type: 'paragraph', tokens: [tok] });
|
|
66
|
+
} else {
|
|
67
|
+
inline.push(tok);
|
|
68
|
+
}
|
|
27
69
|
}
|
|
70
|
+
return { inline, blocks };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildRenderer() {
|
|
74
|
+
// Different bullet glyphs per nesting depth: visual hierarchy without
|
|
75
|
+
// relying on Telegram preserving leading spaces (it does for HTML
|
|
76
|
+
// mode but the cue is clearer with distinct bullets).
|
|
77
|
+
const NESTED_BULLETS = ['•', '◦', '▪', '▫'];
|
|
78
|
+
let listDepth = 0;
|
|
79
|
+
function bulletFor(d) { return NESTED_BULLETS[Math.min(d, NESTED_BULLETS.length - 1)]; }
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
heading({ tokens }) {
|
|
83
|
+
return `<b>${this.parser.parseInline(tokens)}</b>\n\n`;
|
|
84
|
+
},
|
|
85
|
+
paragraph({ tokens }) {
|
|
86
|
+
return this.parser.parseInline(tokens) + '\n\n';
|
|
87
|
+
},
|
|
88
|
+
text({ tokens, text }) {
|
|
89
|
+
if (Array.isArray(tokens) && tokens.length) return this.parser.parseInline(tokens);
|
|
90
|
+
return escapeHtml(text);
|
|
91
|
+
},
|
|
92
|
+
strong({ tokens }) { return `<b>${this.parser.parseInline(tokens)}</b>`; },
|
|
93
|
+
em({ tokens }) { return `<i>${this.parser.parseInline(tokens)}</i>`; },
|
|
94
|
+
del({ tokens }) { return `<s>${this.parser.parseInline(tokens)}</s>`; },
|
|
95
|
+
codespan({ text }) { return `<code>${escapeHtml(text)}</code>`; },
|
|
96
|
+
code({ text, lang }) {
|
|
97
|
+
const langClass = lang ? ` class="language-${escapeHtmlAttr(lang)}"` : '';
|
|
98
|
+
return `<pre><code${langClass}>${escapeHtml(text)}</code></pre>\n\n`;
|
|
99
|
+
},
|
|
100
|
+
blockquote({ tokens }) {
|
|
101
|
+
const inner = this.parser.parse(tokens).trim();
|
|
102
|
+
const expandable = shouldExpandQuote(inner) ? ' expandable' : '';
|
|
103
|
+
return `<blockquote${expandable}>${inner}</blockquote>\n\n`;
|
|
104
|
+
},
|
|
105
|
+
link({ href, tokens }) {
|
|
106
|
+
return `<a href="${escapeHtmlAttr(href)}">${this.parser.parseInline(tokens)}</a>`;
|
|
107
|
+
},
|
|
108
|
+
image({ href, text }) {
|
|
109
|
+
return `<a href="${escapeHtmlAttr(href)}">${escapeHtml(text || href)}</a>`;
|
|
110
|
+
},
|
|
111
|
+
list({ items, ordered, start }) {
|
|
112
|
+
const depth = listDepth;
|
|
113
|
+
listDepth += 1;
|
|
114
|
+
const indent = ' '.repeat(depth);
|
|
115
|
+
const bullet = bulletFor(depth);
|
|
116
|
+
try {
|
|
117
|
+
const lines = items.map((item, i) => {
|
|
118
|
+
const marker = ordered ? `${(start || 1) + i}. ` : `${bullet} `;
|
|
119
|
+
const { inline, blocks } = splitItemTokens(item.tokens);
|
|
120
|
+
const leader = this.parser.parseInline(inline).trim();
|
|
121
|
+
let line = `${indent}${marker}${leader}`;
|
|
122
|
+
for (const block of blocks) {
|
|
123
|
+
const rendered = this.parser.parse([block]).replace(/\n+$/, '');
|
|
124
|
+
if (rendered) line += '\n' + rendered;
|
|
125
|
+
}
|
|
126
|
+
return line;
|
|
127
|
+
});
|
|
128
|
+
return lines.join('\n') + (depth === 0 ? '\n\n' : '');
|
|
129
|
+
} finally {
|
|
130
|
+
listDepth -= 1;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
listitem({ tokens }) { return this.parser.parse(tokens); },
|
|
134
|
+
hr() { return '\n──────\n\n'; },
|
|
135
|
+
br() { return '\n'; },
|
|
136
|
+
table({ header, rows, align }) {
|
|
137
|
+
const headerCells = header.map((cell) => this.parser.parseInline(cell.tokens));
|
|
138
|
+
const rowCells = rows.map((row) => row.map((cell) => this.parser.parseInline(cell.tokens)));
|
|
139
|
+
const stripTags = (s) => s.replace(/<[^>]+>/g, '');
|
|
140
|
+
const widths = headerCells.map((h, col) => {
|
|
141
|
+
let maxLen = stripTags(h).length;
|
|
142
|
+
for (const r of rowCells) {
|
|
143
|
+
const cellLen = stripTags(r[col] || '').length;
|
|
144
|
+
if (cellLen > maxLen) maxLen = cellLen;
|
|
145
|
+
}
|
|
146
|
+
return maxLen;
|
|
147
|
+
});
|
|
148
|
+
const padCell = (s, col) => {
|
|
149
|
+
const len = stripTags(s).length;
|
|
150
|
+
const padding = ' '.repeat(Math.max(0, widths[col] - len));
|
|
151
|
+
if (align[col] === 'right') return padding + s;
|
|
152
|
+
return s + padding;
|
|
153
|
+
};
|
|
154
|
+
const renderRow = (cells) => '| ' + cells.map((c, i) => padCell(c, i)).join(' | ') + ' |';
|
|
155
|
+
const sep = '|' + widths.map((w) => '-'.repeat(w + 2)).join('|') + '|';
|
|
156
|
+
const lines = [renderRow(headerCells), sep, ...rowCells.map(renderRow)];
|
|
157
|
+
return `<pre>${lines.join('\n')}</pre>\n\n`;
|
|
158
|
+
},
|
|
159
|
+
html({ text }) { return escapeHtml(text); },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Spoiler extension for marked. Recognises `||hidden||` inline. Telegram-
|
|
164
|
+
// specific (matches OpenClaw's enableSpoilers behaviour).
|
|
165
|
+
const spoilerExtension = {
|
|
166
|
+
name: 'spoiler',
|
|
167
|
+
level: 'inline',
|
|
168
|
+
start(src) { return src.indexOf('||'); },
|
|
169
|
+
tokenizer(src) {
|
|
170
|
+
const m = /^\|\|([\s\S]+?)\|\|/.exec(src);
|
|
171
|
+
if (!m) return undefined;
|
|
172
|
+
return {
|
|
173
|
+
type: 'spoiler',
|
|
174
|
+
raw: m[0],
|
|
175
|
+
text: m[1],
|
|
176
|
+
tokens: this.lexer.inlineTokens(m[1]),
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
renderer({ tokens }) {
|
|
180
|
+
return `<tg-spoiler>${this.parser.parseInline(tokens)}</tg-spoiler>`;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const HTML_TAG_RE = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
|
|
185
|
+
const AUTO_LINKED_ANCHOR_RE = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
|
186
|
+
|
|
187
|
+
function escapeRegex(str) {
|
|
188
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const _extPattern = FILE_REF_EXTENSIONS.map(escapeRegex).join('|');
|
|
192
|
+
const FILE_REF_PATTERN = new RegExp(
|
|
193
|
+
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${_extPattern}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
|
194
|
+
'gi',
|
|
195
|
+
);
|
|
196
|
+
const ORPHANED_TLD_PATTERN = new RegExp(
|
|
197
|
+
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${_extPattern}))(?=[^a-zA-Z0-9/]|$)`,
|
|
198
|
+
'g',
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
function isAutoLinkedFileRef(label) {
|
|
202
|
+
const dotIdx = label.lastIndexOf('.');
|
|
203
|
+
if (dotIdx < 1) return false;
|
|
204
|
+
const ext = label.slice(dotIdx + 1).toLowerCase();
|
|
205
|
+
if (!FILE_REF_EXTENSIONS.includes(ext)) return false;
|
|
206
|
+
const segments = label.split('/');
|
|
207
|
+
if (segments.length > 1) {
|
|
208
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
209
|
+
if (segments[i].includes('.')) return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function wrapStandaloneFileRef(_match, prefix, filename) {
|
|
216
|
+
if (filename.startsWith('//')) return _match;
|
|
217
|
+
if (/https?:\/\/$/i.test(prefix)) return _match;
|
|
218
|
+
return `${prefix}<code>${escapeHtml(filename)}</code>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function wrapSegment(text, codeDepth, preDepth, anchorDepth) {
|
|
222
|
+
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) return text;
|
|
223
|
+
const w = text.replace(FILE_REF_PATTERN, wrapStandaloneFileRef);
|
|
224
|
+
return w.replace(ORPHANED_TLD_PATTERN, (m, prefix, tld) => {
|
|
225
|
+
if (prefix === '>') return m;
|
|
226
|
+
return `${prefix}<code>${escapeHtml(tld)}</code>`;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function wrapFileReferencesInHtml(html) {
|
|
231
|
+
AUTO_LINKED_ANCHOR_RE.lastIndex = 0;
|
|
232
|
+
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_RE, (m, label) => {
|
|
233
|
+
if (!isAutoLinkedFileRef(label)) return m;
|
|
234
|
+
return `<code>${escapeHtml(label)}</code>`;
|
|
235
|
+
});
|
|
236
|
+
let codeDepth = 0, preDepth = 0, anchorDepth = 0;
|
|
237
|
+
let result = '';
|
|
238
|
+
let lastIndex = 0;
|
|
239
|
+
HTML_TAG_RE.lastIndex = 0;
|
|
240
|
+
let match;
|
|
241
|
+
while ((match = HTML_TAG_RE.exec(deLinkified)) !== null) {
|
|
242
|
+
const tagStart = match.index;
|
|
243
|
+
const tagEnd = HTML_TAG_RE.lastIndex;
|
|
244
|
+
const isClosing = match[1] === '</';
|
|
245
|
+
const tagName = match[2].toLowerCase();
|
|
246
|
+
result += wrapSegment(deLinkified.slice(lastIndex, tagStart), codeDepth, preDepth, anchorDepth);
|
|
247
|
+
if (tagName === 'code') codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
|
|
248
|
+
else if (tagName === 'pre') preDepth = isClosing ? Math.max(0, preDepth - 1) : preDepth + 1;
|
|
249
|
+
else if (tagName === 'a') anchorDepth = isClosing ? Math.max(0, anchorDepth - 1) : anchorDepth + 1;
|
|
250
|
+
result += deLinkified.slice(tagStart, tagEnd);
|
|
251
|
+
lastIndex = tagEnd;
|
|
252
|
+
}
|
|
253
|
+
result += wrapSegment(deLinkified.slice(lastIndex), codeDepth, preDepth, anchorDepth);
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const _markedInstance = new Marked(
|
|
258
|
+
{ gfm: true, breaks: false, renderer: buildRenderer() },
|
|
259
|
+
{ extensions: [spoilerExtension] },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
function toTelegramHtml(text) {
|
|
263
|
+
if (typeof text !== 'string' || text.length === 0) return { text, parseMode: null };
|
|
28
264
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
265
|
+
const html = _markedInstance.parse(text).trimEnd();
|
|
266
|
+
const wrapped = wrapFileReferencesInHtml(html);
|
|
267
|
+
return { text: wrapped, parseMode: 'HTML' };
|
|
31
268
|
} catch {
|
|
32
269
|
return { text, parseMode: null };
|
|
33
270
|
}
|
|
34
271
|
}
|
|
35
272
|
|
|
36
|
-
|
|
273
|
+
function toTelegramMarkdown(text) { return toTelegramHtml(text); }
|
|
274
|
+
|
|
275
|
+
module.exports = { toTelegramMarkdown, toTelegramHtml, wrapFileReferencesInHtml, escapeHtml };
|
package/lib/telegram.js
CHANGED
|
@@ -43,12 +43,12 @@ function sleep(ms) {
|
|
|
43
43
|
return new Promise((r) => setTimeout(r, ms));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Methods whose `text` / `caption` fields we auto-format into
|
|
46
|
+
// Methods whose `text` / `caption` fields we auto-format into Telegram HTML.
|
|
47
47
|
// Anything else passes through untouched (setMessageReaction, sendSticker,
|
|
48
48
|
// deleteMessage, etc. have no text to format).
|
|
49
49
|
const FORMATTABLE_METHODS = new Set(['sendMessage', 'editMessageText']);
|
|
50
50
|
|
|
51
|
-
// Apply Claude-markdown → Telegram
|
|
51
|
+
// Apply Claude-markdown → Telegram HTML conversion in-place on the
|
|
52
52
|
// params object. Skipped if:
|
|
53
53
|
// - Method doesn't carry formattable text.
|
|
54
54
|
// - Caller already set a parse_mode (respect explicit choice).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"better-sqlite3": "^12.9.0",
|
|
64
64
|
"grammy": "^1.42.0",
|
|
65
|
-
"
|
|
65
|
+
"markdown-it": "^14.1.1",
|
|
66
|
+
"marked": "^18.0.2"
|
|
66
67
|
}
|
|
67
68
|
}
|
package/polygram.js
CHANGED
|
@@ -737,6 +737,29 @@ function buildApprovalKeyboard(approvalId, token) {
|
|
|
737
737
|
};
|
|
738
738
|
}
|
|
739
739
|
|
|
740
|
+
// /model and /effort inline keyboard. `show` controls which row(s) appear:
|
|
741
|
+
// 'model', 'effort', or 'all'. The current value gets a ✓ marker so the
|
|
742
|
+
// user can see at a glance what's selected.
|
|
743
|
+
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
744
|
+
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
745
|
+
|
|
746
|
+
function buildConfigKeyboard(chatConfig, show = 'all') {
|
|
747
|
+
const rows = [];
|
|
748
|
+
if (show === 'model' || show === 'all') {
|
|
749
|
+
rows.push(MODEL_OPTIONS.map((m) => ({
|
|
750
|
+
text: m === chatConfig.model ? `✓ ${m}` : m,
|
|
751
|
+
callback_data: `cfg:model:${m}`,
|
|
752
|
+
})));
|
|
753
|
+
}
|
|
754
|
+
if (show === 'effort' || show === 'all') {
|
|
755
|
+
rows.push(EFFORT_OPTIONS.map((e) => ({
|
|
756
|
+
text: e === chatConfig.effort ? `✓ ${e}` : e,
|
|
757
|
+
callback_data: `cfg:effort:${e}`,
|
|
758
|
+
})));
|
|
759
|
+
}
|
|
760
|
+
return { inline_keyboard: rows };
|
|
761
|
+
}
|
|
762
|
+
|
|
740
763
|
function approvalCardText(row, opts = {}) {
|
|
741
764
|
// No parse_mode is used on this card — tool_name/turn_id/tool_input
|
|
742
765
|
// originate from the Claude subprocess and could contain Markdown special
|
|
@@ -927,6 +950,81 @@ async function handleApprovalCallback(ctx) {
|
|
|
927
950
|
resolveApprovalWaiter(id, status);
|
|
928
951
|
}
|
|
929
952
|
|
|
953
|
+
// Handles taps on the /model and /effort inline keyboard buttons. Same
|
|
954
|
+
// outcome as the text-typed `/model sonnet` flow: mutate chatConfig,
|
|
955
|
+
// trigger graceful respawn, log config change, edit the message to show
|
|
956
|
+
// the new ✓ marker.
|
|
957
|
+
async function handleConfigCallback(ctx) {
|
|
958
|
+
const data = ctx.callbackQuery?.data || '';
|
|
959
|
+
const m = String(data).match(/^cfg:(model|effort):(\S+)$/);
|
|
960
|
+
if (!m) return;
|
|
961
|
+
const setting = m[1];
|
|
962
|
+
const value = m[2];
|
|
963
|
+
|
|
964
|
+
const chatId = String(ctx.callbackQuery.message?.chat?.id || '');
|
|
965
|
+
const chatConfig = config.chats[chatId];
|
|
966
|
+
if (!chatConfig) {
|
|
967
|
+
await ctx.answerCallbackQuery({ text: 'Chat not configured', show_alert: true }).catch(() => {});
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (!config.bot?.allowConfigCommands) {
|
|
971
|
+
await ctx.answerCallbackQuery({ text: 'Config commands disabled', show_alert: true }).catch(() => {});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
|
|
976
|
+
if (!validValues.includes(value)) {
|
|
977
|
+
await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const oldValue = chatConfig[setting];
|
|
982
|
+
if (oldValue === value) {
|
|
983
|
+
await ctx.answerCallbackQuery({ text: `Already ${value}` }).catch(() => {});
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
chatConfig[setting] = value;
|
|
988
|
+
const cmdUserId = ctx.callbackQuery.from?.id || null;
|
|
989
|
+
const cmdUser = ctx.callbackQuery.from?.first_name || ctx.callbackQuery.from?.username || null;
|
|
990
|
+
dbWrite(() => db.logConfigChange({
|
|
991
|
+
chat_id: chatId, thread_id: null, field: setting,
|
|
992
|
+
old_value: oldValue, new_value: value,
|
|
993
|
+
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
994
|
+
}), `log ${setting} change`);
|
|
995
|
+
|
|
996
|
+
// Graceful respawn across all sessionKeys for this chat (matches the
|
|
997
|
+
// text-command flow in handleMessage).
|
|
998
|
+
const reason = setting === 'model' ? 'model-change' : 'effort-change';
|
|
999
|
+
const prefix = chatId;
|
|
1000
|
+
let anyActive = false;
|
|
1001
|
+
for (const key of pm.keys()) {
|
|
1002
|
+
if (key === prefix || key.startsWith(prefix + ':')) {
|
|
1003
|
+
const res = pm.requestRespawn(key, reason);
|
|
1004
|
+
if (!res.killed) anyActive = true;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Re-render the card with updated ✓.
|
|
1009
|
+
const ver = MODEL_VERSIONS[chatConfig.model] || chatConfig.model;
|
|
1010
|
+
const newInfo = `Model: ${chatConfig.model} (${ver})\nEffort: ${chatConfig.effort}\nAgent: ${chatConfig.agent}`;
|
|
1011
|
+
const showRow = setting; // /model card → only model row, /effort → only effort, /config → both.
|
|
1012
|
+
// Detect original card type from existing reply_markup row count.
|
|
1013
|
+
const existingRows = ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
|
|
1014
|
+
const newKeyboard = buildConfigKeyboard(chatConfig, existingRows >= 2 ? 'all' : showRow);
|
|
1015
|
+
try {
|
|
1016
|
+
await ctx.editMessageText(newInfo, { reply_markup: newKeyboard });
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
// Edit may fail if message is too old or unchanged — not fatal.
|
|
1019
|
+
console.error(`[${BOT_NAME}] config-card edit failed: ${err.message}`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const ackText = anyActive
|
|
1023
|
+
? `${setting} → ${value} — switching when finished`
|
|
1024
|
+
: `${setting} → ${value}`;
|
|
1025
|
+
await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
930
1028
|
function startApprovalSweeper(intervalMs = 30_000) {
|
|
931
1029
|
return setInterval(() => {
|
|
932
1030
|
let rows;
|
|
@@ -1006,15 +1104,24 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1006
1104
|
const cmdUser = msg.from?.first_name || msg.from?.username || null;
|
|
1007
1105
|
const cmdUserId = msg.from?.id || null;
|
|
1008
1106
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1107
|
+
// sendReply accepts (text, meta?) with optional extra Telegram params
|
|
1108
|
+
// pulled out via meta.params (kept separate so meta stays for DB tags).
|
|
1109
|
+
const sendReply = (replyText, meta = {}) => {
|
|
1110
|
+
const { params: extraParams = {}, ...metaTags } = meta;
|
|
1111
|
+
return tg(bot, 'sendMessage', {
|
|
1112
|
+
chat_id: chatId, text: replyText, ...replyOpts(threadId), ...extraParams,
|
|
1113
|
+
}, { source: 'command-reply', botName: BOT_NAME, model: chatConfig.model, effort: chatConfig.effort, ...metaTags });
|
|
1114
|
+
};
|
|
1012
1115
|
|
|
1013
1116
|
if (botAllowsCommands && (text === '/model' || text === '/config' || text === '/effort')) {
|
|
1014
1117
|
const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
|
|
1015
1118
|
const ver = MODEL_VERSIONS[chatConfig.model] || chatConfig.model;
|
|
1016
1119
|
const info = `Model: ${chatConfig.model} (${ver})\nEffort: ${chatConfig.effort}\nAgent: ${chatConfig.agent}\nProcess: ${alive ? 'warm' : 'cold'}\nSession: ${getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new'}`;
|
|
1017
|
-
|
|
1120
|
+
// Inline keyboard so users tap to switch instead of typing exact
|
|
1121
|
+
// names (avoids "sonet" typo problem).
|
|
1122
|
+
const show = text === '/effort' ? 'effort' : text === '/model' ? 'model' : 'all';
|
|
1123
|
+
const reply_markup = buildConfigKeyboard(chatConfig, show);
|
|
1124
|
+
await sendReply(info, { params: { reply_markup } });
|
|
1018
1125
|
return;
|
|
1019
1126
|
}
|
|
1020
1127
|
// Helper: request respawn across ALL sessionKeys owned by this chat (one
|
|
@@ -1665,7 +1772,12 @@ function createBot(token) {
|
|
|
1665
1772
|
|
|
1666
1773
|
bot.on('callback_query:data', async (ctx) => {
|
|
1667
1774
|
try {
|
|
1668
|
-
|
|
1775
|
+
const data = ctx.callbackQuery?.data || '';
|
|
1776
|
+
if (data.startsWith('cfg:')) {
|
|
1777
|
+
await handleConfigCallback(ctx);
|
|
1778
|
+
} else {
|
|
1779
|
+
await handleApprovalCallback(ctx);
|
|
1780
|
+
}
|
|
1669
1781
|
} catch (err) {
|
|
1670
1782
|
console.error(`[${BOT_NAME}] callback_query error: ${err.message}`);
|
|
1671
1783
|
}
|