@link-assistant/hive-mind 1.15.0 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.15.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 5723a93: fix: prevent early exit when --auto-merge flag is used
8
+
9
+ The `verifyResults()` function was calling `safeExit(0)` before the auto-merge logic could run. This caused the `--auto-merge` flag to be silently ignored. Now the exit condition properly checks for `argv.autoMerge` and `argv.autoRestartUntilMergable` flags.
10
+
11
+ ## 1.15.1
12
+
13
+ ### Patch Changes
14
+
15
+ - docs: Expand auto-cleanup case study with 9 additional solutions (Issue #912)
16
+
17
+ Expanded the case study analysis from 6 to 15 solutions covering:
18
+ - OOM protection (earlyoom, systemd-oomd, OOM score tuning)
19
+ - Resource isolation (cgroups via systemd)
20
+ - Log management (logrotate)
21
+ - Process monitoring (Monit, Supervisord)
22
+ - Event-driven cleanup (incron)
23
+ - Resource watchdog scripts
24
+ - Kubernetes liveness probes and resource limits
25
+
26
+ Added tiered recommendation system (Essential, Recommended, Advanced) and updated implementation guide with steps for earlyoom, OOM score tuning, cgroup limits, and logrotate configuration.
27
+
28
+ Extract message filter functions to testable module with 34 unit tests for message recognition pipeline (issue #1207)
29
+
3
30
  ## 1.15.0
4
31
 
5
32
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "hive-telegram-bot": "./src/telegram-bot.mjs"
14
14
  },
15
15
  "scripts": {
16
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs",
16
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-telegram-message-filters.mjs",
17
17
  "test:queue": "node tests/solve-queue.test.mjs",
18
18
  "test:limits-display": "node tests/limits-display.test.mjs",
19
19
  "test:usage-limit": "node tests/test-usage-limit.mjs",
@@ -688,11 +688,16 @@ Fixes ${issueRef}
688
688
  await log('\n✨ Please review the pull request for the proposed solution draft.');
689
689
  // Don't exit if watch mode is enabled OR if auto-restart is needed for uncommitted changes
690
690
  // Also don't exit if auto-restart-on-non-updated-pull-request-description detected placeholders
691
+ // Issue #1219: Also don't exit if auto-merge or auto-restart-until-mergable is enabled
691
692
  const shouldAutoRestartForPlaceholder = argv.autoRestartOnNonUpdatedPullRequestDescription && (prTitleHasPlaceholder || prBodyHasPlaceholder);
692
693
  if (shouldAutoRestartForPlaceholder) {
693
694
  await log('\n🔄 Placeholder detected in PR title/description - auto-restart will be triggered');
694
695
  }
695
- if (!argv.watch && !shouldRestart && !shouldAutoRestartForPlaceholder) {
696
+ const shouldWaitForAutoMerge = argv.autoMerge || argv.autoRestartUntilMergable;
697
+ if (shouldWaitForAutoMerge) {
698
+ await log('\n🔄 Auto-merge mode enabled - will attempt to merge after verification');
699
+ }
700
+ if (!argv.watch && !shouldRestart && !shouldAutoRestartForPlaceholder && !shouldWaitForAutoMerge) {
696
701
  await safeExit(0, 'Process completed successfully');
697
702
  }
698
703
  // Issue #1154: Return logUploadSuccess to prevent duplicate log uploads
@@ -759,7 +764,9 @@ Fixes ${issueRef}
759
764
  }
760
765
  await log('\n✨ A clarifying comment has been added to the issue.');
761
766
  // Don't exit if watch mode is enabled OR if auto-restart is needed for uncommitted changes
762
- if (!argv.watch && !shouldRestart) {
767
+ // Issue #1219: Also don't exit if auto-merge or auto-restart-until-mergable is enabled
768
+ const shouldWaitForAutoMergeComment = argv.autoMerge || argv.autoRestartUntilMergable;
769
+ if (!argv.watch && !shouldRestart && !shouldWaitForAutoMergeComment) {
763
770
  await safeExit(0, 'Process completed successfully');
764
771
  }
765
772
  // Issue #1154: Return logUploadSuccess to prevent duplicate log uploads
@@ -778,7 +785,9 @@ Fixes ${issueRef}
778
785
  const reviewLogPath = path.resolve(getLogFile());
779
786
  await log(` ${reviewLogPath}`);
780
787
  // Don't exit if watch mode is enabled - it needs to continue monitoring
781
- if (!argv.watch) {
788
+ // Issue #1219: Also don't exit if auto-merge or auto-restart-until-mergable is enabled
789
+ const shouldWaitForAutoMergeNoAction = argv.autoMerge || argv.autoRestartUntilMergable;
790
+ if (!argv.watch && !shouldWaitForAutoMergeNoAction) {
782
791
  await safeExit(0, 'Process completed successfully');
783
792
  }
784
793
  // Issue #1154: Return logUploadSuccess to prevent duplicate log uploads
@@ -795,7 +804,9 @@ Fixes ${issueRef}
795
804
  const checkLogPath = path.resolve(getLogFile());
796
805
  await log(` ${checkLogPath}`);
797
806
  // Don't exit if watch mode is enabled - it needs to continue monitoring
798
- if (!argv.watch) {
807
+ // Issue #1219: Also don't exit if auto-merge or auto-restart-until-mergable is enabled
808
+ const shouldWaitForAutoMergeError = argv.autoMerge || argv.autoRestartUntilMergable;
809
+ if (!argv.watch && !shouldWaitForAutoMergeError) {
799
810
  await safeExit(0, 'Process completed successfully');
800
811
  }
801
812
  // Issue #1154: Return logUploadSuccess to prevent duplicate log uploads
@@ -43,6 +43,8 @@ const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mj
43
43
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
44
44
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
45
45
  const { getSolveQueue, getRunningClaudeProcesses, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
46
+ // Import extracted message filter functions for testability (issue #1207)
47
+ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText } = await import('./telegram-message-filters.lib.mjs');
46
48
 
47
49
  const config = yargs(hideBin(process.argv))
48
50
  .usage('Usage: hive-telegram-bot [options]')
@@ -291,100 +293,22 @@ const bot = new Telegraf(BOT_TOKEN, {
291
293
  // Using Unix timestamp (seconds since epoch) to match Telegram's message.date format
292
294
  const BOT_START_TIME = Math.floor(Date.now() / 1000);
293
295
 
296
+ // Wrapper functions that bind extracted filter functions to bot-specific state
297
+ // The actual logic is in telegram-message-filters.lib.mjs for testability (issue #1207)
294
298
  function isChatAuthorized(chatId) {
295
- if (!allowedChats) {
296
- return true;
297
- }
298
- return allowedChats.includes(chatId);
299
+ return _isChatAuthorized(chatId, allowedChats);
299
300
  }
300
301
 
301
302
  function isOldMessage(ctx) {
302
- // Ignore messages sent before the bot started
303
- // This prevents processing old/pending messages from before current bot instance startup
304
- const messageDate = ctx.message?.date;
305
- if (!messageDate) {
306
- return false;
307
- }
308
- return messageDate < BOT_START_TIME;
303
+ return _isOldMessage(ctx, BOT_START_TIME, { verbose: VERBOSE });
309
304
  }
310
305
 
311
306
  function isGroupChat(ctx) {
312
- const chatType = ctx.chat?.type;
313
- return chatType === 'group' || chatType === 'supergroup';
307
+ return _isGroupChat(ctx);
314
308
  }
315
309
 
316
310
  function isForwardedOrReply(ctx) {
317
- const message = ctx.message;
318
- if (!message) {
319
- if (VERBOSE) {
320
- console.log('[VERBOSE] isForwardedOrReply: No message object');
321
- }
322
- return false;
323
- }
324
-
325
- if (VERBOSE) {
326
- console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
327
- console.log('[VERBOSE] message.forward_origin:', JSON.stringify(message.forward_origin));
328
- console.log('[VERBOSE] message.forward_origin?.type:', message.forward_origin?.type);
329
- console.log('[VERBOSE] message.forward_from:', JSON.stringify(message.forward_from));
330
- console.log('[VERBOSE] message.forward_from_chat:', JSON.stringify(message.forward_from_chat));
331
- console.log('[VERBOSE] message.forward_from_message_id:', message.forward_from_message_id);
332
- console.log('[VERBOSE] message.forward_signature:', message.forward_signature);
333
- console.log('[VERBOSE] message.forward_sender_name:', message.forward_sender_name);
334
- console.log('[VERBOSE] message.forward_date:', message.forward_date);
335
- console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
336
- console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
337
- }
338
-
339
- // Check if message is forwarded (has forward_origin field with actual content)
340
- // Note: We check for .type because Telegram might send empty objects {}
341
- // which are truthy in JavaScript but don't indicate a forwarded message
342
- if (message.forward_origin && message.forward_origin.type) {
343
- if (VERBOSE) {
344
- console.log('[VERBOSE] isForwardedOrReply: TRUE - forward_origin.type exists:', message.forward_origin.type);
345
- }
346
- return true;
347
- }
348
- // Also check old forwarding API fields for backward compatibility
349
- if (message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date) {
350
- if (VERBOSE) {
351
- console.log('[VERBOSE] isForwardedOrReply: TRUE - old forwarding API field detected');
352
- if (message.forward_from) console.log('[VERBOSE] Triggered by: forward_from');
353
- if (message.forward_from_chat) console.log('[VERBOSE] Triggered by: forward_from_chat');
354
- if (message.forward_from_message_id) console.log('[VERBOSE] Triggered by: forward_from_message_id');
355
- if (message.forward_signature) console.log('[VERBOSE] Triggered by: forward_signature');
356
- if (message.forward_sender_name) console.log('[VERBOSE] Triggered by: forward_sender_name');
357
- if (message.forward_date) console.log('[VERBOSE] Triggered by: forward_date');
358
- }
359
- return true;
360
- }
361
- // Check if message is a reply (has reply_to_message field with actual content)
362
- // Note: We check for .message_id because Telegram might send empty objects {}
363
- // IMPORTANT: In forum groups, messages in topics have reply_to_message pointing to the topic's
364
- // first message (with forum_topic_created). These are NOT user replies, just part of the thread.
365
- // We must exclude these to allow commands in forum topics.
366
- if (message.reply_to_message && message.reply_to_message.message_id) {
367
- // If the reply_to_message is a forum topic creation message, this is NOT a user reply
368
- if (message.reply_to_message.forum_topic_created) {
369
- if (VERBOSE) {
370
- console.log('[VERBOSE] isForwardedOrReply: FALSE - reply is to forum topic creation, not user reply');
371
- console.log('[VERBOSE] Forum topic:', message.reply_to_message.forum_topic_created);
372
- }
373
- // This is just a message in a forum topic, not a reply to another user
374
- // Allow the message to proceed
375
- } else {
376
- // This is an actual reply to another user's message
377
- if (VERBOSE) {
378
- console.log('[VERBOSE] isForwardedOrReply: TRUE - reply_to_message.message_id exists:', message.reply_to_message.message_id);
379
- }
380
- return true;
381
- }
382
- }
383
-
384
- if (VERBOSE) {
385
- console.log('[VERBOSE] isForwardedOrReply: FALSE - no forwarding or reply detected');
386
- }
387
- return false;
311
+ return _isForwardedOrReply(ctx, { verbose: VERBOSE });
388
312
  }
389
313
 
390
314
  async function findStartScreenCommand() {
@@ -915,7 +839,8 @@ registerMergeCommand(bot, {
915
839
  addBreadcrumb,
916
840
  });
917
841
 
918
- bot.command(/^solve$/i, async ctx => {
842
+ // Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
843
+ async function handleSolveCommand(ctx) {
919
844
  if (VERBOSE) {
920
845
  console.log('[VERBOSE] /solve command received');
921
846
  }
@@ -1115,9 +1040,12 @@ bot.command(/^solve$/i, async ctx => {
1115
1040
  queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
1116
1041
  if (!solveQueue.executeCallback) solveQueue.executeCallback = createQueueExecuteCallback(executeStartScreen);
1117
1042
  }
1118
- });
1043
+ }
1044
+
1045
+ bot.command(/^solve$/i, handleSolveCommand);
1119
1046
 
1120
- bot.command(/^hive$/i, async ctx => {
1047
+ // Named handler for /hive command - extracted for reuse by text-based fallback (issue #1207)
1048
+ async function handleHiveCommand(ctx) {
1121
1049
  if (VERBOSE) {
1122
1050
  console.log('[VERBOSE] /hive command received');
1123
1051
  }
@@ -1252,7 +1180,9 @@ bot.command(/^hive$/i, async ctx => {
1252
1180
 
1253
1181
  const startingMessage = await ctx.reply(`🚀 Starting hive command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1254
1182
  await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock);
1255
- });
1183
+ }
1184
+
1185
+ bot.command(/^hive$/i, handleHiveCommand);
1256
1186
 
1257
1187
  // Register /top command from separate module
1258
1188
  // This keeps telegram-bot.mjs under the 1500 line limit
@@ -1285,6 +1215,10 @@ if (VERBOSE) {
1285
1215
  });
1286
1216
  if (msg) {
1287
1217
  console.log('[VERBOSE] Msg fields:', Object.keys(msg));
1218
+ // Log entities for command matching diagnostics (issue #1207)
1219
+ if (msg.entities) {
1220
+ console.log('[VERBOSE] Entities:', JSON.stringify(msg.entities));
1221
+ }
1288
1222
  console.log('[VERBOSE] Forward/reply:', {
1289
1223
  forward_origin: msg.forward_origin,
1290
1224
  forward_from: msg.forward_from,
@@ -1299,6 +1233,45 @@ if (VERBOSE) {
1299
1233
  });
1300
1234
  }
1301
1235
 
1236
+ // Text-based fallback for command matching (issue #1207)
1237
+ // Telegraf's bot.command() relies on Telegram's bot_command entities. In rare cases,
1238
+ // messages may not have the expected entity at offset 0 (e.g., certain clients, edge cases
1239
+ // with message formatting, or entity ordering), causing bot.command() to silently skip
1240
+ // the message. This fallback uses text pattern matching to catch those missed commands.
1241
+ // It runs AFTER bot.command() handlers, so it only fires when entity-based matching fails.
1242
+ bot.on('message', async (ctx, next) => {
1243
+ const text = ctx.message?.text;
1244
+ if (!text) return next();
1245
+
1246
+ // Extract command from text using the testable filter function
1247
+ // Note: We pass null for botUsername here and check it separately with ctx.me
1248
+ // which is set by Telegraf after bot initialization
1249
+ const extracted = extractCommandFromText(text);
1250
+ if (!extracted) return next();
1251
+
1252
+ // If command mentions a specific bot, verify it's us
1253
+ if (extracted.botMention) {
1254
+ const myUsername = ctx.me; // Telegraf sets this from getMe()
1255
+ if (!myUsername || extracted.botMention.toLowerCase() !== myUsername.toLowerCase()) {
1256
+ return next(); // Command is for a different bot or we can't verify
1257
+ }
1258
+ }
1259
+
1260
+ // Check if this is a command we handle
1261
+ const handlers = {
1262
+ solve: handleSolveCommand,
1263
+ hive: handleHiveCommand,
1264
+ };
1265
+
1266
+ const handler = handlers[extracted.command];
1267
+ if (!handler) return next();
1268
+
1269
+ // Log that fallback was triggered - this indicates bot.command() entity matching failed
1270
+ console.warn(`[WARNING] Command /${extracted.command} matched by text fallback, not by entity-based bot.command(). ` + `Entities: ${JSON.stringify(ctx.message.entities || [])}. ` + `User: ${ctx.from?.username || ctx.from?.id}. ` + `This may indicate a Telegram client entity issue (issue #1207).`);
1271
+
1272
+ await handler(ctx);
1273
+ });
1274
+
1302
1275
  // Add global error handler for uncaught errors in middleware
1303
1276
  bot.catch((error, ctx) => {
1304
1277
  console.error('Unhandled error while processing update', ctx.update.update_id);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Message filtering functions for Telegram bot.
3
+ * Extracted from telegram-bot.mjs for testability and reuse.
4
+ *
5
+ * These filters determine whether incoming messages should be processed
6
+ * or silently ignored by the bot's command handlers.
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/1207
9
+ * @see https://core.telegram.org/bots/features#privacy-mode
10
+ */
11
+
12
+ /**
13
+ * Check if a message was sent before the bot started.
14
+ * Prevents processing old/pending messages from before the current bot instance startup.
15
+ *
16
+ * @param {Object} ctx - Telegraf context object
17
+ * @param {number} botStartTime - Unix timestamp (seconds) of when bot started
18
+ * @param {Object} [options] - Options
19
+ * @param {boolean} [options.verbose] - Enable verbose logging
20
+ * @returns {boolean} true if message is old and should be ignored
21
+ */
22
+ export function isOldMessage(ctx, botStartTime, options = {}) {
23
+ const messageDate = ctx.message?.date;
24
+ if (!messageDate) {
25
+ return false;
26
+ }
27
+ const isOld = messageDate < botStartTime;
28
+ if (options.verbose && isOld) {
29
+ console.log(`[VERBOSE] isOldMessage: TRUE - message date ${messageDate} < bot start ${botStartTime}`);
30
+ }
31
+ return isOld;
32
+ }
33
+
34
+ /**
35
+ * Check if the chat is a group or supergroup.
36
+ *
37
+ * @param {Object} ctx - Telegraf context object
38
+ * @returns {boolean} true if chat is a group or supergroup
39
+ */
40
+ export function isGroupChat(ctx) {
41
+ const chatType = ctx.chat?.type;
42
+ return chatType === 'group' || chatType === 'supergroup';
43
+ }
44
+
45
+ /**
46
+ * Check if a chat ID is in the allowed chats whitelist.
47
+ *
48
+ * @param {number} chatId - The chat ID to check
49
+ * @param {number[]|null} allowedChats - Array of allowed chat IDs, or null for no restrictions
50
+ * @returns {boolean} true if chat is authorized
51
+ */
52
+ export function isChatAuthorized(chatId, allowedChats) {
53
+ if (!allowedChats) {
54
+ return true;
55
+ }
56
+ return allowedChats.includes(chatId);
57
+ }
58
+
59
+ /**
60
+ * Check if a message is forwarded or a reply to another user's message.
61
+ *
62
+ * This function distinguishes between:
63
+ * 1. Forwarded messages (should be ignored)
64
+ * 2. User replies to other messages (should be ignored, except for /solve reply feature)
65
+ * 3. Forum topic messages (should NOT be ignored - they have reply_to_message pointing
66
+ * to the topic's first message with forum_topic_created)
67
+ * 4. Normal messages (should NOT be ignored)
68
+ *
69
+ * @param {Object} ctx - Telegraf context object
70
+ * @param {Object} [options] - Options
71
+ * @param {boolean} [options.verbose] - Enable verbose logging
72
+ * @returns {boolean} true if message is forwarded or a reply (and should be filtered)
73
+ */
74
+ export function isForwardedOrReply(ctx, options = {}) {
75
+ const message = ctx.message;
76
+ if (!message) {
77
+ if (options.verbose) {
78
+ console.log('[VERBOSE] isForwardedOrReply: No message object');
79
+ }
80
+ return false;
81
+ }
82
+
83
+ if (options.verbose) {
84
+ console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
85
+ console.log('[VERBOSE] message.forward_origin:', JSON.stringify(message.forward_origin));
86
+ console.log('[VERBOSE] message.forward_origin?.type:', message.forward_origin?.type);
87
+ console.log('[VERBOSE] message.forward_from:', JSON.stringify(message.forward_from));
88
+ console.log('[VERBOSE] message.forward_from_chat:', JSON.stringify(message.forward_from_chat));
89
+ console.log('[VERBOSE] message.forward_from_message_id:', message.forward_from_message_id);
90
+ console.log('[VERBOSE] message.forward_signature:', message.forward_signature);
91
+ console.log('[VERBOSE] message.forward_sender_name:', message.forward_sender_name);
92
+ console.log('[VERBOSE] message.forward_date:', message.forward_date);
93
+ console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
94
+ console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
95
+ }
96
+
97
+ // Check if message is forwarded (has forward_origin field with actual content)
98
+ // Note: We check for .type because Telegram might send empty objects {}
99
+ // which are truthy in JavaScript but don't indicate a forwarded message
100
+ if (message.forward_origin && message.forward_origin.type) {
101
+ if (options.verbose) {
102
+ console.log('[VERBOSE] isForwardedOrReply: TRUE - forward_origin.type exists:', message.forward_origin.type);
103
+ }
104
+ return true;
105
+ }
106
+ // Also check old forwarding API fields for backward compatibility
107
+ if (message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date) {
108
+ if (options.verbose) {
109
+ console.log('[VERBOSE] isForwardedOrReply: TRUE - old forwarding API field detected');
110
+ }
111
+ return true;
112
+ }
113
+ // Check if message is a reply (has reply_to_message field with actual content)
114
+ // Note: We check for .message_id because Telegram might send empty objects {}
115
+ // IMPORTANT: In forum groups, messages in topics have reply_to_message pointing to the topic's
116
+ // first message (with forum_topic_created). These are NOT user replies, just part of the thread.
117
+ // We must exclude these to allow commands in forum topics.
118
+ if (message.reply_to_message && message.reply_to_message.message_id) {
119
+ // If the reply_to_message is a forum topic creation message, this is NOT a user reply
120
+ if (message.reply_to_message.forum_topic_created) {
121
+ if (options.verbose) {
122
+ console.log('[VERBOSE] isForwardedOrReply: FALSE - reply is to forum topic creation, not user reply');
123
+ console.log('[VERBOSE] Forum topic:', message.reply_to_message.forum_topic_created);
124
+ }
125
+ // This is just a message in a forum topic, not a reply to another user
126
+ // Allow the message to proceed
127
+ } else {
128
+ // This is an actual reply to another user's message
129
+ if (options.verbose) {
130
+ console.log('[VERBOSE] isForwardedOrReply: TRUE - reply_to_message.message_id exists:', message.reply_to_message.message_id);
131
+ }
132
+ return true;
133
+ }
134
+ }
135
+
136
+ if (options.verbose) {
137
+ console.log('[VERBOSE] isForwardedOrReply: FALSE - no forwarding or reply detected');
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Extract a bot command from message text using text-based pattern matching.
144
+ * This is a fallback for when Telegraf's entity-based bot.command() fails
145
+ * to match due to missing or malformed bot_command entities.
146
+ *
147
+ * @param {string} text - Message text
148
+ * @param {string|null} [botUsername] - Bot's username for @mention validation (case-insensitive)
149
+ * @returns {{ command: string, botMention: string|null } | null} Extracted command info, or null if no command found
150
+ * @see https://github.com/link-assistant/hive-mind/issues/1207
151
+ */
152
+ export function extractCommandFromText(text, botUsername = null) {
153
+ if (!text || typeof text !== 'string') {
154
+ return null;
155
+ }
156
+
157
+ const match = text.match(/^\/(\w+)(?:@(\S+))?\s*/);
158
+ if (!match) {
159
+ return null;
160
+ }
161
+
162
+ const command = match[1].toLowerCase();
163
+ const botMention = match[2] || null;
164
+
165
+ // If command mentions a specific bot, verify it matches ours
166
+ if (botMention && botUsername) {
167
+ if (botMention.toLowerCase() !== botUsername.toLowerCase()) {
168
+ return null; // Command is for a different bot
169
+ }
170
+ }
171
+
172
+ return { command, botMention };
173
+ }