virtualcode 1.9.2 → 2.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 +106 -0
- package/LICENSE +21 -0
- package/README.md +227 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +485 -154
- package/dist/index.js.map +1 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +103 -34
- package/dist/tui.js.map +1 -1
- package/install.js +69 -6
- package/package.json +14 -3
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
2
|
import { Telegraf } from "telegraf";
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
const LINK_FILE = join(homedir(), ".config", "opencode", "telegram-links.json");
|
|
@@ -8,51 +8,86 @@ const LINK_TMP = LINK_FILE + ".tmp";
|
|
|
8
8
|
const TOKEN_FILE = join(homedir(), ".config", "opencode", "telegram-token.json");
|
|
9
9
|
const TOKEN_TMP = TOKEN_FILE + ".tmp";
|
|
10
10
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
11
|
-
|
|
11
|
+
const TOKEN_REGEX = /^\d{8,12}:[\w-]{30,50}$/;
|
|
12
|
+
const MAX_LRU_SIZE = 100;
|
|
13
|
+
const PENDING_TIMEOUT_MS = 30_000;
|
|
14
|
+
const RECONNECT_DELAYS = [5_000, 10_000, 20_000, 30_000];
|
|
15
|
+
const MAX_TELEGRAM_INPUT = 4096;
|
|
16
|
+
const MAX_TUI_TEXT = 400;
|
|
17
|
+
const MIN_PREFIX_LEN = 4;
|
|
18
|
+
const MAX_TOKENS_PER_CHUNK = 25;
|
|
19
|
+
const MIN_HISTORY_LIMIT = 1;
|
|
20
|
+
const FILE_MODE = 0o600;
|
|
21
|
+
function safeChmod(p) {
|
|
12
22
|
try {
|
|
13
|
-
|
|
14
|
-
return JSON.parse(readFileSync(LINK_FILE, "utf-8"));
|
|
15
|
-
}
|
|
23
|
+
chmodSync(p, FILE_MODE);
|
|
16
24
|
}
|
|
17
25
|
catch { }
|
|
18
|
-
return {};
|
|
19
26
|
}
|
|
20
|
-
function
|
|
27
|
+
function atomicWrite(path, tmp, data) {
|
|
21
28
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
22
|
-
writeFileSync(
|
|
29
|
+
writeFileSync(tmp, data);
|
|
30
|
+
safeChmod(tmp);
|
|
23
31
|
try {
|
|
24
|
-
renameSync(
|
|
32
|
+
renameSync(tmp, path);
|
|
25
33
|
}
|
|
26
34
|
catch {
|
|
27
|
-
writeFileSync(
|
|
35
|
+
writeFileSync(path, data);
|
|
28
36
|
}
|
|
37
|
+
safeChmod(path);
|
|
38
|
+
}
|
|
39
|
+
function loadLinks() {
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(LINK_FILE)) {
|
|
42
|
+
const raw = readFileSync(LINK_FILE, "utf-8");
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
debugLog("loadLinks failed:", userError(err));
|
|
51
|
+
}
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
function persistLinks(links) {
|
|
55
|
+
atomicWrite(LINK_FILE, LINK_TMP, JSON.stringify(links, null, 2));
|
|
29
56
|
}
|
|
30
57
|
function loadSavedToken() {
|
|
31
58
|
try {
|
|
32
59
|
if (existsSync(TOKEN_FILE)) {
|
|
33
60
|
const data = JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
|
|
34
|
-
if (data?.token)
|
|
61
|
+
if (typeof data?.token === "string" && data.token.length > 0)
|
|
35
62
|
return data.token;
|
|
36
63
|
}
|
|
37
64
|
}
|
|
38
|
-
catch {
|
|
65
|
+
catch (err) {
|
|
66
|
+
debugLog("loadSavedToken failed:", userError(err));
|
|
67
|
+
}
|
|
39
68
|
return null;
|
|
40
69
|
}
|
|
41
|
-
function
|
|
42
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
43
|
-
writeFileSync(TOKEN_TMP, JSON.stringify({ token }, null, 2));
|
|
70
|
+
function clearSavedToken() {
|
|
44
71
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
catch {
|
|
48
|
-
writeFileSync(TOKEN_FILE, JSON.stringify({ token }, null, 2));
|
|
72
|
+
if (existsSync(TOKEN_FILE))
|
|
73
|
+
renameSync(TOKEN_FILE, TOKEN_FILE + ".bak");
|
|
49
74
|
}
|
|
75
|
+
catch { }
|
|
76
|
+
}
|
|
77
|
+
function saveToken(token) {
|
|
78
|
+
atomicWrite(TOKEN_FILE, TOKEN_TMP, JSON.stringify({ token }, null, 2));
|
|
79
|
+
}
|
|
80
|
+
function isValidToken(token) {
|
|
81
|
+
if (typeof token !== "string")
|
|
82
|
+
return false;
|
|
83
|
+
if (token.length > 100)
|
|
84
|
+
return false;
|
|
85
|
+
return TOKEN_REGEX.test(token);
|
|
50
86
|
}
|
|
51
87
|
function userError(err) {
|
|
52
88
|
if (!err)
|
|
53
89
|
return "Unknown error";
|
|
54
90
|
if (err instanceof Error) {
|
|
55
|
-
// Only first line — never include stack traces
|
|
56
91
|
const first = err.message.split("\n")[0].trim();
|
|
57
92
|
return first || "Unknown error";
|
|
58
93
|
}
|
|
@@ -61,38 +96,41 @@ function userError(err) {
|
|
|
61
96
|
}
|
|
62
97
|
const DEBUG = !!process.env.DEBUG_TELEGRAM;
|
|
63
98
|
function debugLog(...args) {
|
|
64
|
-
console.error("[telegram-plugin]", ...args);
|
|
65
99
|
if (DEBUG) {
|
|
100
|
+
console.error("[telegram-plugin]", ...args);
|
|
66
101
|
for (const a of args) {
|
|
67
102
|
if (a instanceof Error)
|
|
68
|
-
console.error(a);
|
|
103
|
+
console.error(a.stack || a.message);
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
106
|
}
|
|
72
107
|
function sanitizeUI(text, maxLen = 200) {
|
|
73
108
|
if (!text)
|
|
74
109
|
return "";
|
|
75
|
-
|
|
76
|
-
let cleaned = text
|
|
110
|
+
let cleaned = String(text)
|
|
77
111
|
.split("\n")
|
|
78
112
|
.filter((line) => !/^\s*at\s/.test(line))
|
|
79
113
|
.join(" ")
|
|
80
114
|
.trim();
|
|
81
|
-
// Collapse whitespace
|
|
82
115
|
cleaned = cleaned.replace(/\s+/g, " ");
|
|
83
|
-
// Truncate
|
|
84
116
|
if (cleaned.length > maxLen) {
|
|
85
|
-
cleaned = cleaned.slice(0, maxLen - 3).trim() + "...";
|
|
117
|
+
cleaned = cleaned.slice(0, Math.max(0, maxLen - 3)).trim() + "...";
|
|
86
118
|
}
|
|
87
119
|
return cleaned;
|
|
88
120
|
}
|
|
121
|
+
function sanitizeTUI(text, maxLen = MAX_TUI_TEXT) {
|
|
122
|
+
const s = sanitizeUI(text, maxLen);
|
|
123
|
+
if (!s)
|
|
124
|
+
return "";
|
|
125
|
+
return s.length > maxLen ? s.slice(0, maxLen - 3) + "..." : s;
|
|
126
|
+
}
|
|
89
127
|
const KNOWN_ERRORS = [
|
|
90
|
-
[/Expected a string starting with "ses"/, "
|
|
91
|
-
[/ECONNRESET/, "
|
|
92
|
-
[/(409|Conflict)/, "
|
|
93
|
-
[/(401|Unauthorized|invalid.*token)/i, "
|
|
94
|
-
[/(403|Forbidden)/, "
|
|
95
|
-
[/(socket|timeout)/i, "
|
|
128
|
+
[/Expected a string starting with "ses"/, "Invalid session ID."],
|
|
129
|
+
[/ECONNRESET/, "Connection lost."],
|
|
130
|
+
[/(409|Conflict)/, "Another bot instance running."],
|
|
131
|
+
[/(401|Unauthorized|invalid.*token)/i, "Invalid token."],
|
|
132
|
+
[/(403|Forbidden)/, "Bot blocked."],
|
|
133
|
+
[/(socket|timeout)/i, "Connection timed out."],
|
|
96
134
|
];
|
|
97
135
|
function handlePluginError(err, context) {
|
|
98
136
|
const msg = userError(err);
|
|
@@ -101,9 +139,11 @@ function handlePluginError(err, context) {
|
|
|
101
139
|
if (pattern.test(msg))
|
|
102
140
|
return friendly;
|
|
103
141
|
}
|
|
104
|
-
return "
|
|
142
|
+
return "Something went wrong.";
|
|
105
143
|
}
|
|
106
144
|
function chunkText(text, maxLen = 4000) {
|
|
145
|
+
if (!text)
|
|
146
|
+
return [""];
|
|
107
147
|
if (text.length <= maxLen)
|
|
108
148
|
return [text];
|
|
109
149
|
const chunks = [];
|
|
@@ -113,8 +153,33 @@ function chunkText(text, maxLen = 4000) {
|
|
|
113
153
|
return chunks;
|
|
114
154
|
}
|
|
115
155
|
function fmtId(id) {
|
|
156
|
+
if (typeof id !== "string")
|
|
157
|
+
return "(invalid)";
|
|
116
158
|
return id.length > 12 ? id.slice(0, 12) + "..." : id;
|
|
117
159
|
}
|
|
160
|
+
function findSessionById(id, list) {
|
|
161
|
+
if (typeof id !== "string" || !Array.isArray(list))
|
|
162
|
+
return { status: "not_found" };
|
|
163
|
+
const exact = list.find((s) => s && typeof s.id === "string" && s.id === id);
|
|
164
|
+
if (exact)
|
|
165
|
+
return { status: "found", session: exact };
|
|
166
|
+
if (id.length < MIN_PREFIX_LEN)
|
|
167
|
+
return { status: "not_found" };
|
|
168
|
+
const prefix = list.filter((s) => s && typeof s.id === "string" && s.id.startsWith(id));
|
|
169
|
+
if (prefix.length === 1)
|
|
170
|
+
return { status: "found", session: prefix[0] };
|
|
171
|
+
if (prefix.length > 1)
|
|
172
|
+
return { status: "ambiguous" };
|
|
173
|
+
return { status: "not_found" };
|
|
174
|
+
}
|
|
175
|
+
function safeList(arr) {
|
|
176
|
+
if (!Array.isArray(arr))
|
|
177
|
+
return [];
|
|
178
|
+
return arr.filter((s) => s && typeof s === "object" && typeof s.id === "string");
|
|
179
|
+
}
|
|
180
|
+
function delay(ms) {
|
|
181
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
182
|
+
}
|
|
118
183
|
const TelegramPlugin = async ({ client, directory }, options) => {
|
|
119
184
|
const config = options;
|
|
120
185
|
const allowedSet = config?.allowed_users?.length ? new Set(config.allowed_users) : null;
|
|
@@ -122,6 +187,10 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
122
187
|
let bot = null;
|
|
123
188
|
let botReady = false;
|
|
124
189
|
let botStarting = false;
|
|
190
|
+
let userStopped = false;
|
|
191
|
+
let savedToken = null;
|
|
192
|
+
let reconnectTimer = null;
|
|
193
|
+
let reconnectAttempts = 0;
|
|
125
194
|
const lastForwardedBySession = new Map();
|
|
126
195
|
const pendingTelegram = new Map();
|
|
127
196
|
const links = loadLinks();
|
|
@@ -167,44 +236,112 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
167
236
|
}
|
|
168
237
|
persistLinks(links);
|
|
169
238
|
}
|
|
170
|
-
|
|
171
|
-
if (
|
|
239
|
+
function lruSet(map, key, value) {
|
|
240
|
+
if (map.has(key)) {
|
|
241
|
+
map.delete(key);
|
|
242
|
+
}
|
|
243
|
+
else if (map.size >= MAX_LRU_SIZE) {
|
|
244
|
+
const oldest = map.keys().next().value;
|
|
245
|
+
if (oldest !== undefined)
|
|
246
|
+
map.delete(oldest);
|
|
247
|
+
}
|
|
248
|
+
map.set(key, value);
|
|
249
|
+
}
|
|
250
|
+
function setPending(sessionId, chatId) {
|
|
251
|
+
const existing = pendingTelegram.get(sessionId);
|
|
252
|
+
if (existing)
|
|
253
|
+
clearTimeout(existing.timer);
|
|
254
|
+
const timer = setTimeout(() => {
|
|
255
|
+
pendingTelegram.delete(sessionId);
|
|
256
|
+
}, PENDING_TIMEOUT_MS);
|
|
257
|
+
pendingTelegram.set(sessionId, { chatId, timer });
|
|
258
|
+
}
|
|
259
|
+
function clearPending(sessionId) {
|
|
260
|
+
const entry = pendingTelegram.get(sessionId);
|
|
261
|
+
if (entry) {
|
|
262
|
+
clearTimeout(entry.timer);
|
|
263
|
+
pendingTelegram.delete(sessionId);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function scheduleReconnect() {
|
|
267
|
+
if (userStopped || !savedToken)
|
|
268
|
+
return;
|
|
269
|
+
if (reconnectTimer)
|
|
172
270
|
return;
|
|
173
|
-
|
|
271
|
+
if (reconnectAttempts >= 10) {
|
|
272
|
+
debugLog("reconnect attempts exhausted; giving up until next event");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const delayMs = RECONNECT_DELAYS[Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1)];
|
|
276
|
+
reconnectAttempts++;
|
|
277
|
+
debugLog("scheduling reconnect in", delayMs, "ms (attempt", reconnectAttempts + ")");
|
|
278
|
+
reconnectTimer = setTimeout(async () => {
|
|
279
|
+
reconnectTimer = null;
|
|
280
|
+
if (savedToken && !userStopped) {
|
|
281
|
+
await startBot(savedToken);
|
|
282
|
+
}
|
|
283
|
+
}, delayMs);
|
|
284
|
+
}
|
|
285
|
+
async function sendToChats(chats, text) {
|
|
286
|
+
if (!botReady || !bot)
|
|
287
|
+
return false;
|
|
288
|
+
const safeText = sanitizeUI(text, 4000);
|
|
289
|
+
const chunks = chunkText(safeText, 4000);
|
|
290
|
+
let sentAny = false;
|
|
174
291
|
for (const chatId of chats) {
|
|
175
292
|
for (const chunk of chunks) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
botReady = false;
|
|
293
|
+
let attempt = 0;
|
|
294
|
+
while (attempt < 2) {
|
|
295
|
+
try {
|
|
296
|
+
await bot.telegram.sendMessage(chatId, chunk);
|
|
297
|
+
sentAny = true;
|
|
298
|
+
break;
|
|
183
299
|
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
attempt++;
|
|
302
|
+
const msg = handlePluginError(err, "sendMessage");
|
|
303
|
+
if (msg.includes("blocked")) {
|
|
304
|
+
removeLink(chatId);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
if (msg.includes("Invalid token")) {
|
|
308
|
+
botReady = false;
|
|
309
|
+
scheduleReconnect();
|
|
310
|
+
return sentAny;
|
|
311
|
+
}
|
|
312
|
+
if (attempt >= 2) {
|
|
313
|
+
debugLog("sendMessage gave up for chat", chatId, ":", msg);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
await delay(500);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (chunks.length > MAX_TOKENS_PER_CHUNK) {
|
|
321
|
+
await delay(50);
|
|
184
322
|
}
|
|
185
323
|
}
|
|
186
324
|
}
|
|
325
|
+
return sentAny;
|
|
187
326
|
}
|
|
188
327
|
async function sendToSession(sessionId, text) {
|
|
189
328
|
const chats = sessionToChats.get(sessionId);
|
|
190
329
|
if (!chats)
|
|
191
|
-
return;
|
|
192
|
-
await sendToChats(chats, text);
|
|
193
|
-
}
|
|
194
|
-
async function findSessionById(id, list) {
|
|
195
|
-
const exact = list.find((s) => s.id === id);
|
|
196
|
-
if (exact)
|
|
197
|
-
return exact;
|
|
198
|
-
const prefix = list.filter((s) => s.id.startsWith(id));
|
|
199
|
-
if (prefix.length === 1)
|
|
200
|
-
return prefix[0];
|
|
201
|
-
return null;
|
|
330
|
+
return false;
|
|
331
|
+
return await sendToChats(chats, text) ?? false;
|
|
202
332
|
}
|
|
203
333
|
async function startBot(token) {
|
|
204
334
|
if (botStarting)
|
|
205
335
|
return;
|
|
336
|
+
if (!isValidToken(token)) {
|
|
337
|
+
debugLog("startBot: invalid token format");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
206
340
|
botStarting = true;
|
|
207
341
|
botReady = false;
|
|
342
|
+
userStopped = false;
|
|
343
|
+
savedToken = token;
|
|
344
|
+
reconnectAttempts = 0;
|
|
208
345
|
try {
|
|
209
346
|
try {
|
|
210
347
|
bot?.stop();
|
|
@@ -214,144 +351,251 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
214
351
|
bot.catch((err) => {
|
|
215
352
|
handlePluginError(err, "bot.catch");
|
|
216
353
|
});
|
|
354
|
+
bot.command("start", async (ctx) => {
|
|
355
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
356
|
+
return;
|
|
357
|
+
try {
|
|
358
|
+
await ctx.reply("virtualcode - OpenCode Telegram bridge\n\n" +
|
|
359
|
+
"Quick setup:\n" +
|
|
360
|
+
"1. /ls - list your sessions\n" +
|
|
361
|
+
"2. /link <ID> - bind this chat\n" +
|
|
362
|
+
"3. Send any message to talk to OpenCode\n\n" +
|
|
363
|
+
"Type /help for all commands.");
|
|
364
|
+
}
|
|
365
|
+
catch { }
|
|
366
|
+
});
|
|
217
367
|
bot.command("link", async (ctx) => {
|
|
218
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
368
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
219
369
|
return;
|
|
220
|
-
const arg = ctx.payload.trim();
|
|
370
|
+
const arg = ctx.payload.trim().slice(0, 200);
|
|
221
371
|
if (!arg) {
|
|
222
|
-
|
|
372
|
+
try {
|
|
373
|
+
await ctx.reply("Usage: /link <sessionID>");
|
|
374
|
+
}
|
|
375
|
+
catch { }
|
|
223
376
|
return;
|
|
224
377
|
}
|
|
225
378
|
try {
|
|
226
379
|
const list = await client.session.list();
|
|
227
380
|
if (list.error || !list.data) {
|
|
228
|
-
|
|
381
|
+
try {
|
|
382
|
+
await ctx.reply("Could not load sessions.");
|
|
383
|
+
}
|
|
384
|
+
catch { }
|
|
229
385
|
return;
|
|
230
386
|
}
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
387
|
+
const sessions = safeList(list.data);
|
|
388
|
+
const result = findSessionById(arg, sessions);
|
|
389
|
+
if (result.status === "not_found") {
|
|
390
|
+
try {
|
|
391
|
+
await ctx.reply(arg.length < MIN_PREFIX_LEN ? "Prefix too short (min 4 chars)." : "Session not found.");
|
|
392
|
+
}
|
|
393
|
+
catch { }
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (result.status === "ambiguous") {
|
|
397
|
+
try {
|
|
398
|
+
await ctx.reply("Multiple sessions match. Use the full ID from /ls.");
|
|
399
|
+
}
|
|
400
|
+
catch { }
|
|
234
401
|
return;
|
|
235
402
|
}
|
|
236
403
|
const old = links[ctx.chat.id];
|
|
237
|
-
addLink(ctx.chat.id,
|
|
404
|
+
addLink(ctx.chat.id, result.session.id);
|
|
238
405
|
if (old) {
|
|
239
|
-
|
|
406
|
+
try {
|
|
407
|
+
await ctx.reply("Switched to " + fmtId(result.session.id) + " (from " + fmtId(old) + ")");
|
|
408
|
+
}
|
|
409
|
+
catch { }
|
|
240
410
|
}
|
|
241
411
|
else {
|
|
242
|
-
|
|
412
|
+
try {
|
|
413
|
+
await ctx.reply("Linked to " + (result.session.title || fmtId(result.session.id)));
|
|
414
|
+
}
|
|
415
|
+
catch { }
|
|
243
416
|
}
|
|
244
417
|
}
|
|
245
418
|
catch (err) {
|
|
246
419
|
const msg = handlePluginError(err, "/link");
|
|
247
|
-
|
|
420
|
+
try {
|
|
421
|
+
await ctx.reply(sanitizeUI(msg));
|
|
422
|
+
}
|
|
423
|
+
catch { }
|
|
248
424
|
}
|
|
249
425
|
});
|
|
250
426
|
bot.command("unlink", async (ctx) => {
|
|
251
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
427
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
252
428
|
return;
|
|
253
429
|
if (!links[ctx.chat.id]) {
|
|
254
|
-
|
|
430
|
+
try {
|
|
431
|
+
await ctx.reply("Not linked.");
|
|
432
|
+
}
|
|
433
|
+
catch { }
|
|
255
434
|
return;
|
|
256
435
|
}
|
|
257
436
|
removeLink(ctx.chat.id);
|
|
258
|
-
|
|
437
|
+
try {
|
|
438
|
+
await ctx.reply("Unlinked.");
|
|
439
|
+
}
|
|
440
|
+
catch { }
|
|
259
441
|
});
|
|
260
442
|
bot.command("status", async (ctx) => {
|
|
261
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
443
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
262
444
|
return;
|
|
263
445
|
const sessionId = links[ctx.chat.id];
|
|
264
446
|
if (!sessionId) {
|
|
265
|
-
|
|
447
|
+
try {
|
|
448
|
+
await ctx.reply("Not linked. Use /link <ID>");
|
|
449
|
+
}
|
|
450
|
+
catch { }
|
|
266
451
|
return;
|
|
267
452
|
}
|
|
268
|
-
|
|
453
|
+
try {
|
|
454
|
+
await ctx.reply("Connected | Session: " + fmtId(sessionId) + " | Project: " + (directory || "none"));
|
|
455
|
+
}
|
|
456
|
+
catch { }
|
|
269
457
|
});
|
|
270
458
|
bot.command(["ls", "sessions"], async (ctx) => {
|
|
271
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
459
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
272
460
|
return;
|
|
273
461
|
try {
|
|
274
462
|
const res = await client.session.list();
|
|
275
463
|
if (res.error || !res.data) {
|
|
276
|
-
|
|
464
|
+
try {
|
|
465
|
+
await ctx.reply("Could not load sessions.");
|
|
466
|
+
}
|
|
467
|
+
catch { }
|
|
277
468
|
return;
|
|
278
469
|
}
|
|
279
470
|
const current = links[ctx.chat.id];
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
471
|
+
const sessions = safeList(res.data);
|
|
472
|
+
const recent = sessions.slice(-20);
|
|
473
|
+
const lines = recent.map((s, i) => {
|
|
474
|
+
const num = recent.length - i;
|
|
475
|
+
const marker = s.id === current ? " *" : "";
|
|
476
|
+
const label = (s.title || s.id.slice(0, 16)).slice(0, 60);
|
|
477
|
+
return num + ". " + label + " -- " + s.id + marker;
|
|
285
478
|
});
|
|
286
|
-
|
|
479
|
+
try {
|
|
480
|
+
await ctx.reply("Sessions:\n" + (lines.length ? lines.join("\n") : "None"));
|
|
481
|
+
}
|
|
482
|
+
catch { }
|
|
287
483
|
}
|
|
288
484
|
catch (err) {
|
|
289
485
|
const msg = handlePluginError(err, "/ls");
|
|
290
|
-
|
|
486
|
+
try {
|
|
487
|
+
await ctx.reply(sanitizeUI(msg));
|
|
488
|
+
}
|
|
489
|
+
catch { }
|
|
291
490
|
}
|
|
292
491
|
});
|
|
293
492
|
bot.command("use", async (ctx) => {
|
|
294
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
493
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
295
494
|
return;
|
|
296
|
-
const arg = ctx.payload.trim();
|
|
495
|
+
const arg = ctx.payload.trim().slice(0, 200);
|
|
297
496
|
if (!arg) {
|
|
298
|
-
|
|
497
|
+
try {
|
|
498
|
+
await ctx.reply("Usage: /use <number|ID>");
|
|
499
|
+
}
|
|
500
|
+
catch { }
|
|
299
501
|
return;
|
|
300
502
|
}
|
|
301
503
|
try {
|
|
302
504
|
const res = await client.session.list();
|
|
303
505
|
if (res.error || !res.data) {
|
|
304
|
-
|
|
506
|
+
try {
|
|
507
|
+
await ctx.reply("Could not load sessions.");
|
|
508
|
+
}
|
|
509
|
+
catch { }
|
|
305
510
|
return;
|
|
306
511
|
}
|
|
512
|
+
const sessions = safeList(res.data);
|
|
307
513
|
const num = parseInt(arg);
|
|
514
|
+
if (!isNaN(num) && num > 0 && num <= sessions.length) {
|
|
515
|
+
const s = sessions[sessions.length - num];
|
|
516
|
+
addLink(ctx.chat.id, s.id);
|
|
517
|
+
try {
|
|
518
|
+
await ctx.reply("Switched to " + (s.title || fmtId(s.id)));
|
|
519
|
+
}
|
|
520
|
+
catch { }
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
308
523
|
if (!isNaN(num) && num > 0) {
|
|
309
|
-
|
|
310
|
-
await ctx.reply("
|
|
311
|
-
return;
|
|
524
|
+
try {
|
|
525
|
+
await ctx.reply("Only " + sessions.length + " sessions available.");
|
|
312
526
|
}
|
|
313
|
-
|
|
314
|
-
if (!s)
|
|
315
|
-
return;
|
|
316
|
-
addLink(ctx.chat.id, s.id);
|
|
317
|
-
await ctx.reply("Switched to " + (s.title || fmtId(s.id)));
|
|
527
|
+
catch { }
|
|
318
528
|
return;
|
|
319
529
|
}
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
|
|
530
|
+
const result = findSessionById(arg, sessions);
|
|
531
|
+
if (result.status === "not_found") {
|
|
532
|
+
try {
|
|
533
|
+
await ctx.reply(arg.length < MIN_PREFIX_LEN ? "Prefix too short (min 4 chars)." : "Session not found.");
|
|
534
|
+
}
|
|
535
|
+
catch { }
|
|
323
536
|
return;
|
|
324
537
|
}
|
|
325
|
-
|
|
326
|
-
|
|
538
|
+
if (result.status === "ambiguous") {
|
|
539
|
+
try {
|
|
540
|
+
await ctx.reply("Multiple sessions match. Use the full ID from /ls.");
|
|
541
|
+
}
|
|
542
|
+
catch { }
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
addLink(ctx.chat.id, result.session.id);
|
|
546
|
+
try {
|
|
547
|
+
await ctx.reply("Switched to " + (result.session.title || fmtId(result.session.id)));
|
|
548
|
+
}
|
|
549
|
+
catch { }
|
|
327
550
|
}
|
|
328
551
|
catch (err) {
|
|
329
552
|
const msg = handlePluginError(err, "/use");
|
|
330
|
-
|
|
553
|
+
try {
|
|
554
|
+
await ctx.reply(sanitizeUI(msg));
|
|
555
|
+
}
|
|
556
|
+
catch { }
|
|
331
557
|
}
|
|
332
558
|
});
|
|
333
559
|
bot.command("history", async (ctx) => {
|
|
334
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
560
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
335
561
|
return;
|
|
336
562
|
const sessionId = links[ctx.chat.id];
|
|
337
563
|
if (!sessionId) {
|
|
338
|
-
|
|
564
|
+
try {
|
|
565
|
+
await ctx.reply("Not linked. Use /link <ID>");
|
|
566
|
+
}
|
|
567
|
+
catch { }
|
|
339
568
|
return;
|
|
340
569
|
}
|
|
341
570
|
try {
|
|
342
|
-
const limitText = ctx.payload.trim();
|
|
343
|
-
|
|
571
|
+
const limitText = ctx.payload.trim().slice(0, 10);
|
|
572
|
+
let limit = MIN_HISTORY_LIMIT;
|
|
573
|
+
if (limitText) {
|
|
574
|
+
const parsed = parseInt(limitText);
|
|
575
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
576
|
+
limit = Math.min(parsed, 100);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
limit = 20;
|
|
581
|
+
}
|
|
344
582
|
const res = await client.session.messages({ path: { id: sessionId }, query: { directory, limit } });
|
|
345
583
|
if (res.error || !res.data || res.data.length === 0) {
|
|
346
|
-
|
|
584
|
+
try {
|
|
585
|
+
await ctx.reply("No messages found.");
|
|
586
|
+
}
|
|
587
|
+
catch { }
|
|
347
588
|
return;
|
|
348
589
|
}
|
|
349
590
|
const lines = [];
|
|
350
591
|
for (const msg of res.data) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
592
|
+
if (!msg || !msg.info)
|
|
593
|
+
continue;
|
|
594
|
+
const role = msg.info.role === "user" ? "[User]" : "[AI]";
|
|
595
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : [];
|
|
596
|
+
const text = parts
|
|
597
|
+
.filter((p) => p && p.type === "text" && !p.synthetic)
|
|
598
|
+
.map((p) => String(p.text || ""))
|
|
355
599
|
.join("\n")
|
|
356
600
|
.trim();
|
|
357
601
|
if (!text)
|
|
@@ -360,41 +604,70 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
360
604
|
lines.push(role + " " + truncated);
|
|
361
605
|
}
|
|
362
606
|
if (lines.length === 0) {
|
|
363
|
-
|
|
607
|
+
try {
|
|
608
|
+
await ctx.reply("No text messages found.");
|
|
609
|
+
}
|
|
610
|
+
catch { }
|
|
364
611
|
return;
|
|
365
612
|
}
|
|
366
613
|
const text = lines.join("\n\n");
|
|
367
614
|
for (const chunk of chunkText(text)) {
|
|
368
|
-
|
|
615
|
+
try {
|
|
616
|
+
await ctx.reply(chunk);
|
|
617
|
+
}
|
|
618
|
+
catch { }
|
|
369
619
|
}
|
|
370
620
|
}
|
|
371
621
|
catch (err) {
|
|
372
622
|
const msg = handlePluginError(err, "/history");
|
|
373
|
-
|
|
623
|
+
try {
|
|
624
|
+
await ctx.reply(sanitizeUI(msg));
|
|
625
|
+
}
|
|
626
|
+
catch { }
|
|
374
627
|
}
|
|
375
628
|
});
|
|
376
629
|
bot.command("help", async (ctx) => {
|
|
377
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
630
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
378
631
|
return;
|
|
379
|
-
|
|
380
|
-
"/
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
632
|
+
try {
|
|
633
|
+
await ctx.reply("/link <sessionId> - Bind this chat to a session\n" +
|
|
634
|
+
"/unlink - Remove binding\n" +
|
|
635
|
+
"/status - Show connection state\n" +
|
|
636
|
+
"/ls - List recent sessions\n" +
|
|
637
|
+
"/use <number|ID> - Switch active session\n" +
|
|
638
|
+
"/history [N] - View last N messages\n" +
|
|
639
|
+
"/help - Show this help\n\n" +
|
|
640
|
+
"Any other message will be sent to the linked session.");
|
|
641
|
+
}
|
|
642
|
+
catch { }
|
|
643
|
+
});
|
|
644
|
+
bot.on(["photo", "sticker", "document", "video", "audio", "voice"], async (ctx) => {
|
|
645
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
646
|
+
return;
|
|
647
|
+
try {
|
|
648
|
+
await ctx.reply("Only text messages are supported.");
|
|
649
|
+
}
|
|
650
|
+
catch { }
|
|
387
651
|
});
|
|
388
652
|
bot.on("text", async (ctx) => {
|
|
389
|
-
if (allowedSet && !allowedSet.has(ctx.from.id))
|
|
653
|
+
if (allowedSet && (!ctx.from || !allowedSet.has(ctx.from.id)))
|
|
390
654
|
return;
|
|
391
655
|
const sessionId = links[ctx.chat.id];
|
|
392
656
|
if (!sessionId) {
|
|
393
|
-
|
|
657
|
+
try {
|
|
658
|
+
await ctx.reply("Not linked. Use /link <ID>");
|
|
659
|
+
}
|
|
660
|
+
catch { }
|
|
394
661
|
return;
|
|
395
662
|
}
|
|
396
|
-
|
|
397
|
-
|
|
663
|
+
let working = null;
|
|
664
|
+
try {
|
|
665
|
+
working = await ctx.reply("...");
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
debugLog("working reply failed:", userError(err));
|
|
669
|
+
}
|
|
670
|
+
setPending(sessionId, ctx.chat.id);
|
|
398
671
|
try {
|
|
399
672
|
const res = await client.session.prompt({
|
|
400
673
|
path: { id: sessionId },
|
|
@@ -403,24 +676,32 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
403
676
|
},
|
|
404
677
|
query: { directory },
|
|
405
678
|
});
|
|
406
|
-
if (res
|
|
679
|
+
if (res?.error) {
|
|
407
680
|
const msg = handlePluginError(res.error, "prompt");
|
|
408
|
-
|
|
681
|
+
try {
|
|
682
|
+
await ctx.reply(sanitizeUI(msg));
|
|
683
|
+
}
|
|
684
|
+
catch { }
|
|
409
685
|
}
|
|
410
686
|
}
|
|
411
687
|
catch (err) {
|
|
412
688
|
const msg = handlePluginError(err, "prompt");
|
|
413
|
-
|
|
689
|
+
try {
|
|
690
|
+
await ctx.reply(sanitizeUI(msg));
|
|
691
|
+
}
|
|
692
|
+
catch { }
|
|
414
693
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
694
|
+
clearPending(sessionId);
|
|
695
|
+
if (working?.message_id) {
|
|
696
|
+
try {
|
|
697
|
+
await ctx.deleteMessage(working.message_id);
|
|
698
|
+
}
|
|
699
|
+
catch { }
|
|
418
700
|
}
|
|
419
|
-
catch { }
|
|
420
701
|
});
|
|
421
702
|
if (notifyOnReconnect) {
|
|
422
703
|
for (const chatId of Object.keys(links)) {
|
|
423
|
-
bot.telegram.sendMessage(Number(chatId), "
|
|
704
|
+
bot.telegram.sendMessage(Number(chatId), "Telegram bridge reconnected.").catch(() => { });
|
|
424
705
|
}
|
|
425
706
|
}
|
|
426
707
|
try {
|
|
@@ -431,19 +712,26 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
431
712
|
debugLog("startBot failed:", msg);
|
|
432
713
|
botStarting = false;
|
|
433
714
|
botReady = false;
|
|
715
|
+
scheduleReconnect();
|
|
434
716
|
return;
|
|
435
717
|
}
|
|
436
718
|
botReady = true;
|
|
437
719
|
botStarting = false;
|
|
438
|
-
bot.launch().catch((err) => {
|
|
720
|
+
bot.launch().catch(async (err) => {
|
|
439
721
|
handlePluginError(err, "bot.launch");
|
|
440
722
|
botReady = false;
|
|
723
|
+
try {
|
|
724
|
+
await bot?.stop();
|
|
725
|
+
}
|
|
726
|
+
catch { }
|
|
727
|
+
scheduleReconnect();
|
|
441
728
|
});
|
|
442
729
|
}
|
|
443
730
|
catch (err) {
|
|
444
731
|
handlePluginError(err, "startBot");
|
|
445
732
|
botStarting = false;
|
|
446
733
|
botReady = false;
|
|
734
|
+
scheduleReconnect();
|
|
447
735
|
}
|
|
448
736
|
}
|
|
449
737
|
const configToken = config?.token;
|
|
@@ -470,7 +758,7 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
470
758
|
if (event.type === "session.status" && event.properties.status.type === "idle") {
|
|
471
759
|
const sid = event.properties.sessionID;
|
|
472
760
|
const pending = pendingTelegram.get(sid);
|
|
473
|
-
const chats = pending ? new Set([pending]) : sessionToChats.get(sid);
|
|
761
|
+
const chats = pending ? new Set([pending.chatId]) : sessionToChats.get(sid);
|
|
474
762
|
if (!chats || chats.size === 0)
|
|
475
763
|
return;
|
|
476
764
|
let msgs;
|
|
@@ -485,7 +773,7 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
485
773
|
const last = [...msgs.data].reverse().find((m) => m.info.role === "assistant");
|
|
486
774
|
if (!last || lastForwardedBySession.get(sid) === last.info.id)
|
|
487
775
|
return;
|
|
488
|
-
lastForwardedBySession
|
|
776
|
+
lruSet(lastForwardedBySession, sid, last.info.id);
|
|
489
777
|
const text = last.parts
|
|
490
778
|
.filter((p) => p.type === "text" && !p.synthetic)
|
|
491
779
|
.map((p) => p.text)
|
|
@@ -509,13 +797,24 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
509
797
|
await startBot(saved);
|
|
510
798
|
}
|
|
511
799
|
}
|
|
800
|
+
if (!output || !Array.isArray(output.parts))
|
|
801
|
+
return;
|
|
512
802
|
for (const part of output.parts) {
|
|
513
|
-
if (part.type !== "text")
|
|
803
|
+
if (!part || part.type !== "text" || typeof part.text !== "string")
|
|
514
804
|
continue;
|
|
515
|
-
|
|
805
|
+
let text = part.text;
|
|
806
|
+
if (text.length > MAX_TELEGRAM_INPUT) {
|
|
807
|
+
text = text.slice(0, MAX_TELEGRAM_INPUT);
|
|
808
|
+
}
|
|
516
809
|
if (text.startsWith("/telegram")) {
|
|
517
|
-
const
|
|
518
|
-
|
|
810
|
+
const rawArgs = text.slice("/telegram".length).trim().slice(0, 200);
|
|
811
|
+
const cmd = rawArgs.toLowerCase();
|
|
812
|
+
if (cmd === "disconnect" || cmd === "stop") {
|
|
813
|
+
userStopped = true;
|
|
814
|
+
if (reconnectTimer) {
|
|
815
|
+
clearTimeout(reconnectTimer);
|
|
816
|
+
reconnectTimer = null;
|
|
817
|
+
}
|
|
519
818
|
try {
|
|
520
819
|
bot?.stop();
|
|
521
820
|
}
|
|
@@ -523,33 +822,41 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
523
822
|
botReady = false;
|
|
524
823
|
bot = null;
|
|
525
824
|
botStarting = false;
|
|
526
|
-
|
|
527
|
-
|
|
825
|
+
savedToken = null;
|
|
826
|
+
clearSavedToken();
|
|
528
827
|
part.text = "Telegram bot disconnected and token removed.";
|
|
529
828
|
return;
|
|
530
829
|
}
|
|
531
|
-
if (
|
|
830
|
+
if (cmd === "status" || cmd === "") {
|
|
532
831
|
if (botReady) {
|
|
533
832
|
part.text =
|
|
534
833
|
"Telegram bot is connected.\n" +
|
|
535
|
-
"
|
|
536
|
-
"
|
|
537
|
-
"
|
|
834
|
+
"- /telegram <new_token> - Change bot token\n" +
|
|
835
|
+
"- /telegram disconnect - Stop bot and remove token\n" +
|
|
836
|
+
"- /telegram - Open setup dialog (type /telegram in command palette)";
|
|
538
837
|
}
|
|
539
838
|
else {
|
|
540
839
|
part.text =
|
|
541
840
|
"Telegram bot is not connected.\n" +
|
|
542
|
-
"
|
|
543
|
-
"
|
|
841
|
+
"- /telegram <your_bot_token> - Connect with a token\n" +
|
|
842
|
+
"- /telegram - Open setup dialog (type /telegram in command palette)";
|
|
544
843
|
}
|
|
844
|
+
part.text = sanitizeTUI(part.text, MAX_TUI_TEXT);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const tokenMatch = rawArgs.match(/^\s*["'`]*([^\s"'`]+)["'`]*\s*$/);
|
|
848
|
+
if (!tokenMatch) {
|
|
849
|
+
part.text = sanitizeTUI("Invalid token.", MAX_TUI_TEXT);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const token = tokenMatch[1];
|
|
853
|
+
if (!isValidToken(token)) {
|
|
854
|
+
part.text = sanitizeTUI("Invalid token.", MAX_TUI_TEXT);
|
|
545
855
|
return;
|
|
546
856
|
}
|
|
547
|
-
const token = args;
|
|
548
857
|
saveToken(token);
|
|
549
858
|
await startBot(token);
|
|
550
|
-
part.text = botReady
|
|
551
|
-
? "\u2705 Connected."
|
|
552
|
-
: "\u274c Invalid token.";
|
|
859
|
+
part.text = sanitizeTUI(botReady ? "Connected." : "Invalid token.", MAX_TUI_TEXT);
|
|
553
860
|
return;
|
|
554
861
|
}
|
|
555
862
|
}
|
|
@@ -566,20 +873,44 @@ const TelegramPlugin = async ({ client, directory }, options) => {
|
|
|
566
873
|
sessionId: tool.schema.string().optional().describe("Target session ID (defaults to current)"),
|
|
567
874
|
},
|
|
568
875
|
async execute({ text, sessionId }, ctx) {
|
|
876
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
877
|
+
return { output: "No text to send." };
|
|
878
|
+
}
|
|
879
|
+
if (!botReady) {
|
|
880
|
+
return { output: "Telegram bot is not connected." };
|
|
881
|
+
}
|
|
882
|
+
if (!ctx?.sessionID && !sessionId) {
|
|
883
|
+
return { output: "No session context." };
|
|
884
|
+
}
|
|
569
885
|
const targetId = sessionId || ctx.sessionID;
|
|
570
|
-
|
|
571
|
-
|
|
886
|
+
if (typeof targetId !== "string") {
|
|
887
|
+
return { output: "Invalid session ID." };
|
|
888
|
+
}
|
|
889
|
+
const safeText = text.length > 4000 ? text.slice(0, 4000) + "..." : text;
|
|
890
|
+
const ok = await sendToSession(targetId, safeText);
|
|
891
|
+
return { output: ok ? "Message sent to Telegram" : "No linked chats." };
|
|
572
892
|
},
|
|
573
893
|
}),
|
|
574
894
|
},
|
|
575
895
|
async dispose() {
|
|
896
|
+
userStopped = true;
|
|
897
|
+
if (reconnectTimer) {
|
|
898
|
+
clearTimeout(reconnectTimer);
|
|
899
|
+
reconnectTimer = null;
|
|
900
|
+
}
|
|
901
|
+
for (const [, entry] of pendingTelegram) {
|
|
902
|
+
clearTimeout(entry.timer);
|
|
903
|
+
}
|
|
904
|
+
pendingTelegram.clear();
|
|
905
|
+
lastForwardedBySession.clear();
|
|
576
906
|
try {
|
|
577
|
-
bot?.stop();
|
|
907
|
+
await bot?.stop();
|
|
578
908
|
}
|
|
579
909
|
catch { }
|
|
580
910
|
botReady = false;
|
|
581
911
|
botStarting = false;
|
|
582
912
|
bot = null;
|
|
913
|
+
savedToken = null;
|
|
583
914
|
},
|
|
584
915
|
};
|
|
585
916
|
};
|