@hybridaione/hybridclaw 0.2.3 → 0.2.7

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 (285) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +95 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +57 -19
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +72 -7
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1405 -0
  12. package/container/src/browser-tools.ts +672 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +65 -16
  15. package/container/src/index.ts +463 -110
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +107 -18
  18. package/container/src/tools.ts +600 -225
  19. package/container/src/types.ts +49 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +15 -4
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +24 -4
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +31 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +156 -6
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +7 -4
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +755 -150
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +19 -12
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +77 -23
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +24 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +113 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +59 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +624 -87
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +303 -26
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +62 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/hybridai-models.d.ts +8 -0
  132. package/dist/hybridai-models.d.ts.map +1 -0
  133. package/dist/hybridai-models.js +94 -0
  134. package/dist/hybridai-models.js.map +1 -0
  135. package/dist/instruction-approval-audit.d.ts.map +1 -1
  136. package/dist/instruction-approval-audit.js.map +1 -1
  137. package/dist/instruction-integrity.d.ts.map +1 -1
  138. package/dist/instruction-integrity.js +8 -2
  139. package/dist/instruction-integrity.js.map +1 -1
  140. package/dist/ipc.d.ts.map +1 -1
  141. package/dist/ipc.js +6 -1
  142. package/dist/ipc.js.map +1 -1
  143. package/dist/logger.js.map +1 -1
  144. package/dist/memory-consolidation.d.ts +17 -0
  145. package/dist/memory-consolidation.d.ts.map +1 -0
  146. package/dist/memory-consolidation.js +25 -0
  147. package/dist/memory-consolidation.js.map +1 -0
  148. package/dist/memory-service.d.ts +200 -0
  149. package/dist/memory-service.d.ts.map +1 -0
  150. package/dist/memory-service.js +294 -0
  151. package/dist/memory-service.js.map +1 -0
  152. package/dist/mount-security.d.ts.map +1 -1
  153. package/dist/mount-security.js +31 -7
  154. package/dist/mount-security.js.map +1 -1
  155. package/dist/observability-ingest.d.ts.map +1 -1
  156. package/dist/observability-ingest.js +32 -11
  157. package/dist/observability-ingest.js.map +1 -1
  158. package/dist/onboarding.d.ts.map +1 -1
  159. package/dist/onboarding.js +32 -9
  160. package/dist/onboarding.js.map +1 -1
  161. package/dist/proactive-policy.d.ts.map +1 -1
  162. package/dist/proactive-policy.js +2 -1
  163. package/dist/proactive-policy.js.map +1 -1
  164. package/dist/prompt-hooks.d.ts.map +1 -1
  165. package/dist/prompt-hooks.js +11 -7
  166. package/dist/prompt-hooks.js.map +1 -1
  167. package/dist/runtime-config.d.ts +104 -1
  168. package/dist/runtime-config.d.ts.map +1 -1
  169. package/dist/runtime-config.js +503 -24
  170. package/dist/runtime-config.js.map +1 -1
  171. package/dist/scheduled-task-runner.d.ts +1 -0
  172. package/dist/scheduled-task-runner.d.ts.map +1 -1
  173. package/dist/scheduled-task-runner.js +43 -10
  174. package/dist/scheduled-task-runner.js.map +1 -1
  175. package/dist/scheduler.d.ts +43 -4
  176. package/dist/scheduler.d.ts.map +1 -1
  177. package/dist/scheduler.js +530 -56
  178. package/dist/scheduler.js.map +1 -1
  179. package/dist/session-export.d.ts +26 -0
  180. package/dist/session-export.d.ts.map +1 -0
  181. package/dist/session-export.js +149 -0
  182. package/dist/session-export.js.map +1 -0
  183. package/dist/session-maintenance.d.ts.map +1 -1
  184. package/dist/session-maintenance.js +75 -13
  185. package/dist/session-maintenance.js.map +1 -1
  186. package/dist/session-transcripts.d.ts.map +1 -1
  187. package/dist/session-transcripts.js.map +1 -1
  188. package/dist/side-effects.d.ts.map +1 -1
  189. package/dist/side-effects.js +14 -2
  190. package/dist/side-effects.js.map +1 -1
  191. package/dist/skills-guard.d.ts.map +1 -1
  192. package/dist/skills-guard.js +893 -130
  193. package/dist/skills-guard.js.map +1 -1
  194. package/dist/skills.d.ts +5 -0
  195. package/dist/skills.d.ts.map +1 -1
  196. package/dist/skills.js +29 -15
  197. package/dist/skills.js.map +1 -1
  198. package/dist/token-efficiency.d.ts.map +1 -1
  199. package/dist/token-efficiency.js.map +1 -1
  200. package/dist/tui.js +92 -11
  201. package/dist/tui.js.map +1 -1
  202. package/dist/types.d.ts +150 -0
  203. package/dist/types.d.ts.map +1 -1
  204. package/dist/types.js +24 -1
  205. package/dist/types.js.map +1 -1
  206. package/dist/update.d.ts.map +1 -1
  207. package/dist/update.js +42 -14
  208. package/dist/update.js.map +1 -1
  209. package/dist/workspace.d.ts.map +1 -1
  210. package/dist/workspace.js +49 -9
  211. package/dist/workspace.js.map +1 -1
  212. package/docs/chat.html +9 -3
  213. package/docs/index.html +72 -13
  214. package/package.json +8 -2
  215. package/src/agent.ts +31 -4
  216. package/src/audit-cli.ts +44 -16
  217. package/src/audit-events.ts +69 -5
  218. package/src/audit-trail.ts +41 -15
  219. package/src/channels/discord/attachments.ts +81 -27
  220. package/src/channels/discord/debounce.ts +25 -0
  221. package/src/channels/discord/delivery.ts +65 -14
  222. package/src/channels/discord/human-delay.ts +48 -0
  223. package/src/channels/discord/inbound.ts +190 -8
  224. package/src/channels/discord/mentions.ts +42 -18
  225. package/src/channels/discord/presence.ts +148 -0
  226. package/src/channels/discord/rate-limiter.ts +58 -0
  227. package/src/channels/discord/reactions.ts +211 -0
  228. package/src/channels/discord/runtime.ts +1161 -205
  229. package/src/channels/discord/stream.ts +85 -31
  230. package/src/channels/discord/tool-actions.ts +78 -37
  231. package/src/channels/discord/typing.ts +140 -0
  232. package/src/chunk.ts +12 -4
  233. package/src/cli.ts +174 -57
  234. package/src/config.ts +208 -36
  235. package/src/container-runner.ts +134 -42
  236. package/src/container-setup.ts +57 -22
  237. package/src/conversation.ts +9 -7
  238. package/src/db.ts +2217 -84
  239. package/src/delegation-manager.ts +6 -2
  240. package/src/gateway-client.ts +41 -17
  241. package/src/gateway-service.ts +996 -220
  242. package/src/gateway-types.ts +33 -0
  243. package/src/gateway.ts +487 -52
  244. package/src/health.ts +66 -26
  245. package/src/heartbeat.ts +99 -22
  246. package/src/hybridai-bots.ts +14 -5
  247. package/src/hybridai-models.ts +158 -0
  248. package/src/instruction-approval-audit.ts +4 -1
  249. package/src/instruction-integrity.ts +30 -9
  250. package/src/ipc.ts +23 -5
  251. package/src/logger.ts +4 -1
  252. package/src/memory-consolidation.ts +41 -0
  253. package/src/memory-service.ts +606 -0
  254. package/src/mount-security.ts +58 -13
  255. package/src/observability-ingest.ts +134 -35
  256. package/src/onboarding.ts +126 -35
  257. package/src/proactive-policy.ts +3 -1
  258. package/src/prompt-hooks.ts +42 -17
  259. package/src/runtime-config.ts +1112 -122
  260. package/src/scheduled-task-runner.ts +77 -11
  261. package/src/scheduler.ts +683 -60
  262. package/src/session-export.ts +196 -0
  263. package/src/session-maintenance.ts +125 -22
  264. package/src/session-transcripts.ts +12 -3
  265. package/src/side-effects.ts +28 -5
  266. package/src/skills-guard.ts +1067 -219
  267. package/src/skills.ts +163 -65
  268. package/src/token-efficiency.ts +31 -9
  269. package/src/tui.ts +166 -25
  270. package/src/types.ts +199 -2
  271. package/src/update.ts +79 -23
  272. package/src/workspace.ts +63 -11
  273. package/tests/approval-policy.test.ts +335 -0
  274. package/tests/discord.basic.test.ts +213 -4
  275. package/tests/discord.human-presence.test.ts +85 -0
  276. package/tests/gateway-service.media-routing.test.ts +8 -2
  277. package/tests/hybridai-client.test.ts +112 -0
  278. package/tests/hybridai-models.test.ts +46 -0
  279. package/tests/memory-service.test.ts +1114 -0
  280. package/tests/token-efficiency.basic.test.ts +8 -2
  281. package/tests/token-usage.cache.test.ts +128 -0
  282. package/vitest.e2e.config.ts +3 -1
  283. package/vitest.integration.config.ts +3 -1
  284. package/vitest.live.config.ts +3 -1
  285. package/vitest.unit.config.ts +9 -0
