kernelbot 1.0.32 → 1.0.34

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/src/bot.js CHANGED
@@ -16,6 +16,33 @@ import { TTSService } from './services/tts.js';
16
16
  import { STTService } from './services/stt.js';
17
17
  import { getClaudeAuthStatus, claudeLogout } from './claude-auth.js';
18
18
 
19
+ /**
20
+ * Simulate a human-like typing delay based on response length.
21
+ * Short replies (casual chat) get a brief pause; longer replies get more.
22
+ * Keeps the typing indicator alive during the delay so the user sees "typing...".
23
+ *
24
+ * @param {TelegramBot} bot - Telegram bot instance
25
+ * @param {number} chatId - Chat to show typing in
26
+ * @param {string} text - The reply text (used to calculate delay)
27
+ * @returns {Promise<void>}
28
+ */
29
+ async function simulateTypingDelay(bot, chatId, text) {
30
+ const length = (text || '').length;
31
+
32
+ // ~25ms per character, clamped between 0.4s and 4s
33
+ // Short "hey ❤️" (~6 chars) → 0.4s | Medium reply (~120 chars) → 3s | Long reply → 4s cap
34
+ const delay = Math.min(4000, Math.max(400, length * 25));
35
+
36
+ // Add a small random jitter (±15%) so it doesn't feel mechanical
37
+ const jitter = delay * (0.85 + Math.random() * 0.3);
38
+ const finalDelay = Math.round(jitter);
39
+
40
+ // Keep the typing indicator alive during the delay
41
+ bot.sendChatAction(chatId, 'typing').catch(() => {});
42
+
43
+ return new Promise((resolve) => setTimeout(resolve, finalDelay));
44
+ }
45
+
19
46
  function splitMessage(text, maxLength = 4096) {
20
47
  if (text.length <= maxLength) return [text];
21
48
 
@@ -35,6 +62,83 @@ function splitMessage(text, maxLength = 4096) {
35
62
  return chunks;
36
63
  }
37
64
 
65
+ /**
66
+ * Create an onUpdate callback that sends or edits Telegram messages.
67
+ * Tries Markdown first, falls back to plain text.
68
+ */
69
+ function createOnUpdate(bot, chatId) {
70
+ return async (update, opts = {}) => {
71
+ if (opts.editMessageId) {
72
+ try {
73
+ const edited = await bot.editMessageText(update, {
74
+ chat_id: chatId,
75
+ message_id: opts.editMessageId,
76
+ parse_mode: 'Markdown',
77
+ });
78
+ return edited.message_id;
79
+ } catch {
80
+ try {
81
+ const edited = await bot.editMessageText(update, {
82
+ chat_id: chatId,
83
+ message_id: opts.editMessageId,
84
+ });
85
+ return edited.message_id;
86
+ } catch {
87
+ // Edit failed — fall through to send new message
88
+ }
89
+ }
90
+ }
91
+ const parts = splitMessage(update);
92
+ let lastMsgId = null;
93
+ for (const part of parts) {
94
+ try {
95
+ const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
96
+ lastMsgId = sent.message_id;
97
+ } catch {
98
+ const sent = await bot.sendMessage(chatId, part);
99
+ lastMsgId = sent.message_id;
100
+ }
101
+ }
102
+ return lastMsgId;
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Create a sendPhoto callback that sends a photo with optional caption.
108
+ * Tries Markdown caption first, falls back to plain caption.
109
+ */
110
+ function createSendPhoto(bot, chatId, logger) {
111
+ return async (filePath, caption) => {
112
+ const fileOpts = { contentType: 'image/png' };
113
+ try {
114
+ await bot.sendPhoto(chatId, createReadStream(filePath), {
115
+ caption: caption || '',
116
+ parse_mode: 'Markdown',
117
+ }, fileOpts);
118
+ } catch {
119
+ try {
120
+ await bot.sendPhoto(chatId, createReadStream(filePath), {
121
+ caption: caption || '',
122
+ }, fileOpts);
123
+ } catch (err) {
124
+ logger.error(`Failed to send photo: ${err.message}`);
125
+ }
126
+ }
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Create a sendReaction callback for reacting to messages with emoji.
132
+ */
133
+ function createSendReaction(bot) {
134
+ return async (targetChatId, targetMsgId, emoji, isBig = false) => {
135
+ await bot.setMessageReaction(targetChatId, targetMsgId, {
136
+ reaction: [{ type: 'emoji', emoji }],
137
+ is_big: isBig,
138
+ });
139
+ };
140
+ }
141
+
38
142
  /**
39
143
  * Simple per-chat queue to serialize agent processing.
40
144
  * Each chat gets its own promise chain so messages are processed in order.
@@ -56,7 +160,13 @@ class ChatQueue {
56
160
  export function startBot(config, agent, conversationManager, jobManager, automationManager, lifeDeps = {}) {
57
161
  const { lifeEngine, memoryManager, journalManager, shareQueue, evolutionTracker, codebaseKnowledge } = lifeDeps;
58
162
  const logger = getLogger();
59
- const bot = new TelegramBot(config.telegram.bot_token, { polling: true });
163
+ const bot = new TelegramBot(config.telegram.bot_token, {
164
+ polling: {
165
+ params: {
166
+ allowed_updates: ['message', 'callback_query', 'message_reaction'],
167
+ },
168
+ },
169
+ });
60
170
  const chatQueue = new ChatQueue();
61
171
  const batchWindowMs = config.telegram.batch_window_ms || 3000;
62
172
 
@@ -113,54 +223,8 @@ export function startBot(config, agent, conversationManager, jobManager, automat
113
223
  const sendAction = (chatId, action) => bot.sendChatAction(chatId, action).catch(() => {});
114
224
 
115
225
  const agentFactory = (chatId) => {
116
- const onUpdate = async (update, opts = {}) => {
117
- if (opts.editMessageId) {
118
- try {
119
- const edited = await bot.editMessageText(update, {
120
- chat_id: chatId,
121
- message_id: opts.editMessageId,
122
- parse_mode: 'Markdown',
123
- });
124
- return edited.message_id;
125
- } catch {
126
- try {
127
- const edited = await bot.editMessageText(update, {
128
- chat_id: chatId,
129
- message_id: opts.editMessageId,
130
- });
131
- return edited.message_id;
132
- } catch {
133
- // Edit failed — fall through to send new message
134
- }
135
- }
136
- }
137
- const parts = splitMessage(update);
138
- let lastMsgId = null;
139
- for (const part of parts) {
140
- try {
141
- const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
142
- lastMsgId = sent.message_id;
143
- } catch {
144
- const sent = await bot.sendMessage(chatId, part);
145
- lastMsgId = sent.message_id;
146
- }
147
- }
148
- return lastMsgId;
149
- };
150
-
151
- const sendPhoto = async (filePath, caption) => {
152
- const fileOpts = { contentType: 'image/png' };
153
- try {
154
- await bot.sendPhoto(chatId, createReadStream(filePath), { caption: caption || '', parse_mode: 'Markdown' }, fileOpts);
155
- } catch {
156
- try {
157
- await bot.sendPhoto(chatId, createReadStream(filePath), { caption: caption || '' }, fileOpts);
158
- } catch (err) {
159
- logger.error(`[Automation] Failed to send photo: ${err.message}`);
160
- }
161
- }
162
- };
163
-
226
+ const onUpdate = createOnUpdate(bot, chatId);
227
+ const sendPhoto = createSendPhoto(bot, chatId, logger);
164
228
  return { agent, onUpdate, sendPhoto };
165
229
  };
166
230
 
@@ -1574,70 +1638,21 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1574
1638
  bot.sendChatAction(chatId, 'typing').catch(() => {});
1575
1639
 
1576
1640
  try {
1577
- const onUpdate = async (update, opts = {}) => {
1578
- // Edit an existing message instead of sending a new one
1579
- if (opts.editMessageId) {
1580
- try {
1581
- const edited = await bot.editMessageText(update, {
1582
- chat_id: chatId,
1583
- message_id: opts.editMessageId,
1584
- parse_mode: 'Markdown',
1585
- });
1586
- return edited.message_id;
1587
- } catch {
1588
- try {
1589
- const edited = await bot.editMessageText(update, {
1590
- chat_id: chatId,
1591
- message_id: opts.editMessageId,
1592
- });
1593
- return edited.message_id;
1594
- } catch {
1595
- // Edit failed — fall through to send new message
1596
- }
1597
- }
1598
- }
1599
-
1600
- // Send new message(s) — also reached when edit fails
1601
- const parts = splitMessage(update);
1602
- let lastMsgId = null;
1603
- for (const part of parts) {
1604
- try {
1605
- const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
1606
- lastMsgId = sent.message_id;
1607
- } catch {
1608
- const sent = await bot.sendMessage(chatId, part);
1609
- lastMsgId = sent.message_id;
1610
- }
1611
- }
1612
- return lastMsgId;
1613
- };
1614
-
1615
- const sendPhoto = async (filePath, caption) => {
1616
- const fileOpts = { contentType: 'image/png' };
1617
- try {
1618
- await bot.sendPhoto(chatId, createReadStream(filePath), {
1619
- caption: caption || '',
1620
- parse_mode: 'Markdown',
1621
- }, fileOpts);
1622
- } catch {
1623
- try {
1624
- await bot.sendPhoto(chatId, createReadStream(filePath), {
1625
- caption: caption || '',
1626
- }, fileOpts);
1627
- } catch (err) {
1628
- logger.error(`Failed to send photo: ${err.message}`);
1629
- }
1630
- }
1631
- };
1641
+ const onUpdate = createOnUpdate(bot, chatId);
1642
+ const sendPhoto = createSendPhoto(bot, chatId, logger);
1643
+ const sendReaction = createSendReaction(bot);
1632
1644
 
1633
1645
  logger.debug(`[Bot] Sending to orchestrator: chat ${chatId}, text="${mergedText.slice(0, 80)}"`);
1634
1646
  const reply = await agent.processMessage(chatId, mergedText, {
1635
1647
  id: userId,
1636
1648
  username,
1637
- }, onUpdate, sendPhoto);
1649
+ }, onUpdate, sendPhoto, { sendReaction, messageId: msg.message_id });
1638
1650
 
1639
1651
  clearInterval(typingInterval);
1640
1652
 
1653
+ // Simulate human-like typing delay before sending the reply
1654
+ await simulateTypingDelay(bot, chatId, reply || '');
1655
+
1641
1656
  logger.info(`[Bot] Reply for chat ${chatId}: ${(reply || '').length} chars`);
1642
1657
  const chunks = splitMessage(reply || 'Done.');
1643
1658
  for (const chunk of chunks) {
@@ -1668,9 +1683,117 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1668
1683
  });
1669
1684
  });
1670
1685
 
1686
+ // Handle message reactions (love, like, etc.)
1687
+ bot.on('message_reaction', async (reaction) => {
1688
+ const chatId = reaction.chat.id;
1689
+ const userId = reaction.user?.id;
1690
+ const username = reaction.user?.username || reaction.user?.first_name || 'unknown';
1691
+
1692
+ if (!userId || !isAllowedUser(userId, config)) return;
1693
+
1694
+ const newReactions = reaction.new_reaction || [];
1695
+ const emojis = newReactions
1696
+ .filter(r => r.type === 'emoji')
1697
+ .map(r => r.emoji);
1698
+
1699
+ if (emojis.length === 0) return;
1700
+
1701
+ logger.info(`[Bot] Reaction from ${username} (${userId}) in chat ${chatId}: ${emojis.join(' ')}`);
1702
+
1703
+ const reactionText = `[User reacted with ${emojis.join(' ')} to your message]`;
1704
+
1705
+ chatQueue.enqueue(chatId, async () => {
1706
+ try {
1707
+ const onUpdate = createOnUpdate(bot, chatId);
1708
+ const sendReaction = createSendReaction(bot);
1709
+
1710
+ const reply = await agent.processMessage(chatId, reactionText, {
1711
+ id: userId,
1712
+ username,
1713
+ }, onUpdate, null, { sendReaction, messageId: reaction.message_id });
1714
+
1715
+ if (reply && reply.trim()) {
1716
+ const chunks = splitMessage(reply);
1717
+ for (const chunk of chunks) {
1718
+ try {
1719
+ await bot.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
1720
+ } catch {
1721
+ await bot.sendMessage(chatId, chunk);
1722
+ }
1723
+ }
1724
+ }
1725
+ } catch (err) {
1726
+ logger.error(`[Bot] Error processing reaction in chat ${chatId}: ${err.message}`);
1727
+ }
1728
+ });
1729
+ });
1730
+
1671
1731
  bot.on('polling_error', (err) => {
1672
1732
  logger.error(`Telegram polling error: ${err.message}`);
1673
1733
  });
1674
1734
 
1735
+ // ── Resume active chats after restart ────────────────────────
1736
+ setTimeout(async () => {
1737
+ const sendMsg = async (chatId, text) => {
1738
+ try {
1739
+ await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
1740
+ } catch {
1741
+ await bot.sendMessage(chatId, text);
1742
+ }
1743
+ };
1744
+ try {
1745
+ await agent.resumeActiveChats(sendMsg);
1746
+ } catch (err) {
1747
+ logger.error(`[Bot] Resume active chats failed: ${err.message}`);
1748
+ }
1749
+ }, 5000);
1750
+
1751
+ // ── Proactive share delivery (randomized, self-rearming) ────
1752
+ const lifeConfig = config.life || {};
1753
+ const quietStart = lifeConfig.quiet_hours?.start ?? 2;
1754
+ const quietEnd = lifeConfig.quiet_hours?.end ?? 6;
1755
+
1756
+ const armShareDelivery = (delivered) => {
1757
+ // If we just delivered something, wait longer (1–4h) before next check
1758
+ // If nothing was delivered, check again sooner (10–45min) in case new shares appear
1759
+ const minMin = delivered ? 60 : 10;
1760
+ const maxMin = delivered ? 240 : 45;
1761
+ const delayMs = (minMin + Math.random() * (maxMin - minMin)) * 60_000;
1762
+
1763
+ logger.debug(`[Bot] Next share check in ${Math.round(delayMs / 60_000)}m`);
1764
+
1765
+ setTimeout(async () => {
1766
+ // Respect quiet hours
1767
+ const hour = new Date().getHours();
1768
+ if (hour >= quietStart && hour < quietEnd) {
1769
+ armShareDelivery(false);
1770
+ return;
1771
+ }
1772
+
1773
+ const sendMsg = async (chatId, text) => {
1774
+ try {
1775
+ await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
1776
+ } catch {
1777
+ await bot.sendMessage(chatId, text);
1778
+ }
1779
+ };
1780
+
1781
+ let didDeliver = false;
1782
+ try {
1783
+ const before = shareQueue ? shareQueue.getPending(null, 1).length : 0;
1784
+ await agent.deliverPendingShares(sendMsg);
1785
+ const after = shareQueue ? shareQueue.getPending(null, 1).length : 0;
1786
+ didDeliver = before > 0 && after < before;
1787
+ } catch (err) {
1788
+ logger.error(`[Bot] Proactive share delivery failed: ${err.message}`);
1789
+ }
1790
+
1791
+ armShareDelivery(didDeliver);
1792
+ }, delayMs);
1793
+ };
1794
+
1795
+ // Start the first check after a random 10–30 min
1796
+ armShareDelivery(false);
1797
+
1675
1798
  return bot;
1676
1799
  }
@@ -1,22 +1,42 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
+ import { getLogger } from './utils/logger.js';
4
5
 
6
+ /**
7
+ * Resolve the file path for persisted conversations.
8
+ * Ensures the parent directory (~/.kernelbot/) exists.
9
+ * @returns {string} Absolute path to conversations.json.
10
+ */
5
11
  function getConversationsPath() {
6
12
  const dir = join(homedir(), '.kernelbot');
7
13
  mkdirSync(dir, { recursive: true });
8
14
  return join(dir, 'conversations.json');
9
15
  }
10
16
 
17
+ /**
18
+ * Manages per-chat conversation history, including persistence to disk,
19
+ * summarization of older messages, and per-chat skill tracking.
20
+ */
11
21
  export class ConversationManager {
22
+ /**
23
+ * @param {object} config - Application config containing `conversation` settings.
24
+ * @param {number} config.conversation.max_history - Maximum messages to retain per chat.
25
+ * @param {number} [config.conversation.recent_window=10] - Number of recent messages kept verbatim in summarized history.
26
+ */
12
27
  constructor(config) {
13
28
  this.maxHistory = config.conversation.max_history;
14
29
  this.recentWindow = config.conversation.recent_window || 10;
15
30
  this.conversations = new Map();
16
31
  this.activeSkills = new Map();
17
32
  this.filePath = getConversationsPath();
33
+ this.logger = getLogger();
18
34
  }
19
35
 
36
+ /**
37
+ * Load persisted conversations and skills from disk.
38
+ * @returns {boolean} True if at least one conversation was restored.
39
+ */
20
40
  load() {
21
41
  if (!existsSync(this.filePath)) return false;
22
42
  try {
@@ -34,12 +54,18 @@ export class ConversationManager {
34
54
  if (chatId === '_skills') continue;
35
55
  this.conversations.set(String(chatId), messages);
36
56
  }
57
+ this.logger.debug(`Conversations loaded: ${this.conversations.size} chats, ${this.activeSkills.size} active skills`);
37
58
  return this.conversations.size > 0;
38
- } catch {
59
+ } catch (err) {
60
+ this.logger.warn(`Failed to load conversations from ${this.filePath}: ${err.message}`);
39
61
  return false;
40
62
  }
41
63
  }
42
64
 
65
+ /**
66
+ * Persist all conversations and active skills to disk.
67
+ * Failures are logged but never thrown to avoid crashing the bot.
68
+ */
43
69
  save() {
44
70
  try {
45
71
  const data = {};
@@ -55,11 +81,16 @@ export class ConversationManager {
55
81
  data._skills = skills;
56
82
  }
57
83
  writeFileSync(this.filePath, JSON.stringify(data, null, 2));
58
- } catch {
59
- // Silent fail don't crash the bot over persistence
84
+ } catch (err) {
85
+ this.logger.warn(`Failed to save conversations: ${err.message}`);
60
86
  }
61
87
  }
62
88
 
89
+ /**
90
+ * Retrieve the message history for a chat, initializing an empty array if none exists.
91
+ * @param {string|number} chatId - Telegram chat identifier.
92
+ * @returns {Array<{role: string, content: string, timestamp?: number}>} Message array (mutable reference).
93
+ */
63
94
  getHistory(chatId) {
64
95
  const key = String(chatId);
65
96
  if (!this.conversations.has(key)) {
@@ -68,6 +99,48 @@ export class ConversationManager {
68
99
  return this.conversations.get(key);
69
100
  }
70
101
 
102
+ /**
103
+ * Get the timestamp of the most recent message in a chat.
104
+ * Used by agent.js for time-gap detection before the current message is added.
105
+ */
106
+ getLastMessageTimestamp(chatId) {
107
+ const history = this.getHistory(chatId);
108
+ if (history.length === 0) return null;
109
+ return history[history.length - 1].timestamp || null;
110
+ }
111
+
112
+ /**
113
+ * Format a timestamp as a relative time marker.
114
+ * Returns null for missing timestamps (backward compat with old messages).
115
+ */
116
+ _formatRelativeTime(ts) {
117
+ if (!ts) return null;
118
+ const diff = Date.now() - ts;
119
+ const seconds = Math.floor(diff / 1000);
120
+ if (seconds < 60) return '[just now]';
121
+ const minutes = Math.floor(seconds / 60);
122
+ if (minutes < 60) return `[${minutes}m ago]`;
123
+ const hours = Math.floor(minutes / 60);
124
+ if (hours < 24) return `[${hours}h ago]`;
125
+ const days = Math.floor(hours / 24);
126
+ return `[${days}d ago]`;
127
+ }
128
+
129
+ /**
130
+ * Return a shallow copy of a message with a time marker prepended to string content.
131
+ * Skips tool_result arrays and messages without timestamps.
132
+ */
133
+ _annotateWithTime(msg) {
134
+ const marker = this._formatRelativeTime(msg.timestamp);
135
+ if (!marker || typeof msg.content !== 'string') return msg;
136
+ return { ...msg, content: `${marker} ${msg.content}` };
137
+ }
138
+
139
+ /** Strip internal metadata fields, returning only API-safe {role, content}. */
140
+ _sanitize(msg) {
141
+ return { role: msg.role, content: msg.content };
142
+ }
143
+
71
144
  /**
72
145
  * Get history with older messages compressed into a summary.
73
146
  * Keeps the last `recentWindow` messages verbatim and summarizes older ones.
@@ -76,18 +149,19 @@ export class ConversationManager {
76
149
  const history = this.getHistory(chatId);
77
150
 
78
151
  if (history.length <= this.recentWindow) {
79
- return [...history];
152
+ return history.map(m => this._sanitize(this._annotateWithTime(m)));
80
153
  }
81
154
 
82
155
  const olderMessages = history.slice(0, history.length - this.recentWindow);
83
156
  const recentMessages = history.slice(history.length - this.recentWindow);
84
157
 
85
- // Compress older messages into a single summary
158
+ // Compress older messages into a single summary (include time markers when available)
86
159
  const summaryLines = olderMessages.map((msg) => {
160
+ const timeTag = this._formatRelativeTime(msg.timestamp);
87
161
  const content = typeof msg.content === 'string'
88
162
  ? msg.content.slice(0, 200)
89
163
  : JSON.stringify(msg.content).slice(0, 200);
90
- return `[${msg.role}]: ${content}`;
164
+ return `[${msg.role}]${timeTag ? ` ${timeTag}` : ''}: ${content}`;
91
165
  });
92
166
 
93
167
  const summaryMessage = {
@@ -95,17 +169,27 @@ export class ConversationManager {
95
169
  content: `[CONVERSATION SUMMARY - ${olderMessages.length} earlier messages]\n${summaryLines.join('\n')}`,
96
170
  };
97
171
 
172
+ // Annotate recent messages with time markers and strip metadata
173
+ const annotatedRecent = recentMessages.map(m => this._sanitize(this._annotateWithTime(m)));
174
+
98
175
  // Ensure result starts with user role
99
- const result = [summaryMessage, ...recentMessages];
176
+ const result = [summaryMessage, ...annotatedRecent];
100
177
 
101
178
  // If the first real message after summary is assistant, that's fine since
102
179
  // our summary is role:user. But ensure recent starts correctly.
103
180
  return result;
104
181
  }
105
182
 
183
+ /**
184
+ * Append a message to a chat's history, trim to max length, and persist.
185
+ * Automatically ensures the conversation starts with a user message.
186
+ * @param {string|number} chatId - Telegram chat identifier.
187
+ * @param {'user'|'assistant'} role - Message role.
188
+ * @param {string} content - Message content.
189
+ */
106
190
  addMessage(chatId, role, content) {
107
191
  const history = this.getHistory(chatId);
108
- history.push({ role, content });
192
+ history.push({ role, content, timestamp: Date.now() });
109
193
 
110
194
  // Trim to max history
111
195
  while (history.length > this.maxHistory) {
@@ -120,31 +204,60 @@ export class ConversationManager {
120
204
  this.save();
121
205
  }
122
206
 
207
+ /**
208
+ * Delete all history and active skill for a specific chat.
209
+ * @param {string|number} chatId - Telegram chat identifier.
210
+ */
123
211
  clear(chatId) {
124
212
  this.conversations.delete(String(chatId));
125
213
  this.activeSkills.delete(String(chatId));
214
+ this.logger.debug(`Conversation cleared for chat ${chatId}`);
126
215
  this.save();
127
216
  }
128
217
 
218
+ /**
219
+ * Delete all conversations across every chat.
220
+ */
129
221
  clearAll() {
222
+ const count = this.conversations.size;
130
223
  this.conversations.clear();
224
+ this.logger.info(`All conversations cleared (${count} chats removed)`);
131
225
  this.save();
132
226
  }
133
227
 
228
+ /**
229
+ * Return the number of messages stored for a chat.
230
+ * @param {string|number} chatId - Telegram chat identifier.
231
+ * @returns {number} Message count.
232
+ */
134
233
  getMessageCount(chatId) {
135
234
  const history = this.getHistory(chatId);
136
235
  return history.length;
137
236
  }
138
237
 
238
+ /**
239
+ * Activate a skill for a specific chat, persisted across restarts.
240
+ * @param {string|number} chatId - Telegram chat identifier.
241
+ * @param {string} skillId - Skill identifier to activate.
242
+ */
139
243
  setSkill(chatId, skillId) {
140
244
  this.activeSkills.set(String(chatId), skillId);
141
245
  this.save();
142
246
  }
143
247
 
248
+ /**
249
+ * Get the currently active skill for a chat.
250
+ * @param {string|number} chatId - Telegram chat identifier.
251
+ * @returns {string|null} Active skill identifier, or null if none.
252
+ */
144
253
  getSkill(chatId) {
145
254
  return this.activeSkills.get(String(chatId)) || null;
146
255
  }
147
256
 
257
+ /**
258
+ * Deactivate the active skill for a chat.
259
+ * @param {string|number} chatId - Telegram chat identifier.
260
+ */
148
261
  clearSkill(chatId) {
149
262
  this.activeSkills.delete(String(chatId));
150
263
  this.save();