@grinev/opencode-telegram-bot 0.1.0-rc.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.
Files changed (66) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/dist/agent/manager.js +92 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +26 -0
  7. package/dist/bot/commands/agent.js +16 -0
  8. package/dist/bot/commands/definitions.js +20 -0
  9. package/dist/bot/commands/help.js +7 -0
  10. package/dist/bot/commands/model.js +16 -0
  11. package/dist/bot/commands/models.js +37 -0
  12. package/dist/bot/commands/new.js +58 -0
  13. package/dist/bot/commands/opencode-start.js +87 -0
  14. package/dist/bot/commands/opencode-stop.js +46 -0
  15. package/dist/bot/commands/projects.js +104 -0
  16. package/dist/bot/commands/server-restart.js +23 -0
  17. package/dist/bot/commands/server-start.js +23 -0
  18. package/dist/bot/commands/sessions.js +240 -0
  19. package/dist/bot/commands/start.js +40 -0
  20. package/dist/bot/commands/status.js +63 -0
  21. package/dist/bot/commands/stop.js +92 -0
  22. package/dist/bot/handlers/agent.js +96 -0
  23. package/dist/bot/handlers/context.js +112 -0
  24. package/dist/bot/handlers/model.js +115 -0
  25. package/dist/bot/handlers/permission.js +158 -0
  26. package/dist/bot/handlers/question.js +294 -0
  27. package/dist/bot/handlers/variant.js +126 -0
  28. package/dist/bot/index.js +573 -0
  29. package/dist/bot/middleware/auth.js +30 -0
  30. package/dist/bot/utils/keyboard.js +66 -0
  31. package/dist/cli/args.js +97 -0
  32. package/dist/cli.js +90 -0
  33. package/dist/config.js +46 -0
  34. package/dist/index.js +26 -0
  35. package/dist/keyboard/manager.js +171 -0
  36. package/dist/keyboard/types.js +1 -0
  37. package/dist/model/manager.js +123 -0
  38. package/dist/model/types.js +26 -0
  39. package/dist/opencode/client.js +13 -0
  40. package/dist/opencode/events.js +79 -0
  41. package/dist/opencode/server.js +104 -0
  42. package/dist/permission/manager.js +78 -0
  43. package/dist/permission/types.js +1 -0
  44. package/dist/pinned/manager.js +610 -0
  45. package/dist/pinned/types.js +1 -0
  46. package/dist/pinned-message/service.js +54 -0
  47. package/dist/process/manager.js +273 -0
  48. package/dist/process/types.js +1 -0
  49. package/dist/project/manager.js +28 -0
  50. package/dist/question/manager.js +143 -0
  51. package/dist/question/types.js +1 -0
  52. package/dist/runtime/bootstrap.js +278 -0
  53. package/dist/runtime/mode.js +74 -0
  54. package/dist/runtime/paths.js +37 -0
  55. package/dist/session/manager.js +10 -0
  56. package/dist/session/state.js +24 -0
  57. package/dist/settings/manager.js +99 -0
  58. package/dist/status/formatter.js +44 -0
  59. package/dist/summary/aggregator.js +427 -0
  60. package/dist/summary/formatter.js +226 -0
  61. package/dist/utils/formatting.js +237 -0
  62. package/dist/utils/logger.js +59 -0
  63. package/dist/utils/safe-background-task.js +33 -0
  64. package/dist/variant/manager.js +103 -0
  65. package/dist/variant/types.js +1 -0
  66. package/package.json +63 -0