@@ -1,18 +1,22 @@
1
- import { ActivityType, Client, GatewayIntentBits, Partials, } from 'discord.js';
2
- import { DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_GUILD_MEMBERS_INTENT, DISCORD_PRESENCE_INTENT, DISCORD_PREFIX, DISCORD_RESPOND_TO_ALL_MESSAGES, DISCORD_TOKEN, } from '../../config.js';
1
+ import { ApplicationCommandOptionType, Client, GatewayIntentBits, Partials, } from 'discord.js';
2
+ import { DISCORD_ACK_REACTION, DISCORD_ACK_REACTION_SCOPE, DISCORD_COMMAND_ALLOWED_USER_IDS, DISCORD_COMMAND_MODE, DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_DEBOUNCE_MS, DISCORD_FREE_RESPONSE_CHANNELS, DISCORD_GROUP_POLICY, DISCORD_GUILD_MEMBERS_INTENT, DISCORD_GUILDS, DISCORD_HUMAN_DELAY, DISCORD_LIFECYCLE_REACTIONS, DISCORD_MAX_CONCURRENT_PER_CHANNEL, DISCORD_PREFIX, DISCORD_PRESENCE_INTENT, DISCORD_RATE_LIMIT_EXEMPT_ROLES, DISCORD_RATE_LIMIT_PER_USER, DISCORD_REMOVE_ACK_AFTER_REPLY, DISCORD_RESPOND_TO_ALL_MESSAGES, DISCORD_SELF_PRESENCE, DISCORD_SUPPRESS_PATTERNS, DISCORD_TOKEN, DISCORD_TYPING_MODE, } from '../../config.js';
3
+ import { logger } from '../../logger.js';
3
4
  import { buildAttachmentContext } from './attachments.js';
4
- import { buildSessionIdFromContext as buildSessionIdFromContextInbound, cleanIncomingContent as cleanIncomingContentInbound, hasPrefixInvocation as hasPrefixInvocationInbound, isTrigger as isTriggerInbound, parseCommand as parseCommandInbound, } from './inbound.js';
5
- import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
5
+ import { DEFAULT_DEBOUNCE_MAX_BUFFER, resolveInboundDebounceMs, shouldDebounceInbound, } from './debounce.js';
6
6
  import { formatError, prepareChunkedPayloads, sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery, sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery, sendChunkedReply as sendChunkedReplyFromDelivery, } from './delivery.js';
7
- import { createDiscordToolActionRunner, } from './tool-actions.js';
7
+ import { buildSessionIdFromContext as buildSessionIdFromContextInbound, cleanIncomingContent as cleanIncomingContentInbound, hasLooseBotMention as hasLooseBotMentionInbound, hasPrefixInvocation as hasPrefixInvocationInbound, hasSlashCommandInvocation as hasSlashCommandInvocationInbound, isAddressedToChannel as isAddressedToChannelInbound, isAuthorizedCommandUser as isAuthorizedCommandUserInbound, isTrigger as isTriggerInbound, parseCommand as parseCommandInbound, shouldReplyInFreeMode as shouldReplyInFreeModeInbound, shouldSkipFreeReplyBecauseOtherUsersMentioned as shouldSkipFreeReplyBecauseOtherUsersMentionedInbound, } from './inbound.js';
8
+ import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
9
+ import { DiscordAutoPresenceController, } from './presence.js';
10
+ import { SlidingWindowRateLimiter } from './rate-limiter.js';
11
+ import { addAckReaction, LifecycleReactionController, } from './reactions.js';
8
12
  import { DiscordStreamManager } from './stream.js';
9
- import { logger } from '../../logger.js';
13
+ import { createDiscordToolActionRunner, } from './tool-actions.js';
14
+ import { createTypingController } from './typing.js';
10
15
  let client;
11
16
  let messageHandler;
12
17
  let commandHandler;
13
18
  let activeConversationRuns = 0;
14
19
  let botMentionRegex = null;
15
- const MESSAGE_DEBOUNCE_MS = 2_500;
16
20
  const DISCORD_RETRY_MAX_ATTEMPTS = 3;
17
21
  const DISCORD_RETRY_BASE_DELAY_MS = 500;
18
22
  const GUILD_INBOUND_HISTORY_LIMIT = 20;
@@ -22,7 +26,32 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
22
26
  const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
23
27
  const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
24
28
  const MAX_PRESENCE_CACHE_USERS = 5_000;
29
+ const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
30
+ const CONCURRENCY_RETRY_DELAY_MS = 250;
31
+ const PRESENCE_WINDOW_MS = 5 * 60_000;
32
+ const PRESENCE_DEGRADED_DURATION_MS = 45_000;
33
+ const PRESENCE_EXHAUSTED_ERROR_RE = /(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
34
+ const FRIENDLY_RATE_LIMIT_MESSAGE = "You're sending messages too fast — give me a moment to catch up!";
35
+ const READ_WITHOUT_REPLY_RE = /^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
36
+ const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
37
+ const STARTUP_STAGGER_WINDOW_MS = 120_000;
38
+ const STARTUP_STAGGER_MIN_DELAY_MS = 500;
39
+ const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
40
+ const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
41
+ const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
42
+ const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
43
+ const NIGHT_HOURS_START = 22;
44
+ const NIGHT_HOURS_END = 7;
45
+ const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
46
+ const CONVERSATION_COOLDOWN_THRESHOLD = 5;
47
+ const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
25
48
  const discordPresenceCache = new Map();
49
+ const userRateLimiter = new SlidingWindowRateLimiter(60_000);
50
+ const recentConversationMetrics = [];
51
+ let consecutiveConversationFailures = 0;
52
+ let presenceController = null;
53
+ let startupConnectedAtMs = 0;
54
+ const conversationExchangeByKey = new Map();
26
55
  function setDiscordPresence(userId, data) {
27
56
  discordPresenceCache.set(userId, data);
28
57
  if (discordPresenceCache.size > MAX_PRESENCE_CACHE_USERS) {
@@ -90,7 +119,8 @@ function buildPendingHistoryContext(entries) {
90
119
  const line = summarizePendingHistoryEntry(entries[i]);
91
120
  if (!line)
92
121
  continue;
93
- if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS && selected.length > 0)
122
+ if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
123
+ selected.length > 0)
94
124
  break;
95
125
  selected.push(line);
96
126
  totalChars += line.length + 1;
@@ -110,7 +140,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
110
140
  if (!msg.guild || !('messages' in msg.channel))
111
141
  return { entries: [], context: '' };
112
142
  try {
113
- const recentMessages = await msg.channel.messages.fetch({ limit: GUILD_INBOUND_HISTORY_LIMIT });
143
+ const recentMessages = await msg.channel.messages.fetch({
144
+ limit: GUILD_INBOUND_HISTORY_LIMIT,
145
+ });
114
146
  const entries = [];
115
147
  let hiddenTextCount = 0;
116
148
  let hiddenBotTextCount = 0;
@@ -119,7 +151,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
119
151
  if (plainText)
120
152
  return plainText;
121
153
  const embedChunks = recent.embeds
122
- .map((embed) => [embed.title?.trim(), embed.description?.trim()].filter(Boolean).join(' — '))
154
+ .map((embed) => [embed.title?.trim(), embed.description?.trim()]
155
+ .filter(Boolean)
156
+ .join(' — '))
123
157
  .map((part) => part.trim())
124
158
  .filter(Boolean)
125
159
  .slice(0, 3);
@@ -133,7 +167,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
133
167
  if (attachmentNames.length > 0) {
134
168
  return `[attachments] ${attachmentNames.join(', ')}`;
135
169
  }
136
- const systemContent = recent.system ? (recent.cleanContent || '').trim() : '';
170
+ const systemContent = recent.system
171
+ ? (recent.cleanContent || '').trim()
172
+ : '';
137
173
  if (systemContent)
138
174
  return `[system] ${systemContent}`;
139
175
  hiddenTextCount += 1;
@@ -157,7 +193,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
157
193
  username: recent.author.username || 'user',
158
194
  displayName: recent.member?.displayName || null,
159
195
  isBot: Boolean(recent.author.bot),
160
- timestampMs: Number.isFinite(recent.createdTimestamp) ? recent.createdTimestamp : 0,
196
+ timestampMs: Number.isFinite(recent.createdTimestamp)
197
+ ? recent.createdTimestamp
198
+ : 0,
161
199
  content,
162
200
  });
163
201
  }
