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.
- package/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- 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
|
+
|