talon-agent 1.0.0

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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,546 @@
1
+ /**
2
+ * GramJS user client for accessing Telegram features unavailable to bots:
3
+ * - Full message history search and retrieval
4
+ * - Group member enumeration
5
+ * - Message search across chats
6
+ *
7
+ * Requires a one-time phone login to create a session file.
8
+ * After that, runs headless alongside the bot.
9
+ */
10
+
11
+ import { TelegramClient, Api } from "telegram";
12
+ import { StringSession } from "telegram/sessions/index.js";
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { resolve, dirname } from "node:path";
15
+ import { log, logError, logWarn } from "../../util/log.js";
16
+ import { dirs, files } from "../../util/paths.js";
17
+ import { formatSmartTimestamp } from "../../util/time.js";
18
+
19
+ const SESSION_FILE = files.userSession;
20
+
21
+ let client: TelegramClient | null = null;
22
+ let reconnectTimer: ReturnType<typeof setInterval> | null = null;
23
+ let storedApiId = 0;
24
+ let storedApiHash = "";
25
+
26
+ // ── SECURITY: Chat scope guard ──────────────────────────────────────────────
27
+ // The userbot is ONLY allowed to access chats the bot is actively serving.
28
+ // It must NEVER access the user's other chats, DMs, or account data.
29
+ const allowedChatIds = new Set<number>();
30
+ const MAX_ALLOWED_CHATS = 5_000;
31
+
32
+ /** Allow the userbot to access a specific chat (set when bot receives a message). */
33
+ export function allowChat(chatId: number): void {
34
+ if (allowedChatIds.size >= MAX_ALLOWED_CHATS) {
35
+ // Evict oldest entries (first inserted) to prevent unbounded growth
36
+ const iter = allowedChatIds.values();
37
+ for (let i = 0; i < 500; i++) {
38
+ const val = iter.next();
39
+ if (val.done) break;
40
+ allowedChatIds.delete(val.value);
41
+ }
42
+ }
43
+ allowedChatIds.add(chatId);
44
+ }
45
+
46
+ /** Revoke userbot access for a chat (called when bot is removed from group). */
47
+ export function revokeChat(chatId: number): void {
48
+ allowedChatIds.delete(chatId);
49
+ }
50
+
51
+ function assertAllowedChat(chatId: number | string): number {
52
+ const numeric = typeof chatId === "string" ? parseInt(chatId, 10) : chatId;
53
+ if (!allowedChatIds.has(numeric)) {
54
+ throw new Error(
55
+ "Access denied: userbot can only access chats where the bot is active.",
56
+ );
57
+ }
58
+ return numeric;
59
+ }
60
+
61
+ export function isUserClientReady(): boolean {
62
+ return client !== null && !!client.connected;
63
+ }
64
+
65
+ export async function initUserClient(params: {
66
+ apiId: number;
67
+ apiHash: string;
68
+ }): Promise<boolean> {
69
+ const { apiId, apiHash } = params;
70
+ storedApiId = apiId;
71
+ storedApiHash = apiHash;
72
+
73
+ // Load saved session
74
+ let sessionString = "";
75
+ if (existsSync(SESSION_FILE)) {
76
+ sessionString = readFileSync(SESSION_FILE, "utf-8").trim();
77
+ }
78
+
79
+ const session = new StringSession(sessionString);
80
+ client = new TelegramClient(session, apiId, apiHash, {
81
+ connectionRetries: 5,
82
+ });
83
+
84
+ try {
85
+ await client.connect();
86
+
87
+ if (!(await client.isUserAuthorized())) {
88
+ log("userbot", "Not authorized -- run the login script first.");
89
+ client = null;
90
+ return false;
91
+ }
92
+
93
+ // Save session after successful connect
94
+ const newSession = client.session.save() as unknown as string;
95
+ const dir = dirname(SESSION_FILE);
96
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
97
+ writeFileSync(SESSION_FILE, newSession);
98
+
99
+ log("userbot", "Connected and authorized.");
100
+
101
+ // Start periodic connection health check
102
+ startConnectionMonitor();
103
+
104
+ return true;
105
+ } catch (err) {
106
+ logError("userbot", "Connection failed", err);
107
+ client = null;
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /** Gracefully disconnect the GramJS user client. */
113
+ export async function disconnectUserClient(): Promise<void> {
114
+ stopConnectionMonitor();
115
+ if (client) {
116
+ try {
117
+ await client.disconnect();
118
+ log("userbot", "Disconnected.");
119
+ } catch (err) {
120
+ logError("userbot", "Disconnect error", err);
121
+ }
122
+ client = null;
123
+ }
124
+ }
125
+
126
+ // ── Connection monitoring ────────────────────────────────────────────────────
127
+
128
+ const CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
129
+
130
+ let reconnecting = false;
131
+
132
+ function startConnectionMonitor(): void {
133
+ if (reconnectTimer) return;
134
+ reconnectTimer = setInterval(async () => {
135
+ if (!client) return;
136
+ if (client.connected) return;
137
+ if (reconnecting) return; // prevent overlapping reconnect attempts
138
+ reconnecting = true;
139
+
140
+ logWarn("userbot", "Connection lost, attempting reconnect...");
141
+ try {
142
+ await client.connect();
143
+ if (await client.isUserAuthorized()) {
144
+ log("userbot", "Reconnected successfully.");
145
+ } else {
146
+ logWarn("userbot", "Reconnected but not authorized.");
147
+ }
148
+ } catch (err) {
149
+ logError("userbot", "Reconnect failed", err);
150
+ // Try a full re-init on next check
151
+ if (storedApiId && storedApiHash) {
152
+ try {
153
+ client = null;
154
+ let sessionString = "";
155
+ if (existsSync(SESSION_FILE)) {
156
+ sessionString = readFileSync(SESSION_FILE, "utf-8").trim();
157
+ }
158
+ const session = new StringSession(sessionString);
159
+ client = new TelegramClient(session, storedApiId, storedApiHash, {
160
+ connectionRetries: 5,
161
+ });
162
+ await client.connect();
163
+ if (await client.isUserAuthorized()) {
164
+ log("userbot", "Full re-init reconnect succeeded.");
165
+ }
166
+ } catch (retryErr) {
167
+ logError("userbot", "Full re-init reconnect failed", retryErr);
168
+ client = null;
169
+ }
170
+ }
171
+ } finally {
172
+ reconnecting = false;
173
+ }
174
+ }, CHECK_INTERVAL_MS);
175
+ }
176
+
177
+ function stopConnectionMonitor(): void {
178
+ if (reconnectTimer) {
179
+ clearInterval(reconnectTimer);
180
+ reconnectTimer = null;
181
+ }
182
+ }
183
+
184
+ /** Search messages in a chat by keyword. */
185
+ export async function searchMessages(params: {
186
+ chatId: number | string;
187
+ query: string;
188
+ limit?: number;
189
+ }): Promise<string> {
190
+ if (!client) return "User client not connected. Run login script first.";
191
+
192
+ try {
193
+ const chatId = assertAllowedChat(params.chatId);
194
+ const messages = await client.getMessages(chatId, {
195
+ search: params.query,
196
+ limit: params.limit ?? 20,
197
+ });
198
+
199
+ if (messages.length === 0) return `No messages matching "${params.query}".`;
200
+
201
+ return messages
202
+ .map((m) => {
203
+ const date = formatSmartTimestamp(m.date * 1000);
204
+ const sender =
205
+ m.sender && "firstName" in m.sender
206
+ ? [m.sender.firstName, m.sender.lastName].filter(Boolean).join(" ")
207
+ : "Unknown";
208
+ return `[msg:${m.id} ${date}] ${sender}: ${m.text || "(media)"}`;
209
+ })
210
+ .join("\n");
211
+ } catch (err) {
212
+ return `Search failed: ${err instanceof Error ? err.message : err}`;
213
+ }
214
+ }
215
+
216
+ /** Get message history from a chat. Supports going back in time via offsetDate or offsetId. */
217
+ export async function getHistory(params: {
218
+ chatId: number | string;
219
+ limit?: number;
220
+ offsetId?: number;
221
+ /** ISO date string or unix timestamp to start fetching from (goes backward from this point). */
222
+ before?: string | number;
223
+ }): Promise<string> {
224
+ if (!client) return "User client not connected. Run login script first.";
225
+
226
+ try {
227
+ const chatId = assertAllowedChat(params.chatId);
228
+ const opts: Record<string, unknown> = {
229
+ limit: params.limit ?? 30,
230
+ };
231
+ if (params.offsetId) {
232
+ opts.offsetId = params.offsetId;
233
+ }
234
+ if (params.before) {
235
+ const ts =
236
+ typeof params.before === "string"
237
+ ? Math.floor(new Date(params.before).getTime() / 1000)
238
+ : params.before;
239
+ if (ts > 0) opts.offsetDate = ts;
240
+ }
241
+ const messages = await client.getMessages(chatId, opts);
242
+
243
+ if (messages.length === 0) return "No messages found.";
244
+
245
+ return [...messages]
246
+ .reverse()
247
+ .map((m) => {
248
+ const date = formatSmartTimestamp(m.date * 1000);
249
+ const sender =
250
+ m.sender && "firstName" in m.sender
251
+ ? [m.sender.firstName, m.sender.lastName].filter(Boolean).join(" ")
252
+ : "Unknown";
253
+ const replyTag = m.replyTo?.replyToMsgId
254
+ ? ` (reply to msg:${m.replyTo.replyToMsgId})`
255
+ : "";
256
+ const mediaTag = m.media ? ` [${m.media.className}]` : "";
257
+ return `[msg:${m.id} ${date}] ${sender}${replyTag}${mediaTag}: ${m.text || "(media)"}`;
258
+ })
259
+ .join("\n");
260
+ } catch (err) {
261
+ return `History failed: ${err instanceof Error ? err.message : err}`;
262
+ }
263
+ }
264
+
265
+ /** Get detailed participant info including admin status, join date, etc. */
266
+ export async function getParticipantDetails(params: {
267
+ chatId: number | string;
268
+ limit?: number;
269
+ }): Promise<string> {
270
+ if (!client) return "User client not connected.";
271
+
272
+ try {
273
+ const chatId = assertAllowedChat(params.chatId);
274
+ const participants = await client.getParticipants(chatId, {
275
+ limit: params.limit ?? 50,
276
+ });
277
+
278
+ if (participants.length === 0) return "No participants found.";
279
+
280
+ return participants
281
+ .map((p) => {
282
+ const name =
283
+ [p.firstName, p.lastName].filter(Boolean).join(" ") || "(no name)";
284
+ const username = p.username ? `@${p.username}` : "";
285
+ const bot = p.bot ? " [BOT]" : "";
286
+ const verified = p.verified ? " [verified]" : "";
287
+ const premium = p.premium ? " [premium]" : "";
288
+ const status = (() => {
289
+ const s = p.status;
290
+ if (!s) return "unknown";
291
+ const cn = s.className;
292
+ if (cn === "UserStatusOnline") return "online";
293
+ if (cn === "UserStatusOffline") {
294
+ const off = s as { wasOnline?: number };
295
+ if (off.wasOnline) {
296
+ return `last seen ${formatSmartTimestamp(off.wasOnline * 1000)}`;
297
+ }
298
+ return "offline";
299
+ }
300
+ if (cn === "UserStatusRecently") return "recently";
301
+ if (cn === "UserStatusLastWeek") return "last week";
302
+ if (cn === "UserStatusLastMonth") return "last month";
303
+ return cn;
304
+ })();
305
+
306
+ return `${name}${verified}${premium}${bot} ${username}\n ID: ${p.id} | Status: ${status}`;
307
+ })
308
+ .join("\n\n");
309
+ } catch (err) {
310
+ return `Failed: ${err instanceof Error ? err.message : err}`;
311
+ }
312
+ }
313
+
314
+ /** Get info about a specific user by ID -- only works if they're in an allowed chat. */
315
+ export async function getUserInfo(params: {
316
+ chatId: number | string;
317
+ userId: number;
318
+ }): Promise<string> {
319
+ if (!client) return "User client not connected.";
320
+
321
+ try {
322
+ const chatId = assertAllowedChat(params.chatId);
323
+ // Fetch participants so GramJS caches the user entities for getEntity below
324
+ await client.getParticipants(chatId, { limit: 1, search: "" });
325
+ // getEntity only works for users the client has seen
326
+ const entity = await client.getEntity(params.userId).catch(() => null);
327
+ if (!entity || !("firstName" in entity)) {
328
+ return `User ${params.userId} not found or not accessible.`;
329
+ }
330
+
331
+ const u = entity;
332
+ const name = [u.firstName, u.lastName].filter(Boolean).join(" ");
333
+ const username = u.username ? `@${u.username}` : "(no username)";
334
+ const bot = u.bot ? "Yes" : "No";
335
+ const verified = u.verified ? "Yes" : "No";
336
+ const premium = u.premium ? "Yes" : "No";
337
+ const phone = u.phone ? "(has phone)" : "(no phone visible)";
338
+ const status = (() => {
339
+ const s = u.status;
340
+ if (!s) return "unknown";
341
+ const cn = s.className;
342
+ if (cn === "UserStatusOnline") return "Online";
343
+ if (cn === "UserStatusOffline") {
344
+ const off = s as { wasOnline?: number };
345
+ if (off.wasOnline)
346
+ return `Last seen ${formatSmartTimestamp(off.wasOnline * 1000)}`;
347
+ return "Offline";
348
+ }
349
+ if (cn === "UserStatusRecently") return "Recently";
350
+ if (cn === "UserStatusLastWeek") return "Last week";
351
+ if (cn === "UserStatusLastMonth") return "Last month";
352
+ return cn;
353
+ })();
354
+
355
+ return [
356
+ `Name: ${name}`,
357
+ `Username: ${username}`,
358
+ `ID: ${u.id}`,
359
+ `Status: ${status}`,
360
+ `Bot: ${bot}`,
361
+ `Verified: ${verified}`,
362
+ `Premium: ${premium}`,
363
+ `Phone: ${phone}`,
364
+ ].join("\n");
365
+ } catch (err) {
366
+ return `Failed: ${err instanceof Error ? err.message : err}`;
367
+ }
368
+ }
369
+
370
+ /** Get a specific message by ID. */
371
+ export async function getMessage(params: {
372
+ chatId: number | string;
373
+ messageId: number;
374
+ }): Promise<string> {
375
+ if (!client) return "User client not connected.";
376
+
377
+ try {
378
+ const chatId = assertAllowedChat(params.chatId);
379
+ const messages = await client.getMessages(chatId, {
380
+ ids: [params.messageId],
381
+ });
382
+ const m = messages[0];
383
+ if (!m) return `Message ${params.messageId} not found.`;
384
+
385
+ const date = formatSmartTimestamp(m.date * 1000);
386
+ const sender =
387
+ m.sender && "firstName" in m.sender
388
+ ? [m.sender.firstName, m.sender.lastName].filter(Boolean).join(" ")
389
+ : "Unknown";
390
+ const replyTag = m.replyTo?.replyToMsgId
391
+ ? `\nReply to: msg:${m.replyTo.replyToMsgId}`
392
+ : "";
393
+ const mediaTag = m.media ? `\nMedia: ${m.media.className}` : "";
394
+ return `[msg:${m.id} ${date}] ${sender}${replyTag}${mediaTag}\n${m.text || "(no text)"}`;
395
+ } catch (err) {
396
+ return `Failed: ${err instanceof Error ? err.message : err}`;
397
+ }
398
+ }
399
+
400
+ /** Download media from a message and save to workspace/uploads/. */
401
+ export async function downloadMessageMedia(params: {
402
+ chatId: number | string;
403
+ messageId: number;
404
+ }): Promise<string> {
405
+ if (!client) return "User client not connected.";
406
+
407
+ try {
408
+ const chatId = assertAllowedChat(params.chatId);
409
+ const messages = await client.getMessages(chatId, {
410
+ ids: [params.messageId],
411
+ });
412
+ const m = messages[0];
413
+ if (!m) return `Message ${params.messageId} not found.`;
414
+ if (!m.media) return `Message ${params.messageId} has no media.`;
415
+
416
+ // Download the media using GramJS
417
+ const buffer = (await client.downloadMedia(m.media, {})) as Buffer;
418
+ if (!buffer || buffer.length === 0) return "Download returned empty data.";
419
+
420
+ // Use the original filename if available, otherwise generate one
421
+ const doc = (m.media as { document?: { attributes?: Array<{ fileName?: string }> } }).document;
422
+ const originalName = doc?.attributes?.find((a) => a.fileName)?.fileName;
423
+ const filename = originalName
424
+ ? `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
425
+ : `${Date.now()}-msg${params.messageId}`;
426
+
427
+ // Save to .talon/workspace/uploads/
428
+ const uploadsDir = dirs.uploads;
429
+ if (!existsSync(uploadsDir)) mkdirSync(uploadsDir, { recursive: true });
430
+
431
+ const filePath = resolve(uploadsDir, filename);
432
+ writeFileSync(filePath, buffer);
433
+
434
+ log("userbot", `Downloaded media from msg:${params.messageId} → ${filename} (${buffer.length} bytes)`);
435
+ return `Downloaded to: ${filePath} (${buffer.length} bytes). Use the Read tool on this path to view the content.`;
436
+ } catch (err) {
437
+ return `Download failed: ${err instanceof Error ? err.message : err}`;
438
+ }
439
+ }
440
+
441
+ // ── Sticker pack utilities ────────────────────────────────────────────────────
442
+
443
+ /** Save a sticker pack's file_ids to workspace for quick reuse. */
444
+ export async function saveStickerPack(params: {
445
+ setName: string;
446
+ bot: unknown;
447
+ }): Promise<string> {
448
+ try {
449
+ const bot = params.bot as { api: { getStickerSet: (name: string) => Promise<{ title: string; name: string; stickers: Array<{ emoji?: string; file_id: string }> }> } };
450
+ const stickerSet = await bot.api.getStickerSet(params.setName);
451
+
452
+ const stickers = stickerSet.stickers.map((s) => ({
453
+ emoji: s.emoji ?? "",
454
+ fileId: s.file_id,
455
+ }));
456
+
457
+ const packData = {
458
+ name: stickerSet.name,
459
+ title: stickerSet.title,
460
+ count: stickers.length,
461
+ stickers,
462
+ savedAt: new Date().toISOString(),
463
+ };
464
+
465
+ const dir = dirs.stickers;
466
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
467
+ const filePath = resolve(dir, `${stickerSet.name}.json`);
468
+ writeFileSync(filePath, JSON.stringify(packData, null, 2));
469
+
470
+ return `Saved "${stickerSet.title}" (${stickers.length} stickers) to .talon/workspace/stickers/${stickerSet.name}.json`;
471
+ } catch (err) {
472
+ return `Failed to save sticker pack: ${err instanceof Error ? err.message : err}`;
473
+ }
474
+ }
475
+
476
+ // ── Chat statistics & utility ────────────────────────────────────────────────
477
+
478
+ /** Get detailed chat/group statistics — message counts, top posters, activity. */
479
+ /** Get the pinned message(s) in a chat. */
480
+ export async function getPinnedMessages(params: {
481
+ chatId: number | string;
482
+ }): Promise<string> {
483
+ if (!client) return "User client not connected.";
484
+
485
+ try {
486
+ const chatId = assertAllowedChat(params.chatId);
487
+ const result = await client.invoke(
488
+ new Api.messages.Search({
489
+ peer: chatId,
490
+ q: "",
491
+ filter: new Api.InputMessagesFilterPinned(),
492
+ minDate: 0,
493
+ maxDate: 0,
494
+ offsetId: 0,
495
+ addOffset: 0,
496
+ limit: 10,
497
+ maxId: 0,
498
+ minId: 0,
499
+ hash: BigInt(0) as unknown as import("big-integer").BigInteger,
500
+ }),
501
+ );
502
+
503
+ if (!("messages" in result) || result.messages.length === 0) {
504
+ return "No pinned messages.";
505
+ }
506
+
507
+ const lines = result.messages.map((m) => {
508
+ if (!("message" in m)) return `[msg:${m.id}] (no text)`;
509
+ const date = formatSmartTimestamp(m.date * 1000);
510
+ const text = m.message?.slice(0, 200) ?? "(media only)";
511
+ return `[msg:${m.id} ${date}] ${text}`;
512
+ });
513
+
514
+ return `Pinned messages (${lines.length}):\n${lines.join("\n")}`;
515
+ } catch (err) {
516
+ return `Failed: ${err instanceof Error ? err.message : err}`;
517
+ }
518
+ }
519
+
520
+ /** Get online/recently-active member count for a chat. */
521
+ export async function getOnlineCount(params: {
522
+ chatId: number | string;
523
+ }): Promise<string> {
524
+ if (!client) return "User client not connected.";
525
+
526
+ try {
527
+ const chatId = assertAllowedChat(params.chatId);
528
+ const participants = await client.getParticipants(chatId, { limit: 200 });
529
+
530
+ let online = 0;
531
+ let recently = 0;
532
+ let total = participants.length;
533
+
534
+ for (const p of participants) {
535
+ if (p.bot) continue;
536
+ const status = p.status?.className;
537
+ if (status === "UserStatusOnline") online++;
538
+ else if (status === "UserStatusRecently") recently++;
539
+ }
540
+
541
+ return `Members: ${total} total, ${online} online, ${recently} recently active`;
542
+ } catch (err) {
543
+ return `Failed: ${err instanceof Error ? err.message : err}`;
544
+ }
545
+ }
546
+