@@ -305,38 +343,150 @@ function getSessionId(msg) {
305
343
  function hasPrefixInvocation(content) {
306
344
  return hasPrefixInvocationInbound(content, botMentionRegex, DISCORD_PREFIX);
307
345
  }
346
+ function hasSlashCommandInvocation(content) {
347
+ return hasSlashCommandInvocationInbound(content, botMentionRegex);
348
+ }
308
349
  function isAuthorizedCommandUserId(userId) {
309
- const configuredUserId = DISCORD_COMMAND_USER_ID.trim();
310
- if (!configuredUserId)
311
- return true;
312
- return userId === configuredUserId;
350
+ return isAuthorizedCommandUserInbound({
351
+ mode: DISCORD_COMMAND_MODE,
352
+ userId,
353
+ allowedUserIds: DISCORD_COMMAND_ALLOWED_USER_IDS,
354
+ legacyCommandUserId: DISCORD_COMMAND_USER_ID,
355
+ });
313
356
  }
314
357
  function buildSessionIdFromContext(guildId, channelId, userId) {
315
358
  return buildSessionIdFromContextInbound(guildId, channelId, userId);
316
359
  }
317
- function isTrigger(msg) {
360
+ function resolveGuildMessageMode(msg) {
361
+ if (!msg.guild)
362
+ return 'free';
363
+ if (DISCORD_GROUP_POLICY === 'disabled')
364
+ return 'off';
365
+ const guildConfig = DISCORD_GUILDS[msg.guild.id];
366
+ const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
367
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
368
+ return explicitMode ?? 'off';
369
+ }
370
+ if (explicitMode)
371
+ return explicitMode;
372
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId))
373
+ return 'free';
374
+ if (guildConfig)
375
+ return guildConfig.defaultMode;
376
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES)
377
+ return 'free';
378
+ return 'mention';
379
+ }
380
+ function resolveChannelBehavior(msg) {
381
+ const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
382
+ const channelConfig = guildConfig?.channels[msg.channelId];
383
+ return {
384
+ guildMessageMode: resolveGuildMessageMode(msg),
385
+ typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
386
+ debounceMs: resolveInboundDebounceMs(DISCORD_DEBOUNCE_MS, channelConfig?.debounceMs),
387
+ ackReaction: (channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
388
+ DISCORD_ACK_REACTION,
389
+ ackReactionScope: channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
390
+ removeAckAfterReply: channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
391
+ humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
392
+ rateLimitPerUser: Math.max(0, channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER),
393
+ suppressPatterns: channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
394
+ maxConcurrentPerChannel: Math.max(1, channelConfig?.maxConcurrentPerChannel ??
395
+ DISCORD_MAX_CONCURRENT_PER_CHANNEL),
396
+ };
397
+ }
398
+ function isTrigger(msg, behavior) {
318
399
  return isTriggerInbound({
319
400
  content: msg.content,
320
401
  isDm: !msg.guild,
321
402
  commandsOnly: DISCORD_COMMANDS_ONLY,
322
403
  respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
404
+ guildMessageMode: behavior.guildMessageMode,
323
405
  prefix: DISCORD_PREFIX,
324
406
  botMentionRegex,
325
407
  hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
408
+ suppressPatterns: behavior.suppressPatterns,
409
+ });
410
+ }
411
+ function shouldHandleFreeModeMessage(msg, behavior, content) {
412
+ if (!msg.guild)
413
+ return true;
414
+ const hasPrefixedInvocation = hasPrefixInvocation(msg.content || '');
415
+ const hasBotMention = Boolean(client.user && msg.mentions.has(client.user));
416
+ const hasLooseBotMention = client.user != null
417
+ ? hasLooseBotMentionInbound(content, [
418
+ client.user.username,
419
+ client.user.globalName || '',
420
+ msg.guild?.members.me?.displayName || '',
421
+ ])
422
+ : false;
423
+ const isReplyToBot = Boolean(client.user && msg.mentions.repliedUser?.id === client.user.id);
424
+ const mentionedUserIds = Array.from(msg.mentions.users.keys());
425
+ const botUserId = client.user?.id ?? null;
426
+ if (shouldSkipFreeReplyBecauseOtherUsersMentionedInbound({
427
+ guildMessageMode: behavior.guildMessageMode,
428
+ hasBotMention,
429
+ hasPrefixInvocation: hasPrefixedInvocation,
430
+ botUserId,
431
+ mentionedUserIds,
432
+ })) {
433
+ return false;
434
+ }
435
+ return shouldReplyInFreeModeInbound({
436
+ guildMessageMode: behavior.guildMessageMode,
437
+ content,
438
+ hasBotMention,
439
+ hasLooseBotMention,
440
+ isAddressedToChannel: isAddressedToChannelInbound(content),
441
+ hasPrefixInvocation: hasPrefixedInvocation,
442
+ isReplyToBot,
443
+ hasAttachments: msg.attachments.size > 0,
326
444
  });
327
445
  }
446
+ function shouldApplyAckReaction(msg, behavior) {
447
+ const scope = behavior.ackReactionScope;
448
+ if (scope === 'off')
449
+ return false;
450
+ if (scope === 'all')
451
+ return true;
452
+ if (scope === 'direct')
453
+ return !msg.guild;
454
+ if (!msg.guild || !client.user)
455
+ return false;
456
+ return msg.mentions.has(client.user);
457
+ }
458
+ function isRateLimitExempt(msg) {
459
+ if (msg.author.id === DISCORD_COMMAND_USER_ID.trim())
460
+ return true;
461
+ if (!msg.guild)
462
+ return false;
463
+ if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0)
464
+ return false;
465
+ const exemptByName = new Set(DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) => role.trim().toLowerCase()).filter(Boolean));
466
+ if (exemptByName.size === 0)
467
+ return false;
468
+ for (const role of msg.member.roles.cache.values()) {
469
+ const normalized = role.name.trim().toLowerCase();
470
+ if (exemptByName.has(normalized))
471
+ return true;
472
+ }
473
+ return false;
474
+ }
328
475
  function parseCommand(content) {
329
476
  return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
330
477
  }
331
478
  function isRetryableDiscordError(error) {
332
479
  const maybe = error;
333
480
  const status = maybe.status ?? maybe.httpStatus;
334
- return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
481
+ return (status === 429 ||
482
+ (typeof status === 'number' && status >= 500 && status <= 599));
335
483
  }