@@ -0,0 +1,610 @@
1
+ import { logger } from "../utils/logger.js";
2
+ import { opencodeClient } from "../opencode/client.js";
3
+ import { getCurrentSession } from "../session/manager.js";
4
+ import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
5
+ import { getStoredModel } from "../model/manager.js";
6
+ class PinnedMessageManager {
7
+ api = null;
8
+ chatId = null;
9
+ state = {
10
+ messageId: null,
11
+ chatId: null,
12
+ sessionId: null,
13
+ sessionTitle: "new session",
14
+ projectName: "",
15
+ tokensUsed: 0,
16
+ tokensLimit: 0,
17
+ lastUpdated: 0,
18
+ changedFiles: [],
19
+ };
20
+ contextLimit = null;
21
+ onKeyboardUpdateCallback;
22
+ /**
23
+ * Initialize manager with bot API and chat ID
24
+ */
25
+ initialize(api, chatId) {
26
+ this.api = api;
27
+ this.chatId = chatId;
28
+ // Restore pinned message ID from settings
29
+ const savedMessageId = getPinnedMessageId();
30
+ if (savedMessageId) {
31
+ this.state.messageId = savedMessageId;
32
+ this.state.chatId = chatId;
33
+ }
34
+ }
35
+ /**
36
+ * Called when session changes - create new pinned message
37
+ */
38
+ async onSessionChange(sessionId, sessionTitle) {
39
+ logger.info(`[PinnedManager] Session changed: ${sessionId}, title: ${sessionTitle}`);
40
+ // Reset tokens for new session
41
+ this.state.tokensUsed = 0;
42
+ // Update state
43
+ this.state.sessionId = sessionId;
44
+ this.state.sessionTitle = sessionTitle || "new session";
45
+ const project = getCurrentProject();
46
+ this.state.projectName =
47
+ project?.name || this.extractProjectName(project?.worktree) || "Unknown";
48
+ // Fetch context limit for current model
49
+ await this.fetchContextLimit();
50
+ // Trigger keyboard update callback with reset context (0 tokens)
51
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
52
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
53
+ }
54
+ // Reset changed files for new session
55
+ this.state.changedFiles = [];
56
+ // Unpin old message and create new one
57
+ await this.unpinOldMessage();
58
+ await this.createPinnedMessage();
59
+ // Load existing diffs from API (for session restoration)
60
+ await this.loadDiffsFromApi(sessionId);
61
+ }
62
+ /**
63
+ * Called when session title is updated (after first message)
64
+ */
65
+ async onSessionTitleUpdate(newTitle) {
66
+ if (this.state.sessionTitle !== newTitle && newTitle) {
67
+ logger.debug(`[PinnedManager] Session title updated: ${newTitle}`);
68
+ this.state.sessionTitle = newTitle;
69
+ await this.updatePinnedMessage();
70
+ }
71
+ }
72
+ /**
73
+ * Load context token usage from session history
74
+ */
75
+ async loadContextFromHistory(sessionId, directory) {
76
+ try {
77
+ logger.debug(`[PinnedManager] Loading context from history for session: ${sessionId}`);
78
+ const { data: messagesData, error } = await opencodeClient.session.messages({
79
+ sessionID: sessionId,
80
+ directory,
81
+ });
82
+ if (error || !messagesData) {
83
+ logger.warn("[PinnedManager] Failed to load session history:", error);
84
+ return;
85
+ }
86
+ // Get the maximum context size from session history
87
+ // Context = input + cache.read (cache.read contains previously cached context)
88
+ let maxContextSize = 0;
89
+ logger.debug(`[PinnedManager] Processing ${messagesData.length} messages from history`);
90
+ messagesData.forEach(({ info }) => {
91
+ if (info.role === "assistant") {
92
+ const assistantInfo = info;
93
+ // Skip summary messages (technical, not real agent responses)
94
+ if (assistantInfo.summary) {
95
+ logger.debug(`[PinnedManager] Skipping summary message`);
96
+ return;
97
+ }
98
+ const input = assistantInfo.tokens?.input || 0;
99
+ const cacheRead = assistantInfo.tokens?.cache?.read || 0;
100
+ const contextSize = input + cacheRead;
101
+ logger.debug(`[PinnedManager] Assistant message: input=${input}, cache.read=${cacheRead}, total=${contextSize}`);
102
+ // Keep track of maximum context size (peak usage in session)
103
+ if (contextSize > maxContextSize) {
104
+ maxContextSize = contextSize;
105
+ }
106
+ }
107
+ });
108
+ this.state.tokensUsed = maxContextSize;
109
+ this.state.sessionId = sessionId;
110
+ logger.info(`[PinnedManager] Loaded context from history: ${this.state.tokensUsed} tokens`);
111
+ await this.updatePinnedMessage();
112
+ }
113
+ catch (err) {
114
+ logger.error("[PinnedManager] Error loading context from history:", err);
115
+ }
116
+ }
117
+ /**
118
+ * Called when session is compacted - reload context from history
119
+ */
120
+ async onSessionCompacted(sessionId, directory) {
121
+ logger.info(`[PinnedManager] Session compacted, reloading context: ${sessionId}`);
122
+ // Reload context from updated history (after compaction)
123
+ await this.loadContextFromHistory(sessionId, directory);
124
+ }
125
+ /**
126
+ * Called when assistant message completes with token info
127
+ */
128
+ async onMessageComplete(tokens) {
129
+ // Ensure context limit is available even if session was restored
130
+ // without a fresh onSessionChange call (for example after /stop + continue).
131
+ if (this.getContextLimit() === 0) {
132
+ await this.fetchContextLimit();
133
+ }
134
+ // Context = input + cache.read (cache.read contains previously cached context)
135
+ // This represents the actual context window usage
136
+ this.state.tokensUsed = tokens.input + tokens.cacheRead;
137
+ logger.debug(`[PinnedManager] Tokens updated: ${this.state.tokensUsed}/${this.state.tokensLimit}`);
138
+ // Also fetch latest session title (it may have changed after first message)
139
+ await this.refreshSessionTitle();
140
+ await this.updatePinnedMessage();
141
+ }
142
+ /**
143
+ * Set callback for keyboard updates when context changes
144
+ */
145
+ setOnKeyboardUpdate(callback) {
146
+ this.onKeyboardUpdateCallback = callback;
147
+ logger.debug("[PinnedManager] Keyboard update callback registered");
148
+ }
149
+ /**
150
+ * Get current context information
151
+ */
152
+ getContextInfo() {
153
+ // Use cached contextLimit if tokensLimit is not set yet
154
+ const limit = this.state.tokensLimit > 0 ? this.state.tokensLimit : this.contextLimit || 0;
155
+ if (limit === 0) {
156
+ return null;
157
+ }
158
+ return {
159
+ tokensUsed: this.state.tokensUsed,
160
+ tokensLimit: limit,
161
+ };
162
+ }
163
+ /**
164
+ * Get context limit (for keyboard display when no session)
165
+ * Returns cached limit or 0 if not available
166
+ */
167
+ getContextLimit() {
168
+ return this.contextLimit || this.state.tokensLimit || 0;
169
+ }
170
+ /**
171
+ * Refresh context limit for current model (call after model change)
172
+ */
173
+ async refreshContextLimit() {
174
+ await this.fetchContextLimit();
175
+ }
176
+ /**
177
+ * Called when session.diff SSE event is received.
178
+ * Only overwrites if non-empty (API may return empty while tool events collected data).
179
+ */
180
+ async onSessionDiff(diffs) {
181
+ if (diffs.length === 0 && this.state.changedFiles.length > 0) {
182
+ logger.debug("[PinnedManager] Ignoring empty session.diff, keeping tool-collected data");
183
+ return;
184
+ }
185
+ this.state.changedFiles = diffs;
186
+ logger.debug(`[PinnedManager] Session diff updated: ${diffs.length} files`);
187
+ await this.updatePinnedMessage();
188
+ }
189
+ /**
190
+ * Called when a single file is changed (from tool events: edit/write)
191
+ */
192
+ addFileChange(change) {
193
+ const existing = this.state.changedFiles.find((f) => f.file === change.file);
194
+ if (existing) {
195
+ existing.additions += change.additions;
196
+ existing.deletions += change.deletions;
197
+ }
198
+ else {
199
+ this.state.changedFiles.push(change);
200
+ }
201
+ logger.debug(`[PinnedManager] File change added: ${change.file} (+${change.additions} -${change.deletions}), total: ${this.state.changedFiles.length}`);
202
+ // Schedule debounced update (avoid spamming Telegram API on rapid tool events)
203
+ this.scheduleDebouncedUpdate();
204
+ }
205
+ updateDebounceTimer = null;
206
+ scheduleDebouncedUpdate() {
207
+ if (this.updateDebounceTimer) {
208
+ clearTimeout(this.updateDebounceTimer);
209
+ }
210
+ this.updateDebounceTimer = setTimeout(() => {
211
+ this.updateDebounceTimer = null;
212
+ this.updatePinnedMessage();
213
+ }, 500);
214
+ }
215
+ /**
216
+ * Load file diffs from API for current session.
217
+ * Tries session.diff() first, falls back to parsing session.messages() tool parts.
218
+ */
219
+ async loadDiffsFromApi(sessionId) {
220
+ try {
221
+ const project = getCurrentProject();
222
+ if (!project) {
223
+ logger.debug("[PinnedManager] loadDiffsFromApi: no project");
224
+ return;
225
+ }
226
+ logger.debug(`[PinnedManager] loadDiffsFromApi: trying session.diff() for ${sessionId}`);
227
+ // Try session.diff() API first
228
+ const { data, error } = await opencodeClient.session.diff({
229
+ sessionID: sessionId,
230
+ directory: project.worktree,
231
+ });
232
+ logger.debug(`[PinnedManager] session.diff() result: error=${!!error}, data.length=${data?.length ?? 0}`);
233
+ if (!error && data && data.length > 0) {
234
+ this.state.changedFiles = data.map((d) => ({
235
+ file: d.file,
236
+ additions: d.additions,
237
+ deletions: d.deletions,
238
+ }));
239
+ logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from session.diff()`);
240
+ await this.updatePinnedMessage();
241
+ return;
242
+ }
243
+ // Fallback: parse tool parts from session messages
244
+ logger.debug("[PinnedManager] session.diff() empty, trying loadDiffsFromMessages()");
245
+ await this.loadDiffsFromMessages(sessionId, project.worktree);
246
+ }
247
+ catch (err) {
248
+ logger.debug("[PinnedManager] Could not load diffs from API:", err);
249
+ }
250
+ }
251
+ /**
252
+ * Fallback: extract file changes from session message tool parts
253
+ */
254
+ async loadDiffsFromMessages(sessionId, directory) {
255
+ try {
256
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: fetching messages for ${sessionId}`);
257
+ const { data: messagesData, error } = await opencodeClient.session.messages({
258
+ sessionID: sessionId,
259
+ directory,
260
+ });
261
+ if (error || !messagesData) {
262
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: error or no data`);
263
+ return;
264
+ }
265
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
266
+ const filesMap = new Map();
267
+ let toolCount = 0;
268
+ let editWriteCount = 0;
269
+ for (const { parts } of messagesData) {
270
+ for (const part of parts) {
271
+ if (part.type !== "tool")
272
+ continue;
273
+ toolCount++;
274
+ const toolPart = part;
275
+ if (toolPart.state.status !== "completed")
276
+ continue;
277
+ if (toolPart.tool === "edit" || toolPart.tool === "write") {
278
+ editWriteCount++;
279
+ }
280
+ if (toolPart.tool === "edit" &&
281
+ toolPart.state.metadata &&
282
+ "filediff" in toolPart.state.metadata) {
283
+ const filediff = toolPart.state.metadata.filediff;
284
+ if (filediff.file) {
285
+ const existing = filesMap.get(filediff.file);
286
+ if (existing) {
287
+ existing.additions += filediff.additions || 0;
288
+ existing.deletions += filediff.deletions || 0;
289
+ }
290
+ else {
291
+ filesMap.set(filediff.file, {
292
+ file: filediff.file,
293
+ additions: filediff.additions || 0,
294
+ deletions: filediff.deletions || 0,
295
+ });
296
+ }
297
+ }
298
+ }
299
+ else if (toolPart.tool === "write" &&
300
+ toolPart.state.input &&
301
+ "filePath" in toolPart.state.input &&
302
+ "content" in toolPart.state.input) {
303
+ const filePath = toolPart.state.input.filePath;
304
+ const content = toolPart.state.input.content;
305
+ const lines = content.split("\n").length;
306
+ const existing = filesMap.get(filePath);
307
+ if (existing) {
308
+ existing.additions += lines;
309
+ }
310
+ else {
311
+ filesMap.set(filePath, {
312
+ file: filePath,
313
+ additions: lines,
314
+ deletions: 0,
315
+ });
316
+ }
317
+ }
318
+ }
319
+ }
320
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${editWriteCount} edit/write`);
321
+ if (filesMap.size > 0) {
322
+ this.state.changedFiles = Array.from(filesMap.values());
323
+ logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from messages`);
324
+ await this.updatePinnedMessage();
325
+ }
326
+ else {
327
+ logger.debug("[PinnedManager] loadDiffsFromMessages: no file changes found");
328
+ }
329
+ }
330
+ catch (err) {
331
+ logger.debug("[PinnedManager] Could not load diffs from messages:", err);
332
+ }
333
+ }
334
+ /**
335
+ * Refresh session title from API
336
+ */
337
+ async refreshSessionTitle() {
338
+ const session = getCurrentSession();
339
+ const project = getCurrentProject();
340
+ if (!session || !project) {
341
+ return;
342
+ }
343
+ try {
344
+ const { data: sessionData } = await opencodeClient.session.get({
345
+ sessionID: session.id,
346
+ directory: project.worktree,
347
+ });
348
+ if (sessionData && sessionData.title !== this.state.sessionTitle) {
349
+ this.state.sessionTitle = sessionData.title;
350
+ logger.debug(`[PinnedManager] Session title refreshed: ${sessionData.title}`);
351
+ }
352
+ }
353
+ catch (err) {
354
+ logger.debug("[PinnedManager] Could not refresh session title:", err);
355
+ }
356
+ }
357
+ /**
358
+ * Extract project name from worktree path
359
+ */
360
+ extractProjectName(worktree) {
361
+ if (!worktree)
362
+ return "";
363
+ // Get last part of path
364
+ const parts = worktree.replace(/\\/g, "/").split("/");
365
+ return parts[parts.length - 1] || "";
366
+ }
367
+ /**
368
+ * Make file path relative to project worktree
369
+ */
370
+ makeRelativePath(filePath) {
371
+ const normalized = filePath.replace(/\\/g, "/");
372
+ const project = getCurrentProject();
373
+ if (project?.worktree) {
374
+ const worktree = project.worktree.replace(/\\/g, "/");
375
+ if (normalized.startsWith(worktree)) {
376
+ // Remove worktree prefix and leading slash
377
+ let relative = normalized.slice(worktree.length);
378
+ if (relative.startsWith("/")) {
379
+ relative = relative.slice(1);
380
+ }
381
+ return relative || normalized;
382
+ }
383
+ }
384
+ // Fallback: just show last 3 segments if path is still absolute
385
+ const segments = normalized.split("/");
386
+ if (segments.length <= 3)
387
+ return normalized;
388
+ return ".../" + segments.slice(-3).join("/");
389
+ }
390
+ /**
391
+ * Fetch context limit from current model configuration
392
+ */
393
+ async fetchContextLimit() {
394
+ try {
395
+ const model = getStoredModel();
396
+ if (!model.providerID || !model.modelID) {
397
+ logger.warn("[PinnedManager] No model configured, using default limit");
398
+ this.contextLimit = 200000;
399
+ this.state.tokensLimit = this.contextLimit;
400
+ return;
401
+ }
402
+ const { data: providersData, error } = await opencodeClient.config.providers();
403
+ if (error || !providersData) {
404
+ logger.warn("[PinnedManager] Failed to fetch providers, using default limit");
405
+ this.contextLimit = 200000;
406
+ this.state.tokensLimit = this.contextLimit;
407
+ return;
408
+ }
409
+ // Find the model in providers
410
+ for (const provider of providersData.providers) {
411
+ if (provider.id === model.providerID) {
412
+ const modelInfo = provider.models[model.modelID];
413
+ if (modelInfo?.limit?.context) {
414
+ this.contextLimit = modelInfo.limit.context;
415
+ this.state.tokensLimit = this.contextLimit;
416
+ logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
417
+ return;
418
+ }
419
+ }
420
+ }
421
+ logger.warn("[PinnedManager] Model not found in providers, using default limit");
422
+ this.contextLimit = 200000;
423
+ this.state.tokensLimit = this.contextLimit;
424
+ }
425
+ catch (err) {
426
+ logger.error("[PinnedManager] Error fetching context limit:", err);
427
+ this.contextLimit = 200000;
428
+ this.state.tokensLimit = this.contextLimit;
429
+ }
430
+ }
431
+ /**
432
+ * Format the pinned message text
433
+ */
434
+ formatMessage() {
435
+ const percentage = this.state.tokensLimit > 0
436
+ ? Math.round((this.state.tokensUsed / this.state.tokensLimit) * 100)
437
+ : 0;
438
+ const tokensFormatted = this.formatTokenCount(this.state.tokensUsed);
439
+ const limitFormatted = this.formatTokenCount(this.state.tokensLimit);
440
+ // Get current model info
441
+ const currentModel = getStoredModel();
442
+ const modelName = currentModel.providerID && currentModel.modelID
443
+ ? `${currentModel.providerID}/${currentModel.modelID}`
444
+ : "Unknown";
445
+ const lines = [
446
+ `${this.state.sessionTitle}`,
447
+ `Project: ${this.state.projectName}`,
448
+ `Model: ${modelName}`,
449
+ `Context: ${tokensFormatted} / ${limitFormatted} (${percentage}%)`,
450
+ ];
451
+ if (this.state.changedFiles.length > 0) {
452
+ const maxFiles = 10;
453
+ const total = this.state.changedFiles.length;
454
+ const filesToShow = this.state.changedFiles.slice(0, maxFiles);
455
+ lines.push("");
456
+ lines.push(`Files (${total}):`);
457
+ for (const f of filesToShow) {
458
+ const relativePath = this.makeRelativePath(f.file);
459
+ const parts = [];
460
+ if (f.additions > 0)
461
+ parts.push(`+${f.additions}`);
462
+ if (f.deletions > 0)
463
+ parts.push(`-${f.deletions}`);
464
+ const diffStr = parts.length > 0 ? ` (${parts.join(" ")})` : "";
465
+ lines.push(` ${relativePath}${diffStr}`);
466
+ }
467
+ if (total > maxFiles) {
468
+ lines.push(` ... and ${total - maxFiles} more`);
469
+ }
470
+ }
471
+ return lines.join("\n");
472
+ }
473
+ /**
474
+ * Format token count (e.g., 150000 -> "150K")
475
+ */
476
+ formatTokenCount(count) {
477
+ if (count >= 1000000) {
478
+ return `${(count / 1000000).toFixed(1)}M`;
479
+ }
480
+ else if (count >= 1000) {
481
+ return `${Math.round(count / 1000)}K`;
482
+ }
483
+ return count.toString();
484
+ }
485
+ /**
486
+ * Create and pin a new status message
487
+ */
488
+ async createPinnedMessage() {
489
+ if (!this.api || !this.chatId) {
490
+ logger.warn("[PinnedManager] API or chatId not initialized");
491
+ return;
492
+ }
493
+ try {
494
+ const text = this.formatMessage();
495
+ // Send new message
496
+ const sentMessage = await this.api.sendMessage(this.chatId, text);
497
+ this.state.messageId = sentMessage.message_id;
498
+ this.state.chatId = this.chatId;
499
+ this.state.lastUpdated = Date.now();
500
+ // Save to settings for persistence
501
+ setPinnedMessageId(sentMessage.message_id);
502
+ // Pin the message (silently)
503
+ await this.api.pinChatMessage(this.chatId, sentMessage.message_id, {
504
+ disable_notification: true,
505
+ });
506
+ logger.info(`[PinnedManager] Created and pinned message: ${sentMessage.message_id}`);
507
+ }
508
+ catch (err) {
509
+ logger.error("[PinnedManager] Error creating pinned message:", err);
510
+ }
511
+ }
512
+ /**
513
+ * Update existing pinned message text
514
+ */
515
+ async updatePinnedMessage() {
516
+ if (!this.api || !this.chatId || !this.state.messageId) {
517
+ return;
518
+ }
519
+ try {
520
+ const text = this.formatMessage();
521
+ await this.api.editMessageText(this.chatId, this.state.messageId, text);
522
+ this.state.lastUpdated = Date.now();
523
+ logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
524
+ // Trigger keyboard update callback
525
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
526
+ setImmediate(() => {
527
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
528
+ });
529
+ }
530
+ }
531
+ catch (err) {
532
+ // Handle "message is not modified" error silently
533
+ if (err instanceof Error && err.message.includes("message is not modified")) {
534
+ return;
535
+ }
536
+ // Handle "message to edit not found" - recreate
537
+ if (err instanceof Error && err.message.includes("message to edit not found")) {
538
+ logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
539
+ this.state.messageId = null;
540
+ clearPinnedMessageId();
541
+ await this.createPinnedMessage();
542
+ return;
543
+ }
544
+ logger.error("[PinnedManager] Error updating pinned message:", err);
545
+ }
546
+ }
547
+ /**
548
+ * Unpin old message before creating new one
549
+ */
550
+ async unpinOldMessage() {
551
+ if (!this.api || !this.chatId) {
552
+ return;
553
+ }
554
+ try {
555
+ // Unpin all messages (ensures clean state)
556
+ await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
557
+ this.state.messageId = null;
558
+ clearPinnedMessageId();
559
+ logger.debug("[PinnedManager] Unpinned old messages");
560
+ }
561
+ catch (err) {
562
+ logger.error("[PinnedManager] Error unpinning messages:", err);
563
+ }
564
+ }
565
+ /**
566
+ * Get current state (for debugging/status)
567
+ */
568
+ getState() {
569
+ return { ...this.state };
570
+ }
571
+ /**
572
+ * Check if manager is initialized
573
+ */
574
+ isInitialized() {
575
+ return this.api !== null && this.chatId !== null;
576
+ }
577
+ /**
578
+ * Clear pinned message (when switching projects)
579
+ */
580
+ async clear() {
581
+ if (!this.api || !this.chatId) {
582
+ // Just reset state if not initialized
583
+ this.state.messageId = null;
584
+ this.state.sessionId = null;
585
+ this.state.tokensUsed = 0;
586
+ this.state.tokensLimit = 0;
587
+ this.state.changedFiles = [];
588
+ clearPinnedMessageId();
589
+ return;
590
+ }
591
+ try {
592
+ // Unpin all messages
593
+ await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
594
+ // Reset state
595
+ this.state.messageId = null;
596
+ this.state.sessionId = null;
597
+ this.state.sessionTitle = "new session";
598
+ this.state.projectName = "";
599
+ this.state.tokensUsed = 0;
600
+ this.state.tokensLimit = 0;
601
+ this.state.changedFiles = [];
602
+ clearPinnedMessageId();
603
+ logger.info("[PinnedManager] Cleared pinned message state");
604
+ }
605
+ catch (err) {
606
+ logger.error("[PinnedManager] Error clearing pinned message:", err);
607
+ }
608
+ }
609
+ }
610
+ export const pinnedMessageManager = new PinnedMessageManager();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { getPinnedMessageId, setPinnedMessageId, setChatId, clearPinnedMessageId, } from "../settings/manager.js";
2
+ import { formatStatus } from "../status/formatter.js";
3
+ export async function createPinnedMessage(ctx, sessionInfo, agent, model, todos = [], projectName = "Неизвестно", lastUpdated = new Date()) {
4
+ await unpinPrevious(ctx);
5
+ const text = formatPinnedMessage(sessionInfo.title, agent, model, 0, todos, projectName, lastUpdated);
6
+ const message = await ctx.reply(text, { parse_mode: "HTML" });
7
+ if (message && message.message_id) {
8
+ await ctx.api.pinChatMessage(ctx.chat.id, message.message_id);
9
+ setPinnedMessageId(message.message_id);
10
+ setChatId(ctx.chat.id);
11
+ }
12
+ }
13
+ export async function unpinPrevious(ctx) {
14
+ const pinnedMessageId = getPinnedMessageId();
15
+ if (!pinnedMessageId) {
16
+ return;
17
+ }
18
+ try {
19
+ await ctx.api.unpinChatMessage(ctx.chat.id, pinnedMessageId);
20
+ clearPinnedMessageId();
21
+ }
22
+ catch (error) {
23
+ console.error("Error unpinning previous message:", error);
24
+ }
25
+ }
26
+ export async function updatePinnedMessage(ctx, sessionTitle, agent, model, contextPercentage, todos, projectName = "Неизвестно", lastUpdated = new Date()) {
27
+ const pinnedMessageId = getPinnedMessageId();
28
+ if (!pinnedMessageId) {
29
+ return;
30
+ }
31
+ try {
32
+ const text = formatPinnedMessage(sessionTitle, agent, model, contextPercentage, todos, projectName, lastUpdated);
33
+ await ctx.api.editMessageText(ctx.chat.id, pinnedMessageId, text, {
34
+ parse_mode: "HTML",
35
+ });
36
+ }
37
+ catch (error) {
38
+ console.error("Error updating pinned message:", error);
39
+ }
40
+ }
41
+ function formatPinnedMessage(sessionTitle, agent, model, contextPercentage, todos, projectName = "Неизвестно", lastUpdated = new Date()) {
42
+ const contextUsed = contextPercentage;
43
+ const contextMax = 100000;
44
+ return formatStatus({
45
+ projectName,
46
+ sessionTitle,
47
+ agent,
48
+ model,
49
+ contextUsed,
50
+ contextMax,
51
+ todos,
52
+ lastUpdated,
53
+ });
54
+ }