336
484
  function retryDelayMs(error, fallbackMs) {
337
485
  const maybe = error;
338
486
  const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
339
- if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
487
+ if (typeof retryAfterSeconds === 'number' &&
488
+ Number.isFinite(retryAfterSeconds) &&
489
+ retryAfterSeconds > 0) {
340
490
  return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
341
491
  }
342
492
  return fallbackMs + Math.floor(Math.random() * 250);
@@ -350,7 +500,8 @@ async function withDiscordRetry(label, fn) {
350
500
  return await fn();
351
501
  }
352
502
  catch (error) {
353
- if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
503
+ if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
504
+ !isRetryableDiscordError(error)) {
354
505
  throw error;
355
506
  }
356
507
  const waitMs = retryDelayMs(error, delayMs);
@@ -386,7 +537,9 @@ function buildChannelInfoContext(msg) {
386
537
  if (channelTopic) {
387
538
  lines.push(`- channel_topic: ${channelTopic}`);
388
539
  }
389
- const parentName = typeof namedChannel.parent?.name === 'string' ? namedChannel.parent.name.trim() : '';
540
+ const parentName = typeof namedChannel.parent?.name === 'string'
541
+ ? namedChannel.parent.name.trim()
542
+ : '';
390
543
  if (parentName) {
391
544
  lines.push(`- parent_channel: ${parentName}`);
392
545
  }
@@ -395,7 +548,9 @@ function buildChannelInfoContext(msg) {
395
548
  }
396
549
  async function buildReplyContext(msg) {
397
550
  const blocks = [];
398
- if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
551
+ if ('isThread' in msg.channel &&
552
+ typeof msg.channel.isThread === 'function' &&
553
+ msg.channel.isThread()) {
399
554
  try {
400
555
  const starter = await msg.channel.fetchStarterMessage();
401
556
  if (starter) {
@@ -427,70 +582,22 @@ async function buildReplyContext(msg) {
427
582
  return '';
428
583
  return `${blocks.join('\n\n')}\n\n`;
429
584
  }
430
- async function addProcessingReaction(msg) {
431
- if (!client.user)
432
- return async () => { };
433
- const botUserId = client.user.id;
434
- try {
435
- await withDiscordRetry('react', () => msg.react('👀'));
436
- }
437
- catch (error) {
438
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
439
- return async () => { };
440
- }
441
- return async () => {
442
- try {
443
- const reaction = msg.reactions.resolve('👀');
444
- if (!reaction)
445
- return;
446
- await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
447
- }
448
- catch (error) {
449
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
450
- }
451
- };
452
- }
453
- function startTypingLoop(msg) {
454
- let stopped = false;
455
- const sendTyping = async () => {
456
- if (stopped)
457
- return;
458
- if (!('sendTyping' in msg.channel))
459
- return;
460
- try {
461
- await msg.channel.sendTyping();
462
- }
463
- catch (error) {
464
- logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
465
- }
466
- };
467
- void sendTyping();
468
- const timer = setInterval(() => {
469
- void sendTyping();
470
- }, 8_000);
471
- return {
472
- stop: () => {
473
- if (stopped)
474
- return;
475
- stopped = true;
476
- clearInterval(timer);
477
- },
478
- };
479
- }
480
- async function sendChunkedReply(msg, text, files, mentionLookup) {
585
+ async function sendChunkedReply(msg, text, files, mentionLookup, humanDelay) {
481
586
  await sendChunkedReplyFromDelivery({
482
587
  msg,
483
588
  text,
484
589
  withRetry: withDiscordRetry,
590
+ ...(humanDelay ? { humanDelay } : {}),
485
591
  ...(files ? { files } : {}),
486
592
  ...(mentionLookup ? { mentionLookup } : {}),
487
593
  });
488
594
  }
489
- async function sendChunkedDirectReply(msg, text, files, mentionLookup) {
595
+ async function sendChunkedDirectReply(msg, text, files, mentionLookup, humanDelay) {
490
596
  await sendChunkedDirectReplyFromDelivery({
491
597
  msg,
492
598
  text,
493
599
  withRetry: withDiscordRetry,
600
+ ...(humanDelay ? { humanDelay } : {}),
494
601
  ...(files ? { files } : {}),
495
602
  ...(mentionLookup ? { mentionLookup } : {}),
496
603
  });
@@ -503,52 +610,216 @@ async function sendChunkedInteractionReply(interaction, text, files) {
503
610
  ...(files ? { files } : {}),
504
611
  });
505
612
  }
506
- async function ensureSlashStatusCommand() {
507
- const definition = {
508
- name: 'status',
509
- description: 'Show HybridClaw runtime status (only visible to you)',
510
- };
613
+ async function ensureSlashCommands() {
614
+ const definitions = [
615
+ {
616
+ name: 'status',
617
+ description: 'Show HybridClaw runtime status (only visible to you)',
618
+ dmPermission: true,
619
+ },
620
+ {
621
+ name: 'approve',
622
+ description: 'View/respond to pending tool approval requests (private)',
623
+ dmPermission: true,
624
+ options: [
625
+ {
626
+ type: ApplicationCommandOptionType.String,
627
+ name: 'action',
628
+ description: 'Action to perform',
629
+ required: false,
630
+ choices: [
631
+ { name: 'view', value: 'view' },
632
+ { name: 'yes', value: 'yes' },
633
+ { name: 'session', value: 'session' },
634
+ { name: 'agent', value: 'agent' },
635
+ { name: 'no', value: 'no' },
636
+ ],
637
+ },
638
+ {
639
+ type: ApplicationCommandOptionType.String,
640
+ name: 'approval_id',
641
+ description: 'Optional approval id (defaults to latest pending)',
642
+ required: false,
643
+ },
644
+ ],
645
+ },
646
+ {
647
+ name: 'channel-mode',
648
+ description: 'Set this channel to off, mention-only, or free-response',
649
+ options: [
650
+ {
651
+ type: ApplicationCommandOptionType.String,
652
+ name: 'mode',
653
+ description: 'Response mode for this channel',
654
+ required: true,
655
+ choices: [
656
+ { name: 'off', value: 'off' },
657
+ { name: 'mention', value: 'mention' },
658
+ { name: 'free', value: 'free' },
659
+ ],
660
+ },
661
+ ],
662
+ },
663
+ {
664
+ name: 'channel-policy',
665
+ description: 'Set guild channel policy to open, allowlist, or disabled',
666
+ options: [
667
+ {
668
+ type: ApplicationCommandOptionType.String,
669
+ name: 'policy',
670
+ description: 'Guild channel policy',
671
+ required: true,
672
+ choices: [
673
+ { name: 'open', value: 'open' },
674
+ { name: 'allowlist', value: 'allowlist' },
675
+ { name: 'disabled', value: 'disabled' },
676
+ ],
677
+ },
678
+ ],
679
+ },
680
+ ];
511
681
  if (!client.application)
512
682
  return;
683
+ const globalDefinitions = definitions.filter((definition) => definition.name === 'status' || definition.name === 'approve');
684
+ try {
685
+ for (const definition of globalDefinitions) {
686
+ // POST is an upsert by name for global commands. Keep command IDs stable
687
+ // to avoid stale-client command references in DMs.
688
+ await client.application.commands.create(definition);
689
+ logger.info({ scope: 'global', command: definition.name }, 'Upserted slash command');
690
+ }
691
+ }
692
+ catch (error) {
693
+ logger.warn({ error }, 'Failed to register global slash commands');
694
+ }
513
695
  await Promise.allSettled([...client.guilds.cache.values()].map(async (guild) => {
514
696
  try {
515
697
  const existing = await guild.commands.fetch();
516
- const current = existing.find((command) => command.name === definition.name);
517
- if (!current) {
518
- await guild.commands.create(definition);
519
- logger.info({ guildId: guild.id }, 'Registered slash command /status');
520
- return;
521
- }
522
- if (current.description !== definition.description) {
698
+ for (const definition of definitions) {
699
+ const current = existing.find((command) => command.name === definition.name);
700
+ if (!current) {
701
+ await guild.commands.create(definition);
702
+ logger.info({ guildId: guild.id, command: definition.name }, 'Registered slash command');
703
+ continue;
704
+ }
523
705
  await guild.commands.edit(current.id, definition);
524
- logger.info({ guildId: guild.id }, 'Updated slash command /status');
706
+ logger.info({ guildId: guild.id, command: definition.name }, 'Updated slash command');
525
707
  }
526
708
  }
527
709
  catch (error) {
528
- logger.warn({ error, guildId: guild.id }, 'Failed to register slash command /status');
710
+ logger.warn({ error, guildId: guild.id }, 'Failed to register Discord slash commands');
529
711
  }
530
712
  }));
531
713
  }
532
- function updatePresence() {
533
- if (!client.user)
534
- return;
535
- if (activeConversationRuns > 0) {
536
- client.user.setPresence({
537
- activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
538
- status: 'online',
539
- });
714
+ function trimRecentConversationMetrics(nowMs = Date.now()) {
715
+ const cutoff = nowMs - PRESENCE_WINDOW_MS;
716
+ while (recentConversationMetrics.length > 0 &&
717
+ recentConversationMetrics[0].atMs < cutoff) {
718
+ recentConversationMetrics.shift();
719
+ }
720
+ }
721
+ function recordConversationMetric(params) {
722
+ const nowMs = Date.now();
723
+ const errorText = params.error instanceof Error
724
+ ? params.error.message
725
+ : String(params.error || '');
726
+ const exhaustedHint = !params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
727
+ if (params.ok) {
728
+ consecutiveConversationFailures = 0;
729
+ }
730
+ else {
731
+ consecutiveConversationFailures += 1;
732
+ }
733
+ recentConversationMetrics.push({
734
+ atMs: nowMs,
735
+ durationMs: Math.max(0, Math.floor(params.durationMs)),
736
+ ok: params.ok,
737
+ exhaustedHint,
738
+ });
739
+ trimRecentConversationMetrics(nowMs);
740
+ }
741
+ function resolvePresenceHealthState() {
742
+ trimRecentConversationMetrics();
743
+ if (activeConversationRuns >= 4)
744
+ return 'degraded';
745
+ if (consecutiveConversationFailures >= 3)
746
+ return 'exhausted';
747
+ if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
748
+ return 'exhausted';
749
+ if (recentConversationMetrics.length === 0)
750
+ return 'healthy';
751
+ const totalDuration = recentConversationMetrics.reduce((sum, entry) => sum + entry.durationMs, 0);
752
+ const avgDuration = totalDuration / recentConversationMetrics.length;
753
+ const failureCount = recentConversationMetrics.filter((entry) => !entry.ok).length;
754
+ const errorRate = failureCount / recentConversationMetrics.length;
755
+ if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
756
+ return 'degraded';
757
+ return 'healthy';
758
+ }
759
+ function randomIntInRange(minMs, maxMs) {
760
+ const lo = Math.floor(Math.max(0, minMs));
761
+ const hi = Math.floor(Math.max(lo, maxMs));
762
+ if (hi <= lo)
763
+ return lo;
764
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
765
+ }
766
+ function isNightOrWeekend(now) {
767
+ const day = now.getDay();
768
+ const hour = now.getHours();
769
+ const weekend = day === 0 || day === 6;
770
+ const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
771
+ return weekend || night;
772
+ }
773
+ function buildConversationCooldownKey(channelId, userId) {
774
+ return `${channelId}:${userId}`;
775
+ }
776
+ function resolveHumanDelayWithBehavior(base, cooldownKey) {
777
+ if (base.mode === 'off')
778
+ return base;
779
+ const now = new Date();
780
+ let factor = isNightOrWeekend(now) ? 1.5 : 1;
781
+ const record = conversationExchangeByKey.get(cooldownKey);
782
+ if (record) {
783
+ const elapsedMs = Date.now() - record.lastAtMs;
784
+ if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
785
+ conversationExchangeByKey.delete(cooldownKey);
786
+ }
787
+ else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
788
+ const extra = Math.min(CONVERSATION_COOLDOWN_MAX_FACTOR - 1, (record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12);
789
+ factor += Math.max(0, extra);
790
+ }
791
+ }
792
+ if (factor <= 1)
793
+ return base;
794
+ const minMs = Math.round((base.minMs ?? 800) * factor);
795
+ const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
796
+ return {
797
+ mode: 'custom',
798
+ minMs,
799
+ maxMs: Math.max(minMs, maxMs),
800
+ };
801
+ }
802
+ function noteConversationExchange(cooldownKey) {
803
+ const nowMs = Date.now();
804
+ const existing = conversationExchangeByKey.get(cooldownKey);
805
+ if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
806
+ conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
540
807
  return;
541
808
  }
542
- client.user.setPresence({
543
- activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
544
- status: 'online',
809
+ conversationExchangeByKey.set(cooldownKey, {
810
+ count: existing.count + 1,
811
+ lastAtMs: nowMs,
545
812
  });
546
813
  }
814
+ function pickReadWithoutReplyEmoji() {
815
+ return Math.random() < 0.7 ? '👍' : '✅';
816
+ }
547
817
  export function initDiscord(onMessage, onCommand) {
548
818
  messageHandler = onMessage;
549
819
  commandHandler = onCommand;
550
820
  const pendingBatches = new Map();
551
821
  const inFlightByMessageId = new Map();
822
+ const channelConcurrencyById = new Map();
552
823
  const negativeFeedbackByChannel = new Map();
553
824
  const participantMemoryByChannel = new Map();
554
825
  const touchParticipantMemoryChannel = (channelId) => {
@@ -623,6 +894,103 @@ export function initDiscord(onMessage, onCommand) {
623
894
  rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
624
895
  }
625
896
  };
897
+ const waitForChannelConcurrencySlot = async (channelId, maxConcurrent, abortSignal) => {
898
+ const boundedMax = Math.max(1, Math.floor(maxConcurrent));
899
+ while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
900
+ if (abortSignal?.aborted) {
901
+ throw new Error('Conversation aborted while waiting for channel concurrency slot.');
902
+ }
903
+ await new Promise((resolve) => setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS));
904
+ }
905
+ if (abortSignal?.aborted) {
906
+ throw new Error('Conversation aborted before acquiring channel concurrency slot.');
907
+ }
908
+ channelConcurrencyById.set(channelId, (channelConcurrencyById.get(channelId) ?? 0) + 1);
909
+ return () => {
910
+ const current = channelConcurrencyById.get(channelId) ?? 0;
911
+ const next = Math.max(0, current - 1);
912
+ if (next === 0) {
913
+ channelConcurrencyById.delete(channelId);
914
+ }
915
+ else {
916
+ channelConcurrencyById.set(channelId, next);
917
+ }
918
+ };
919
+ };
920
+ const enforcePerUserRateLimit = async (msg, behavior) => {
921
+ const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
922
+ if (limit === 0)
923
+ return true;
924
+ if (isRateLimitExempt(msg))
925
+ return true;
926
+ const key = `${msg.channelId}:${msg.author.id}`;
927
+ const decision = userRateLimiter.check(key, limit);
928
+ if (decision.allowed)
929
+ return true;
930
+ if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
931
+ try {
932
+ await withDiscordRetry('rate-limit-reply', () => msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }));
933
+ }
934
+ catch (error) {
935
+ logger.debug({ error, channelId: msg.channelId, userId: msg.author.id }, 'Failed to send rate-limit warning');
936
+ }
937
+ }
938
+ return false;
939
+ };
940
+ const maybeHandleReadWithoutReply = async (msg, content) => {
941
+ if (!content.trim())
942
+ return false;
943
+ if (!msg.guild)
944
+ return false;
945
+ if (msg.attachments.size > 0)
946
+ return false;
947
+ if (hasPrefixInvocation(msg.content || ''))
948
+ return false;
949
+ if (hasSlashCommandInvocation(msg.content || ''))
950
+ return false;
951
+ if (client.user && msg.mentions.has(client.user))
952
+ return false;
953
+ if (content.trim().length > 80)
954
+ return false;
955
+ if (!READ_WITHOUT_REPLY_RE.test(content.trim()))
956
+ return false;
957
+ if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY)
958
+ return false;
959
+ try {
960
+ await withDiscordRetry('reaction-read-without-reply', () => msg.react(pickReadWithoutReplyEmoji()));
961
+ return true;
962
+ }
963
+ catch (error) {
964
+ logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed read-without-reply reaction');
965
+ return false;
966
+ }
967
+ };
968
+ const shouldSelectivelySilence = (params) => {
969
+ if (!params.sourceItem.msg.guild)
970
+ return false;
971
+ if (params.behavior.guildMessageMode !== 'free')
972
+ return false;
973
+ if (params.sourceItem.wasExplicitlyAddressed)
974
+ return false;
975
+ const nowMs = Date.now();
976
+ const peerMessages = params.inboundHistory
977
+ .filter((entry) => !entry.isBot &&
978
+ entry.userId !== params.sourceItem.msg.author.id &&
979
+ nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS)
980
+ .sort((a, b) => a.timestampMs - b.timestampMs);
981
+ if (peerMessages.length === 0)
982
+ return false;
983
+ const sourceText = params.sourceItem.content.trim();
984
+ const asksQuestion = sourceText.includes('?');
985
+ const latestPeer = peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
986
+ '';
987
+ const peerLooksLikeAnswer = latestPeer.length >= 24 ||
988
+ /\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(latestPeer);
989
+ const probability = asksQuestion && peerLooksLikeAnswer
990
+ ? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
991
+ : SELECTIVE_SILENCE_BASE_PROBABILITY;
992
+ return Math.random() < probability;
993
+ };
626
994
  const intents = [
627
995
  GatewayIntentBits.Guilds,
628
996
  GatewayIntentBits.GuildMessages,
@@ -636,7 +1004,12 @@ export function initDiscord(onMessage, onCommand) {
636
1004
  intents.push(GatewayIntentBits.GuildPresences);
637
1005
  client = new Client({
638
1006
  intents,
639
- partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
1007
+ partials: [
1008
+ Partials.Channel,
1009
+ Partials.Message,
1010
+ Partials.Reaction,
1011
+ Partials.User,
1012
+ ],
640
1013
  });
641
1014
  client.on('presenceUpdate', (_oldPresence, nextPresence) => {
642
1015
  const userId = nextPresence.userId || nextPresence.user?.id;
@@ -654,30 +1027,91 @@ export function initDiscord(onMessage, onCommand) {
654
1027
  });
655
1028
  client.on('clientReady', () => {
656
1029
  logger.info({ user: client.user?.tag }, 'Discord bot connected');
1030
+ startupConnectedAtMs = Date.now();
657
1031
  if (client.user) {
658
1032
  botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
659
1033
  }
660
- updatePresence();
661
- void ensureSlashStatusCommand();
1034
+ presenceController?.stop();
1035
+ presenceController = new DiscordAutoPresenceController({
1036
+ client,
1037
+ getConfig: () => DISCORD_SELF_PRESENCE,
1038
+ resolveState: resolvePresenceHealthState,
1039
+ });
1040
+ presenceController.start();
1041
+ void ensureSlashCommands();
662
1042
  });
663
1043
  client.on('interactionCreate', async (interaction) => {
664
1044
  if (!interaction.isChatInputCommand())
665
1045
  return;
666
- if (interaction.commandName !== 'status')
1046
+ if (interaction.commandName !== 'status' &&
1047
+ interaction.commandName !== 'approve' &&
1048
+ interaction.commandName !== 'channel-mode' &&
1049
+ interaction.commandName !== 'channel-policy') {
667
1050
  return;
668
- if (!isAuthorizedCommandUserId(interaction.user.id)) {
1051
+ }
1052
+ if (interaction.guildId &&
1053
+ !isAuthorizedCommandUserId(interaction.user.id)) {
669
1054
  await sendChunkedInteractionReply(interaction, 'You are not authorized to run commands for this bot.');
670
1055
  return;
671
1056
  }
672
1057
  const guildId = interaction.guildId ?? null;
673
1058
  const channelId = interaction.channelId;
674
1059
  const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
1060
+ const args = (() => {
1061
+ if (interaction.commandName === 'status')
1062
+ return ['status'];
1063
+ if (interaction.commandName === 'approve') {
1064
+ const action = interaction.options
1065
+ .getString('action', false)
1066
+ ?.trim()
1067
+ .toLowerCase() || 'view';
1068
+ if (action !== 'view' &&
1069
+ action !== 'yes' &&
1070
+ action !== 'session' &&
1071
+ action !== 'agent' &&
1072
+ action !== 'no') {
1073
+ return null;
1074
+ }
1075
+ const approvalId = interaction.options.getString('approval_id', false)?.trim() || '';
1076
+ return approvalId
1077
+ ? ['approve', action, approvalId]
1078
+ : ['approve', action];
1079
+ }
1080
+ if (interaction.commandName === 'channel-mode') {
1081
+ if (!interaction.guildId)
1082
+ return null;
1083
+ const selectedMode = interaction.options
1084
+ .getString('mode', true)
1085
+ .trim()
1086
+ .toLowerCase();
1087
+ if (selectedMode !== 'off' &&
1088
+ selectedMode !== 'mention' &&
1089
+ selectedMode !== 'free')
1090
+ return null;
1091
+ return ['channel', 'mode', selectedMode];
1092
+ }
1093
+ if (!interaction.guildId)
1094
+ return null;
1095
+ const selectedPolicy = interaction.options
1096
+ .getString('policy', true)
1097
+ .trim()
1098
+ .toLowerCase();
1099
+ if (selectedPolicy !== 'open' &&
1100
+ selectedPolicy !== 'allowlist' &&
1101
+ selectedPolicy !== 'disabled')
1102
+ return null;
1103
+ return ['channel', 'policy', selectedPolicy];
1104
+ })();
1105
+ if (!args) {
1106
+ await sendChunkedInteractionReply(interaction, 'This command can only be used in a server channel with a valid mode/policy option.');
1107
+ return;
1108
+ }
675
1109
  try {
676
- await commandHandler(sessionId, guildId, channelId, ['status'], async (text, files) => sendChunkedInteractionReply(interaction, text, files));
1110
+ await commandHandler(sessionId, guildId, channelId, interaction.user.id, interaction.user.username, args, async (text, files) => sendChunkedInteractionReply(interaction, text, files));
677
1111
  }
678
1112
  catch (error) {
679
1113
  const detail = error instanceof Error ? error.message : String(error);
680
- logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash /status command failed');
1114
+ logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash command failed');
681
1115
  await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
682
1116
  }
683
1117
  });
@@ -696,8 +1130,13 @@ export function initDiscord(onMessage, onCommand) {
696
1130
  const channelId = msg.channelId;
697
1131
  const userId = msg.author.id;
698
1132
  const username = msg.author.username;
1133
+ const behavior = sourceItem.behavior;
1134
+ const startedAt = Date.now();
1135
+ let releaseChannelSlot = null;
699
1136
  const batchedContent = items.length > 1
700
- ? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
1137
+ ? items
1138
+ .map((item, index) => `Message ${index + 1}:\n${item.content}`)
1139
+ .join('\n\n')
701
1140
  : sourceItem.content;
702
1141
  const channelInfoContext = buildChannelInfoContext(msg);
703
1142
  const replyContext = await buildReplyContext(msg);
@@ -712,78 +1151,206 @@ export function initDiscord(onMessage, onCommand) {
712
1151
  const participantContext = buildParticipantContext(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
713
1152
  const mentionLookup = buildMentionLookup(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
714
1153
  const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
1154
+ const selectiveSilence = shouldSelectivelySilence({
1155
+ sourceItem,
1156
+ inboundHistory: inboundHistory.entries,
1157
+ behavior,
1158
+ });
715
1159
  const abortController = new AbortController();
716
- const typingLoop = startTypingLoop(msg);
1160
+ const typingController = pending.typingController;
1161
+ const lifecycleController = pending.lifecycleController;
1162
+ const emitLifecyclePhase = (phase) => {
1163
+ if (phase === 'queued') {
1164
+ typingController.setPhase('received');
1165
+ }
1166
+ else if (phase === 'thinking') {
1167
+ typingController.setPhase('thinking');
1168
+ }
1169
+ else if (phase === 'toolUse') {
1170
+ typingController.setPhase('toolUse');
1171
+ }
1172
+ else if (phase === 'streaming') {
1173
+ typingController.setPhase('streaming');
1174
+ }
1175
+ else {
1176
+ typingController.setPhase('done');
1177
+ }
1178
+ lifecycleController?.setPhase(phase);
1179
+ };
717
1180
  const stream = new DiscordStreamManager(msg, {
718
- onFirstMessage: () => typingLoop.stop(),
1181
+ onFirstMessage: () => emitLifecyclePhase('streaming'),
1182
+ humanDelay: behavior.humanDelay,
719
1183
  });
720
1184
  const inFlight = {
721
1185
  abortController,
722
1186
  stream,
723
1187
  messageIds: new Set(items.map((item) => item.msg.id)),
724
1188
  aborted: false,
1189
+ emitLifecyclePhase,
725
1190
  };
726
1191
  for (const messageId of inFlight.messageIds) {
727
1192
  inFlightByMessageId.set(messageId, inFlight);
728
1193
  }
729
1194
  try {
1195
+ if (selectiveSilence) {
1196
+ emitLifecyclePhase('done');
1197
+ if (Math.random() < 0.5) {
1198
+ await withDiscordRetry('reaction-selective-silence', () => msg.react(pickReadWithoutReplyEmoji())).catch(() => { });
1199
+ }
1200
+ recordConversationMetric({
1201
+ durationMs: Date.now() - startedAt,
1202
+ ok: true,
1203
+ });
1204
+ return;
1205
+ }
1206
+ releaseChannelSlot = await waitForChannelConcurrencySlot(channelId, behavior.maxConcurrentPerChannel, abortController.signal);
1207
+ if (abortController.signal.aborted) {
1208
+ return;
1209
+ }
730
1210
  activeConversationRuns += 1;
731
- updatePresence();
1211
+ emitLifecyclePhase('thinking');
732
1212
  await messageHandler(sessionId, guildId, channelId, userId, username, combinedContent, attachmentContext.media, async (text, files) => {
733
- typingLoop.stop();
734
- await sendChunkedReply(msg, text, files, mentionLookup);
1213
+ emitLifecyclePhase('streaming');
1214
+ await sendChunkedReply(msg, text, files, mentionLookup, behavior.humanDelay);
735
1215
  }, {
736
1216
  sourceMessage: msg,
737
1217
  batchedMessages: items.map((item) => item.msg),
738
1218
  abortSignal: abortController.signal,
739
1219
  stream,
740
1220
  mentionLookup,
1221
+ emitLifecyclePhase,
1222
+ });
1223
+ emitLifecyclePhase('done');
1224
+ recordConversationMetric({
1225
+ durationMs: Date.now() - startedAt,
1226
+ ok: true,
741
1227
  });
1228
+ noteConversationExchange(sourceItem.cooldownKey);
742
1229
  }
743
1230
  catch (error) {
1231
+ if (abortController.signal.aborted || inFlight.aborted) {
1232
+ logger.debug({ channelId, sessionId }, 'Conversation batch aborted before completion');
1233
+ return;
1234
+ }
1235
+ emitLifecyclePhase('error');
1236
+ recordConversationMetric({
1237
+ durationMs: Date.now() - startedAt,
1238
+ ok: false,
1239
+ error,
1240
+ });
744
1241
  logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
745
1242
  const detail = error instanceof Error ? error.message : String(error);
746
1243
  if (stream.hasSentMessages()) {
747
1244
  await stream.fail(formatError('Gateway Error', detail));
748
1245
  }
749
1246
  else {
750
- await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
1247
+ await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup, behavior.humanDelay);
751
1248
  }
752
1249
  }
753
1250
  finally {
754
1251
  activeConversationRuns = Math.max(0, activeConversationRuns - 1);
755
- updatePresence();
1252
+ if (releaseChannelSlot) {
1253
+ releaseChannelSlot();
1254
+ }
756
1255
  for (const messageId of inFlight.messageIds) {
757
1256
  if (inFlightByMessageId.get(messageId) === inFlight) {
758
1257
  inFlightByMessageId.delete(messageId);
759
1258
  }
760
1259
  }
761
- typingLoop.stop();
1260
+ typingController.stop();
762
1261
  await Promise.all(items.map(async (item) => {
763
- await item.clearReaction();
1262
+ await item.clearAckReaction();
764
1263
  }));
765
1264
  }
766
1265
  };
767
- const queueConversationMessage = async (msg, content) => {
1266
+ const queueConversationMessage = async (msg, content, behavior) => {
768
1267
  const key = `${msg.channelId}:${msg.author.id}`;
769
- const clearReaction = await addProcessingReaction(msg);
770
- const queued = { msg, content, clearReaction };
1268
+ const cooldownKey = buildConversationCooldownKey(msg.channelId, msg.author.id);
1269
+ const adjustedHumanDelay = resolveHumanDelayWithBehavior(behavior.humanDelay, cooldownKey);
1270
+ const queuedBehavior = {
1271
+ ...behavior,
1272
+ humanDelay: adjustedHumanDelay,
1273
+ };
1274
+ const wasExplicitlyAddressed = !msg.guild ||
1275
+ hasPrefixInvocation(msg.content || '') ||
1276
+ Boolean(client.user && msg.mentions.has(client.user)) ||
1277
+ Boolean(client.user && msg.mentions.repliedUser?.id === client.user.id);
1278
+ let clearAckReaction = async () => { };
1279
+ if (client.user && shouldApplyAckReaction(msg, behavior)) {
1280
+ const clearReaction = await addAckReaction({
1281
+ message: msg,
1282
+ emoji: behavior.ackReaction,
1283
+ withRetry: withDiscordRetry,
1284
+ botUserId: client.user.id,
1285
+ });
1286
+ clearAckReaction = behavior.removeAckAfterReply
1287
+ ? clearReaction
1288
+ : async () => { };
1289
+ }
1290
+ const queued = {
1291
+ msg,
1292
+ content,
1293
+ behavior: queuedBehavior,
1294
+ clearAckReaction,
1295
+ wasExplicitlyAddressed,
1296
+ cooldownKey,
1297
+ };
771
1298
  const existing = pendingBatches.get(key);
1299
+ const shouldDebounceMessage = shouldDebounceInbound({
1300
+ content: msg.content || '',
1301
+ hasAttachments: msg.attachments.size > 0,
1302
+ isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
1303
+ });
772
1304
  if (!existing) {
1305
+ const typingController = createTypingController(msg, behavior.typingMode);
1306
+ typingController.setPhase('received');
1307
+ const lifecycleController = client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
1308
+ ? new LifecycleReactionController({
1309
+ message: msg,
1310
+ withRetry: withDiscordRetry,
1311
+ botUserId: client.user.id,
1312
+ config: {
1313
+ enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
1314
+ removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
1315
+ phases: DISCORD_LIFECYCLE_REACTIONS.phases,
1316
+ },
1317
+ })
1318
+ : null;
1319
+ lifecycleController?.setPhase('queued');
1320
+ const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
1321
+ const startupStaggerMs = startupConnectedAtMs > 0 &&
1322
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1323
+ !wasExplicitlyAddressed
1324
+ ? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
1325
+ : 0;
1326
+ const delayMs = baseDelayMs + startupStaggerMs;
773
1327
  const timer = setTimeout(() => {
774
1328
  void dispatchConversationBatch(key);
775
- }, MESSAGE_DEBOUNCE_MS);
1329
+ }, delayMs);
776
1330
  pendingBatches.set(key, {
777
1331
  items: [queued],
778
1332
  timer,
1333
+ typingController,
1334
+ lifecycleController,
779
1335
  });
780
1336
  return;
781
1337
  }
1338
+ existing.typingController.setPhase('received');
1339
+ existing.lifecycleController?.setPhase('queued');
782
1340
  clearTimeout(existing.timer);
783
1341
  existing.items.push(queued);
1342
+ const shouldFlushImmediately = !shouldDebounceMessage ||
1343
+ existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
1344
+ const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
1345
+ const startupStaggerMs = startupConnectedAtMs > 0 &&
1346
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1347
+ !wasExplicitlyAddressed
1348
+ ? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
1349
+ : 0;
1350
+ const delayMs = baseDelayMs + startupStaggerMs;
784
1351
  existing.timer = setTimeout(() => {
785
1352
  void dispatchConversationBatch(key);
786
- }, MESSAGE_DEBOUNCE_MS);
1353
+ }, delayMs);
787
1354
  };
788
1355
  const dropPendingMessage = async (messageId) => {
789
1356
  for (const [key, pending] of pendingBatches) {
@@ -791,29 +1358,39 @@ export function initDiscord(onMessage, onCommand) {
791
1358
  if (index === -1)
792
1359
  continue;
793
1360
  const [removed] = pending.items.splice(index, 1);
794
- await removed.clearReaction();
1361
+ await removed.clearAckReaction();
795
1362
  if (pending.items.length === 0) {
796
1363
  clearTimeout(pending.timer);
1364
+ pending.typingController.stop();
1365
+ await pending.lifecycleController?.clear();
797
1366
  pendingBatches.delete(key);
798
1367
  }
799
1368
  return;
800
1369
  }
801
1370
  };
802
- const updatePendingMessage = async (messageId, nextMsg, nextContent) => {
1371
+ const updatePendingMessage = async (messageId, nextMsg, nextContent, nextBehavior) => {
803
1372
  for (const [key, pending] of pendingBatches) {
804
1373
  const index = pending.items.findIndex((item) => item.msg.id === messageId);
805
1374
  if (index === -1)
806
1375
  continue;
807
1376
  if (!nextContent) {
808
1377
  const [removed] = pending.items.splice(index, 1);
809
- await removed.clearReaction();
1378
+ await removed.clearAckReaction();
810
1379
  }
811
1380
  else {
812
1381
  pending.items[index].msg = nextMsg;
813
1382
  pending.items[index].content = nextContent;
1383
+ pending.items[index].behavior = nextBehavior;
1384
+ pending.items[index].wasExplicitlyAddressed =
1385
+ !nextMsg.guild ||
1386
+ hasPrefixInvocation(nextMsg.content || '') ||
1387
+ Boolean(client.user && nextMsg.mentions.has(client.user));
1388
+ pending.items[index].cooldownKey = buildConversationCooldownKey(nextMsg.channelId, nextMsg.author.id);
814
1389
  }
815
1390
  if (pending.items.length === 0) {
816
1391
  clearTimeout(pending.timer);
1392
+ pending.typingController.stop();
1393
+ await pending.lifecycleController?.clear();
817
1394
  pendingBatches.delete(key);
818
1395
  }
819
1396
  return true;
@@ -826,35 +1403,32 @@ export function initDiscord(onMessage, onCommand) {
826
1403
  const sessionId = getSessionId(msg);
827
1404
  const guildId = msg.guild?.id || null;
828
1405
  const channelId = msg.channelId;
1406
+ const behavior = resolveChannelBehavior(msg);
829
1407
  const content = cleanIncomingContent(msg.content);
830
1408
  observeMessageParticipants(msg, content);
831
1409
  const immediateMentionLookup = buildMentionLookup([msg], [], msg.guild ? participantMemoryByChannel.get(msg.channelId) : undefined);
832
1410
  const reply = async (text, files) => {
833
- await sendChunkedReply(msg, text, files, immediateMentionLookup);
1411
+ await sendChunkedReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
834
1412
  };
835
1413
  const commandReply = async (text, files) => {
836
1414
  try {
837
- await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
1415
+ await sendChunkedDirectReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
838
1416
  }
839
1417
  catch (error) {
840
1418
  logger.warn({ error, userId: msg.author.id, channelId: msg.channelId }, 'Failed to send command reply via DM; command response dropped');
841
1419
  }
842
1420
  };
843
1421
  const parsed = parseCommand(msg.content);
844
- const prefixedToken = hasPrefixInvocation(msg.content)
845
- ? cleanIncomingContent(msg.content).split(/\s+/)[0]?.toLowerCase() || ''
846
- : '';
847
- const ignorePrefixCommand = prefixedToken === 'status';
1422
+ const hasPrefixedInvocation = hasPrefixInvocation(msg.content);
1423
+ const hasSlashInvocation = hasSlashCommandInvocation(msg.content);
1424
+ const hasCommandInvocation = hasPrefixedInvocation || hasSlashInvocation;
848
1425
  if (DISCORD_COMMANDS_ONLY) {
849
- if (!hasPrefixInvocation(msg.content))
1426
+ if (!hasCommandInvocation)
850
1427
  return;
851
- if (!isAuthorizedCommandUserId(msg.author.id)) {
1428
+ if (msg.guild && !isAuthorizedCommandUserId(msg.author.id)) {
852
1429
  logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command in commands-only mode');
853
1430
  return;
854
1431
  }
855
- if (ignorePrefixCommand) {
856
- return;
857
- }
858
1432
  if (!parsed.isCommand) {
859
1433
  if (!content) {
860
1434
  await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
@@ -864,28 +1438,37 @@ export function initDiscord(onMessage, onCommand) {
864
1438
  }
865
1439
  return;
866
1440
  }
867
- await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
1441
+ await commandHandler(sessionId, guildId, channelId, msg.author.id, msg.author.username, [parsed.command, ...parsed.args], commandReply);
868
1442
  return;
869
1443
  }
870
- if (!isTrigger(msg))
1444
+ if (msg.guild &&
1445
+ hasCommandInvocation &&
1446
+ !isAuthorizedCommandUserId(msg.author.id)) {
1447
+ logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord prefixed command');
871
1448
  return;
872
- if (ignorePrefixCommand) {
1449
+ }
1450
+ if (!isTrigger(msg, behavior))
1451
+ return;
1452
+ if (parsed.isCommand && hasCommandInvocation) {
1453
+ await commandHandler(sessionId, guildId, channelId, msg.author.id, msg.author.username, [parsed.command, ...parsed.args], commandReply);
873
1454
  return;
874
1455
  }
875
- if (parsed.isCommand && hasPrefixInvocation(msg.content)) {
876
- if (!isAuthorizedCommandUserId(msg.author.id)) {
877
- logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command; processing as normal chat message');
878
- }
879
- else {
880
- await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
881
- return;
882
- }
1456
+ if (!shouldHandleFreeModeMessage(msg, behavior, content)) {
1457
+ logger.debug({ channelId: msg.channelId, messageId: msg.id, userId: msg.author.id }, 'Skipping Discord free-mode message by relevance/mention gate');
1458
+ return;
883
1459
  }
884
1460
  if (!content) {
885
1461
  await reply('How can I help? Send me a message or try `!claw help`.');
886
1462
  return;
887
1463
  }
888
- await queueConversationMessage(msg, content);
1464
+ const readWithoutReplyHandled = await maybeHandleReadWithoutReply(msg, content);
1465
+ if (readWithoutReplyHandled) {
1466
+ return;
1467
+ }
1468
+ const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
1469
+ if (!rateLimitAllowed)
1470
+ return;
1471
+ await queueConversationMessage(msg, content, behavior);
889
1472
  });
890
1473
  client.on('messageUpdate', async (_oldMsg, nextMsg) => {
891
1474
  if (DISCORD_COMMANDS_ONLY)
@@ -898,15 +1481,28 @@ export function initDiscord(onMessage, onCommand) {
898
1481
  if (fetched.author?.bot)
899
1482
  return;
900
1483
  const updatedContent = cleanIncomingContent(fetched.content || '');
1484
+ const behavior = resolveChannelBehavior(fetched);
901
1485
  observeMessageParticipants(fetched, updatedContent);
902
- if (!isTrigger(fetched))
1486
+ if (!isTrigger(fetched, behavior)) {
1487
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
1488
+ return;
1489
+ }
1490
+ if (hasPrefixInvocation(fetched.content || '') &&
1491
+ !isAuthorizedCommandUserId(fetched.author.id)) {
1492
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
1493
+ return;
1494
+ }
1495
+ if (!shouldHandleFreeModeMessage(fetched, behavior, updatedContent)) {
1496
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
903
1497
  return;
904
- await updatePendingMessage(fetched.id, fetched, updatedContent);
1498
+ }
1499
+ await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
905
1500
  const inFlight = inFlightByMessageId.get(fetched.id);
906
1501
  if (!inFlight || inFlight.aborted)
907
1502
  return;
908
1503
  inFlight.aborted = true;
909
1504
  inFlight.abortController.abort();
1505
+ inFlight.emitLifecyclePhase('error');
910
1506
  for (const messageId of inFlight.messageIds) {
911
1507
  if (inFlightByMessageId.get(messageId) === inFlight) {
912
1508
  inFlightByMessageId.delete(messageId);
@@ -914,7 +1510,10 @@ export function initDiscord(onMessage, onCommand) {
914
1510
  }
915
1511
  await inFlight.stream.discard();
916
1512
  if (updatedContent) {
917
- await queueConversationMessage(fetched, updatedContent);
1513
+ const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
1514
+ if (!rateLimitAllowed)
1515
+ return;
1516
+ await queueConversationMessage(fetched, updatedContent, behavior);
918
1517
  }
919
1518
  });
920
1519
  client.on('messageDelete', async (msg) => {
@@ -924,6 +1523,7 @@ export function initDiscord(onMessage, onCommand) {
924
1523
  return;
925
1524
  inFlight.aborted = true;
926
1525
  inFlight.abortController.abort();
1526
+ inFlight.emitLifecyclePhase('error');
927
1527
  for (const messageId of inFlight.messageIds) {
928
1528
  if (inFlightByMessageId.get(messageId) === inFlight) {
929
1529
  inFlightByMessageId.delete(messageId);
@@ -956,6 +1556,11 @@ export function initDiscord(onMessage, onCommand) {
956
1556
  client.login(DISCORD_TOKEN);
957
1557
  return client;
958
1558
  }
1559
+ export async function setDiscordMaintenancePresence() {
1560
+ if (!presenceController)
1561
+ return;
1562
+ await presenceController.setMaintenance();
1563
+ }
959
1564
  /**
960
1565
  * Send a message to a channel by ID (used by scheduler).
961
1566
  */