@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,59 +1,98 @@
1
1
  import {
2
- ActivityType,
2
+ type ApplicationCommandDataResolvable,
3
3
  ApplicationCommandOptionType,
4
- AttachmentBuilder,
4
+ type AttachmentBuilder,
5
5
  Client,
6
- GatewayIntentBits,
7
- type ApplicationCommandDataResolvable,
8
6
  type Message as DiscordMessage,
7
+ GatewayIntentBits,
9
8
  Partials,
10
9
  } from 'discord.js';
11
10
 
12
11
  import {
12
+ DISCORD_ACK_REACTION,
13
+ DISCORD_ACK_REACTION_SCOPE,
14
+ DISCORD_COMMAND_ALLOWED_USER_IDS,
15
+ DISCORD_COMMAND_MODE,
13
16
  DISCORD_COMMAND_USER_ID,
14
17
  DISCORD_COMMANDS_ONLY,
18
+ DISCORD_DEBOUNCE_MS,
15
19
  DISCORD_FREE_RESPONSE_CHANNELS,
16
20
  DISCORD_GROUP_POLICY,
17
21
  DISCORD_GUILD_MEMBERS_INTENT,
18
22
  DISCORD_GUILDS,
19
- DISCORD_PRESENCE_INTENT,
23
+ DISCORD_HUMAN_DELAY,
24
+ DISCORD_LIFECYCLE_REACTIONS,
25
+ DISCORD_MAX_CONCURRENT_PER_CHANNEL,
20
26
  DISCORD_PREFIX,
27
+ DISCORD_PRESENCE_INTENT,
28
+ DISCORD_RATE_LIMIT_EXEMPT_ROLES,
29
+ DISCORD_RATE_LIMIT_PER_USER,
30
+ DISCORD_REMOVE_ACK_AFTER_REPLY,
21
31
  DISCORD_RESPOND_TO_ALL_MESSAGES,
32
+ DISCORD_SELF_PRESENCE,
33
+ DISCORD_SUPPRESS_PATTERNS,
22
34
  DISCORD_TOKEN,
35
+ DISCORD_TYPING_MODE,
23
36
  } from '../../config.js';
37
+ import { logger } from '../../logger.js';
38
+ import type { MediaContextItem } from '../../types.js';
24
39
  import { buildAttachmentContext } from './attachments.js';
40
+ import {
41
+ DEFAULT_DEBOUNCE_MAX_BUFFER,
42
+ resolveInboundDebounceMs,
43
+ shouldDebounceInbound,
44
+ } from './debounce.js';
45
+ import {
46
+ formatError,
47
+ prepareChunkedPayloads,
48
+ sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
49
+ sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
50
+ sendChunkedReply as sendChunkedReplyFromDelivery,
51
+ } from './delivery.js';
52
+ import type { HumanDelayConfig } from './human-delay.js';
25
53
  import {
26
54
  buildSessionIdFromContext as buildSessionIdFromContextInbound,
27
55
  cleanIncomingContent as cleanIncomingContentInbound,
56
+ type DiscordGuildMessageMode,
57
+ hasLooseBotMention as hasLooseBotMentionInbound,
28
58
  hasPrefixInvocation as hasPrefixInvocationInbound,
59
+ hasSlashCommandInvocation as hasSlashCommandInvocationInbound,
60
+ isAddressedToChannel as isAddressedToChannelInbound,
61
+ isAuthorizedCommandUser as isAuthorizedCommandUserInbound,
29
62
  isTrigger as isTriggerInbound,
30
- parseCommand as parseCommandInbound,
31
- type DiscordGuildMessageMode,
32
63
  type ParsedCommand,
64
+ parseCommand as parseCommandInbound,
65
+ shouldReplyInFreeMode as shouldReplyInFreeModeInbound,
66
+ shouldSkipFreeReplyBecauseOtherUsersMentioned as shouldSkipFreeReplyBecauseOtherUsersMentionedInbound,
33
67
  } from './inbound.js';
34
68
  import {
35
69
  addMentionAlias,
36
70
  extractMentionAliasHints,
37
- normalizeMentionAlias,
38
71
  type MentionLookup,
72
+ normalizeMentionAlias,
39
73
  } from './mentions.js';
40
74
  import {
41
- formatError,
42
- prepareChunkedPayloads,
43
- sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
44
- sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
45
- sendChunkedReply as sendChunkedReplyFromDelivery,
46
- } from './delivery.js';
75
+ DiscordAutoPresenceController,
76
+ type PresenceHealthState,
77
+ } from './presence.js';
78
+ import { SlidingWindowRateLimiter } from './rate-limiter.js';
79
+ import {
80
+ addAckReaction,
81
+ type LifecyclePhase,
82
+ LifecycleReactionController,
83
+ } from './reactions.js';
84
+ import { DiscordStreamManager } from './stream.js';
47
85
  import {
48
- createDiscordToolActionRunner,
49
86
  type CachedDiscordPresence,
87
+ createDiscordToolActionRunner,
50
88
  type DiscordToolActionRequest,
51
89
  } from './tool-actions.js';
52
- import { DiscordStreamManager } from './stream.js';
53
- import { logger } from '../../logger.js';
54
- import type { MediaContextItem } from '../../types.js';
90
+ import { createTypingController } from './typing.js';
55
91
 
56
- export type ReplyFn = (content: string, files?: AttachmentBuilder[]) => Promise<void>;
92
+ export type ReplyFn = (
93
+ content: string,
94
+ files?: AttachmentBuilder[],
95
+ ) => Promise<void>;
57
96
 
58
97
  interface PendingGuildHistoryEntry {
59
98
  messageId: string;
@@ -76,6 +115,7 @@ export interface MessageRunContext {
76
115
  abortSignal: AbortSignal;
77
116
  stream: DiscordStreamManager;
78
117
  mentionLookup: MentionLookup;
118
+ emitLifecyclePhase: (phase: LifecyclePhase) => void;
79
119
  }
80
120
 
81
121
  export type MessageHandler = (
@@ -94,6 +134,8 @@ export type CommandHandler = (
94
134
  sessionId: string,
95
135
  guildId: string | null,
96
136
  channelId: string,
137
+ userId: string,
138
+ username: string,
97
139
  args: string[],
98
140
  reply: ReplyFn,
99
141
  ) => Promise<void>;
@@ -103,7 +145,6 @@ let messageHandler: MessageHandler;
103
145
  let commandHandler: CommandHandler;
104
146
  let activeConversationRuns = 0;
105
147
  let botMentionRegex: RegExp | null = null;
106
- const MESSAGE_DEBOUNCE_MS = 2_500;
107
148
  const DISCORD_RETRY_MAX_ATTEMPTS = 3;
108
149
  const DISCORD_RETRY_BASE_DELAY_MS = 500;
109
150
  const GUILD_INBOUND_HISTORY_LIMIT = 20;
@@ -113,8 +154,44 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
113
154
  const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
114
155
  const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
115
156
  const MAX_PRESENCE_CACHE_USERS = 5_000;
157
+ const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
158
+ const CONCURRENCY_RETRY_DELAY_MS = 250;
159
+ const PRESENCE_WINDOW_MS = 5 * 60_000;
160
+ const PRESENCE_DEGRADED_DURATION_MS = 45_000;
161
+ const PRESENCE_EXHAUSTED_ERROR_RE =
162
+ /(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
163
+ const FRIENDLY_RATE_LIMIT_MESSAGE =
164
+ "You're sending messages too fast — give me a moment to catch up!";
165
+ const READ_WITHOUT_REPLY_RE =
166
+ /^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
167
+ const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
168
+ const STARTUP_STAGGER_WINDOW_MS = 120_000;
169
+ const STARTUP_STAGGER_MIN_DELAY_MS = 500;
170
+ const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
171
+ const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
172
+ const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
173
+ const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
174
+ const NIGHT_HOURS_START = 22;
175
+ const NIGHT_HOURS_END = 7;
176
+ const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
177
+ const CONVERSATION_COOLDOWN_THRESHOLD = 5;
178
+ const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
116
179
 
117
180
  const discordPresenceCache = new Map<string, CachedDiscordPresence>();
181
+ const userRateLimiter = new SlidingWindowRateLimiter(60_000);
182
+ const recentConversationMetrics: Array<{
183
+ atMs: number;
184
+ durationMs: number;
185
+ ok: boolean;
186
+ exhaustedHint: boolean;
187
+ }> = [];
188
+ let consecutiveConversationFailures = 0;
189
+ let presenceController: DiscordAutoPresenceController | null = null;
190
+ let startupConnectedAtMs = 0;
191
+ const conversationExchangeByKey = new Map<
192
+ string,
193
+ { count: number; lastAtMs: number }
194
+ >();
118
195
 
119
196
  function setDiscordPresence(userId: string, data: CachedDiscordPresence): void {
120
197
  discordPresenceCache.set(userId, data);
@@ -138,7 +215,10 @@ function buildMentionLookup(
138
215
  const lookup: MentionLookup = { byAlias: new Map<string, Set<string>>() };
139
216
  const botUserId = client.user?.id || '';
140
217
 
141
- const addUser = (userId: string, aliases: Array<string | null | undefined>): void => {
218
+ const addUser = (
219
+ userId: string,
220
+ aliases: Array<string | null | undefined>,
221
+ ): void => {
142
222
  if (!userId || userId === botUserId) return;
143
223
  for (const alias of aliases) {
144
224
  addMentionAlias(lookup, alias, userId);
@@ -153,7 +233,8 @@ function buildMentionLookup(
153
233
  for (const mentioned of msg.mentions.users.values()) {
154
234
  const aliases = [mentioned.username];
155
235
  const mentionedMember = msg.mentions.members?.get(mentioned.id);
156
- if (mentionedMember?.displayName) aliases.push(mentionedMember.displayName);
236
+ if (mentionedMember?.displayName)
237
+ aliases.push(mentionedMember.displayName);
157
238
  addUser(mentioned.id, aliases);
158
239
  }
159
240
 
@@ -182,18 +263,25 @@ function summarizePendingHistoryEntry(entry: PendingGuildHistoryEntry): string {
182
263
  const author = entry.displayName || entry.username || 'user';
183
264
  const authorLabel = entry.isBot ? `${author} [bot]` : author;
184
265
  const content = entry.content.trim();
185
- const snippet = content.length > 300 ? `${content.slice(0, 297)}...` : content;
266
+ const snippet =
267
+ content.length > 300 ? `${content.slice(0, 297)}...` : content;
186
268
  return `${authorLabel}: ${snippet}`;
187
269
  }
188
270
 
189
- function buildPendingHistoryContext(entries: PendingGuildHistoryEntry[]): string {
271
+ function buildPendingHistoryContext(
272
+ entries: PendingGuildHistoryEntry[],
273
+ ): string {
190
274
  if (entries.length === 0) return '';
191
275
  const selected: string[] = [];
192
276
  let totalChars = 0;
193
277
  for (let i = entries.length - 1; i >= 0; i -= 1) {
194
278
  const line = summarizePendingHistoryEntry(entries[i]);
195
279
  if (!line) continue;
196
- if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS && selected.length > 0) break;
280
+ if (
281
+ totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
282
+ selected.length > 0
283
+ )
284
+ break;
197
285
  selected.push(line);
198
286
  totalChars += line.length + 1;
199
287
  }
@@ -212,10 +300,13 @@ async function buildInboundHistorySnapshot(
212
300
  msg: DiscordMessage,
213
301
  excludeMessageIds: Set<string>,
214
302
  ): Promise<{ entries: PendingGuildHistoryEntry[]; context: string }> {
215
- if (!msg.guild || !('messages' in msg.channel)) return { entries: [], context: '' };
303
+ if (!msg.guild || !('messages' in msg.channel))
304
+ return { entries: [], context: '' };
216
305
 
217
306
  try {
218
- const recentMessages = await msg.channel.messages.fetch({ limit: GUILD_INBOUND_HISTORY_LIMIT });
307
+ const recentMessages = await msg.channel.messages.fetch({
308
+ limit: GUILD_INBOUND_HISTORY_LIMIT,
309
+ });
219
310
  const entries: PendingGuildHistoryEntry[] = [];
220
311
  let hiddenTextCount = 0;
221
312
  let hiddenBotTextCount = 0;
@@ -225,7 +316,11 @@ async function buildInboundHistorySnapshot(
225
316
  if (plainText) return plainText;
226
317
 
227
318
  const embedChunks = recent.embeds
228
- .map((embed) => [embed.title?.trim(), embed.description?.trim()].filter(Boolean).join(' — '))
319
+ .map((embed) =>
320
+ [embed.title?.trim(), embed.description?.trim()]
321
+ .filter(Boolean)
322
+ .join(' — '),
323
+ )
229
324
  .map((part) => part.trim())
230
325
  .filter(Boolean)
231
326
  .slice(0, 3);
@@ -241,7 +336,9 @@ async function buildInboundHistorySnapshot(
241
336
  return `[attachments] ${attachmentNames.join(', ')}`;
242
337
  }
243
338
 
244
- const systemContent = recent.system ? (recent.cleanContent || '').trim() : '';
339
+ const systemContent = recent.system
340
+ ? (recent.cleanContent || '').trim()
341
+ : '';
245
342
  if (systemContent) return `[system] ${systemContent}`;
246
343
 
247
344
  hiddenTextCount += 1;
@@ -261,11 +358,16 @@ async function buildInboundHistorySnapshot(
261
358
  username: recent.author.username || 'user',
262
359
  displayName: recent.member?.displayName || null,
263
360
  isBot: Boolean(recent.author.bot),
264
- timestampMs: Number.isFinite(recent.createdTimestamp) ? recent.createdTimestamp : 0,
361
+ timestampMs: Number.isFinite(recent.createdTimestamp)
362
+ ? recent.createdTimestamp
363
+ : 0,
265
364
  content,
266
365
  });
267
366
  }
268
- entries.sort((a, b) => a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId));
367
+ entries.sort(
368
+ (a, b) =>
369
+ a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId),
370
+ );
269
371
  let context = buildPendingHistoryContext(entries);
270
372
  if (hiddenTextCount > 0) {
271
373
  const visibilityNote = [
@@ -282,18 +384,26 @@ async function buildInboundHistorySnapshot(
282
384
  context,
283
385
  };
284
386
  } catch (error) {
285
- logger.debug({ error, guildId: msg.guild.id, channelId: msg.channelId }, 'Failed to build inbound channel history snapshot');
387
+ logger.debug(
388
+ { error, guildId: msg.guild.id, channelId: msg.channelId },
389
+ 'Failed to build inbound channel history snapshot',
390
+ );
286
391
  return { entries: [], context: '' };
287
392
  }
288
393
  }
289
394
 
290
- function addParticipantAlias(info: ParticipantInfo, alias: string | null | undefined): void {
395
+ function addParticipantAlias(
396
+ info: ParticipantInfo,
397
+ alias: string | null | undefined,
398
+ ): void {
291
399
  const normalized = normalizeMentionAlias(alias);
292
400
  if (!normalized) return;
293
401
  info.aliases.add(normalized);
294
402
  }
295
403
 
296
- function formatDiscordHandleFromAlias(alias: string | null | undefined): string | null {
404
+ function formatDiscordHandleFromAlias(
405
+ alias: string | null | undefined,
406
+ ): string | null {
297
407
  const normalized = normalizeMentionAlias(alias);
298
408
  if (!normalized) return null;
299
409
  return `@${normalized}`;
@@ -376,7 +486,8 @@ function buildParticipantContext(
376
486
  .slice(0, PARTICIPANT_CONTEXT_MAX_USERS)
377
487
  .map((entry) => {
378
488
  const aliases = Array.from(entry.aliases).slice(0, 3);
379
- const preferredHandle = formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
489
+ const preferredHandle =
490
+ formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
380
491
  const botSuffix = botParticipantIds.has(entry.id) ? ' [bot]' : '';
381
492
  return `- ${preferredHandle}${botSuffix} id:${entry.id} aliases: ${aliases.join(', ')}`;
382
493
  });
@@ -422,23 +533,51 @@ export async function runDiscordToolAction(
422
533
  }
423
534
 
424
535
  function getSessionId(msg: DiscordMessage): string {
425
- return buildSessionIdFromContext(msg.guild?.id ?? null, msg.channelId, msg.author.id);
536
+ return buildSessionIdFromContext(
537
+ msg.guild?.id ?? null,
538
+ msg.channelId,
539
+ msg.author.id,
540
+ );
426
541
  }
427
542
 
428
543
  function hasPrefixInvocation(content: string): boolean {
429
544
  return hasPrefixInvocationInbound(content, botMentionRegex, DISCORD_PREFIX);
430
545
  }
431
546
 
547
+ function hasSlashCommandInvocation(content: string): boolean {
548
+ return hasSlashCommandInvocationInbound(content, botMentionRegex);
549
+ }
550
+
432
551
  function isAuthorizedCommandUserId(userId: string): boolean {
433
- const configuredUserId = DISCORD_COMMAND_USER_ID.trim();
434
- if (!configuredUserId) return true;
435
- return userId === configuredUserId;
552
+ return isAuthorizedCommandUserInbound({
553
+ mode: DISCORD_COMMAND_MODE,
554
+ userId,
555
+ allowedUserIds: DISCORD_COMMAND_ALLOWED_USER_IDS,
556
+ legacyCommandUserId: DISCORD_COMMAND_USER_ID,
557
+ });
436
558
  }
437
559
 
438
- function buildSessionIdFromContext(guildId: string | null, channelId: string, userId: string): string {
560
+ function buildSessionIdFromContext(
561
+ guildId: string | null,
562
+ channelId: string,
563
+ userId: string,
564
+ ): string {
439
565
  return buildSessionIdFromContextInbound(guildId, channelId, userId);
440
566
  }
441
567
 
568
+ interface ResolvedChannelBehavior {
569
+ guildMessageMode: DiscordGuildMessageMode;
570
+ typingMode: 'instant' | 'thinking' | 'streaming' | 'never';
571
+ debounceMs: number;
572
+ ackReaction: string;
573
+ ackReactionScope: 'all' | 'group-mentions' | 'direct' | 'off';
574
+ removeAckAfterReply: boolean;
575
+ humanDelay: HumanDelayConfig;
576
+ rateLimitPerUser: number;
577
+ suppressPatterns: string[];
578
+ maxConcurrentPerChannel: number;
579
+ }
580
+
442
581
  function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
443
582
  if (!msg.guild) return 'free';
444
583
  if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
@@ -455,19 +594,133 @@ function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
455
594
  return 'mention';
456
595
  }
457
596
 
458
- function isTrigger(msg: DiscordMessage): boolean {
597
+ function resolveChannelBehavior(msg: DiscordMessage): ResolvedChannelBehavior {
598
+ const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
599
+ const channelConfig = guildConfig?.channels[msg.channelId];
600
+ return {
601
+ guildMessageMode: resolveGuildMessageMode(msg),
602
+ typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
603
+ debounceMs: resolveInboundDebounceMs(
604
+ DISCORD_DEBOUNCE_MS,
605
+ channelConfig?.debounceMs,
606
+ ),
607
+ ackReaction:
608
+ (channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
609
+ DISCORD_ACK_REACTION,
610
+ ackReactionScope:
611
+ channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
612
+ removeAckAfterReply:
613
+ channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
614
+ humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
615
+ rateLimitPerUser: Math.max(
616
+ 0,
617
+ channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER,
618
+ ),
619
+ suppressPatterns:
620
+ channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
621
+ maxConcurrentPerChannel: Math.max(
622
+ 1,
623
+ channelConfig?.maxConcurrentPerChannel ??
624
+ DISCORD_MAX_CONCURRENT_PER_CHANNEL,
625
+ ),
626
+ };
627
+ }
628
+
629
+ function isTrigger(
630
+ msg: DiscordMessage,
631
+ behavior: ResolvedChannelBehavior,
632
+ ): boolean {
459
633
  return isTriggerInbound({
460
634
  content: msg.content,
461
635
  isDm: !msg.guild,
462
636
  commandsOnly: DISCORD_COMMANDS_ONLY,
463
637
  respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
464
- guildMessageMode: resolveGuildMessageMode(msg),
638
+ guildMessageMode: behavior.guildMessageMode,
465
639
  prefix: DISCORD_PREFIX,
466
640
  botMentionRegex,
467
641
  hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
642
+ suppressPatterns: behavior.suppressPatterns,
468
643
  });
469
644
  }
470
645
 
646
+ function shouldHandleFreeModeMessage(
647
+ msg: DiscordMessage,
648
+ behavior: ResolvedChannelBehavior,
649
+ content: string,
650
+ ): boolean {
651
+ if (!msg.guild) return true;
652
+
653
+ const hasPrefixedInvocation = hasPrefixInvocation(msg.content || '');
654
+ const hasBotMention = Boolean(client.user && msg.mentions.has(client.user));
655
+ const hasLooseBotMention =
656
+ client.user != null
657
+ ? hasLooseBotMentionInbound(content, [
658
+ client.user.username,
659
+ client.user.globalName || '',
660
+ msg.guild?.members.me?.displayName || '',
661
+ ])
662
+ : false;
663
+ const isReplyToBot = Boolean(
664
+ client.user && msg.mentions.repliedUser?.id === client.user.id,
665
+ );
666
+ const mentionedUserIds = Array.from(msg.mentions.users.keys());
667
+ const botUserId = client.user?.id ?? null;
668
+
669
+ if (
670
+ shouldSkipFreeReplyBecauseOtherUsersMentionedInbound({
671
+ guildMessageMode: behavior.guildMessageMode,
672
+ hasBotMention,
673
+ hasPrefixInvocation: hasPrefixedInvocation,
674
+ botUserId,
675
+ mentionedUserIds,
676
+ })
677
+ ) {
678
+ return false;
679
+ }
680
+
681
+ return shouldReplyInFreeModeInbound({
682
+ guildMessageMode: behavior.guildMessageMode,
683
+ content,
684
+ hasBotMention,
685
+ hasLooseBotMention,
686
+ isAddressedToChannel: isAddressedToChannelInbound(content),
687
+ hasPrefixInvocation: hasPrefixedInvocation,
688
+ isReplyToBot,
689
+ hasAttachments: msg.attachments.size > 0,
690
+ });
691
+ }
692
+
693
+ function shouldApplyAckReaction(
694
+ msg: DiscordMessage,
695
+ behavior: ResolvedChannelBehavior,
696
+ ): boolean {
697
+ const scope = behavior.ackReactionScope;
698
+ if (scope === 'off') return false;
699
+ if (scope === 'all') return true;
700
+ if (scope === 'direct') return !msg.guild;
701
+ if (!msg.guild || !client.user) return false;
702
+ return msg.mentions.has(client.user);
703
+ }
704
+
705
+ function isRateLimitExempt(msg: DiscordMessage): boolean {
706
+ if (msg.author.id === DISCORD_COMMAND_USER_ID.trim()) return true;
707
+ if (!msg.guild) return false;
708
+ if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0) return false;
709
+
710
+ const exemptByName = new Set(
711
+ DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) =>
712
+ role.trim().toLowerCase(),
713
+ ).filter(Boolean),
714
+ );
715
+ if (exemptByName.size === 0) return false;
716
+
717
+ for (const role of msg.member.roles.cache.values()) {
718
+ const normalized = role.name.trim().toLowerCase();
719
+ if (exemptByName.has(normalized)) return true;
720
+ }
721
+ return false;
722
+ }
723
+
471
724
  function parseCommand(content: string): ParsedCommand {
472
725
  return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
473
726
  }
@@ -475,19 +728,29 @@ function parseCommand(content: string): ParsedCommand {
475
728
  function isRetryableDiscordError(error: unknown): boolean {
476
729
  const maybe = error as DiscordErrorLike;
477
730
  const status = maybe.status ?? maybe.httpStatus;
478
- return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
731
+ return (
732
+ status === 429 ||
733
+ (typeof status === 'number' && status >= 500 && status <= 599)
734
+ );
479
735
  }
480
736
 
481
737
  function retryDelayMs(error: unknown, fallbackMs: number): number {
482
738
  const maybe = error as DiscordErrorLike;
483
739
  const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
484
- if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
740
+ if (
741
+ typeof retryAfterSeconds === 'number' &&
742
+ Number.isFinite(retryAfterSeconds) &&
743
+ retryAfterSeconds > 0
744
+ ) {
485
745
  return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
486
746
  }
487
747
  return fallbackMs + Math.floor(Math.random() * 250);
488
748
  }
489
749
 
490
- async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
750
+ async function withDiscordRetry<T>(
751
+ label: string,
752
+ fn: () => Promise<T>,
753
+ ): Promise<T> {
491
754
  let attempt = 0;
492
755
  let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
493
756
  while (true) {
@@ -495,11 +758,17 @@ async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise
495
758
  try {
496
759
  return await fn();
497
760
  } catch (error) {
498
- if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
761
+ if (
762
+ attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
763
+ !isRetryableDiscordError(error)
764
+ ) {
499
765
  throw error;
500
766
  }
501
767
  const waitMs = retryDelayMs(error, delayMs);
502
- logger.warn({ label, attempt, waitMs, error }, 'Discord API call failed; retrying');
768
+ logger.warn(
769
+ { label, attempt, waitMs, error },
770
+ 'Discord API call failed; retrying',
771
+ );
503
772
  await new Promise((resolve) => setTimeout(resolve, waitMs));
504
773
  delayMs = Math.min(delayMs * 2, 4_000);
505
774
  }
@@ -513,7 +782,8 @@ function cleanIncomingContent(content: string): string {
513
782
  function summarizeContextMessage(msg: DiscordMessage): string {
514
783
  const author = msg.author?.username || 'user';
515
784
  const content = (msg.content || '').trim();
516
- const snippet = content.length > 500 ? `${content.slice(0, 497)}...` : content;
785
+ const snippet =
786
+ content.length > 500 ? `${content.slice(0, 497)}...` : content;
517
787
  return `${author}: ${snippet || '(no text)'}`;
518
788
  }
519
789
 
@@ -526,16 +796,25 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
526
796
  `- channel_id: ${msg.channelId}`,
527
797
  ];
528
798
 
529
- const namedChannel = msg.channel as unknown as { name?: string; topic?: string; parent?: { name?: string | null } | null };
530
- const channelName = typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
799
+ const namedChannel = msg.channel as unknown as {
800
+ name?: string;
801
+ topic?: string;
802
+ parent?: { name?: string | null } | null;
803
+ };
804
+ const channelName =
805
+ typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
531
806
  if (channelName) {
532
807
  lines.push(`- channel_name: #${channelName}`);
533
808
  }
534
- const channelTopic = typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
809
+ const channelTopic =
810
+ typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
535
811
  if (channelTopic) {
536
812
  lines.push(`- channel_topic: ${channelTopic}`);
537
813
  }
538
- const parentName = typeof namedChannel.parent?.name === 'string' ? namedChannel.parent.name.trim() : '';
814
+ const parentName =
815
+ typeof namedChannel.parent?.name === 'string'
816
+ ? namedChannel.parent.name.trim()
817
+ : '';
539
818
  if (parentName) {
540
819
  lines.push(`- parent_channel: ${parentName}`);
541
820
  }
@@ -547,14 +826,21 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
547
826
  async function buildReplyContext(msg: DiscordMessage): Promise<string> {
548
827
  const blocks: string[] = [];
549
828
 
550
- if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
829
+ if (
830
+ 'isThread' in msg.channel &&
831
+ typeof msg.channel.isThread === 'function' &&
832
+ msg.channel.isThread()
833
+ ) {
551
834
  try {
552
835
  const starter = await msg.channel.fetchStarterMessage();
553
836
  if (starter) {
554
837
  blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
555
838
  }
556
839
  } catch (error) {
557
- logger.debug({ error, channelId: msg.channelId }, 'Failed to fetch thread starter message');
840
+ logger.debug(
841
+ { error, channelId: msg.channelId },
842
+ 'Failed to fetch thread starter message',
843
+ );
558
844
  }
559
845
  }
560
846
 
@@ -579,63 +865,18 @@ async function buildReplyContext(msg: DiscordMessage): Promise<string> {
579
865
  return `${blocks.join('\n\n')}\n\n`;
580
866
  }
581
867
 
582
- async function addProcessingReaction(msg: DiscordMessage): Promise<() => Promise<void>> {
583
- if (!client.user) return async () => {};
584
- const botUserId = client.user.id;
585
- try {
586
- await withDiscordRetry('react', () => msg.react('👀'));
587
- } catch (error) {
588
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
589
- return async () => {};
590
- }
591
-
592
- return async () => {
593
- try {
594
- const reaction = msg.reactions.resolve('👀');
595
- if (!reaction) return;
596
- await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
597
- } catch (error) {
598
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
599
- }
600
- };
601
- }
602
-
603
- function startTypingLoop(msg: DiscordMessage): { stop: () => void } {
604
- let stopped = false;
605
- const sendTyping = async (): Promise<void> => {
606
- if (stopped) return;
607
- if (!('sendTyping' in msg.channel)) return;
608
- try {
609
- await msg.channel.sendTyping();
610
- } catch (error) {
611
- logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
612
- }
613
- };
614
-
615
- void sendTyping();
616
- const timer = setInterval(() => {
617
- void sendTyping();
618
- }, 8_000);
619
-
620
- return {
621
- stop: () => {
622
- if (stopped) return;
623
- stopped = true;
624
- clearInterval(timer);
625
- },
626
- };
627
- }
628
-
629
868
  async function sendChunkedReply(
630
869
  msg: DiscordMessage,
631
870
  text: string,
632
871
  files?: AttachmentBuilder[],
633
872
  mentionLookup?: MentionLookup,
873
+ humanDelay?: HumanDelayConfig,
634
874
  ): Promise<void> {
635
875
  await sendChunkedReplyFromDelivery({
636
876
  msg,
637
877
  text,
638
878
  withRetry: withDiscordRetry,
879
+ ...(humanDelay ? { humanDelay } : {}),
639
880
  ...(files ? { files } : {}),
640
881
  ...(mentionLookup ? { mentionLookup } : {}),
641
882
  });
@@ -646,18 +887,22 @@ async function sendChunkedDirectReply(
646
887
  text: string,
647
888
  files?: AttachmentBuilder[],
648
889
  mentionLookup?: MentionLookup,
890
+ humanDelay?: HumanDelayConfig,
649
891
  ): Promise<void> {
650
892
  await sendChunkedDirectReplyFromDelivery({
651
893
  msg,
652
894
  text,
653
895
  withRetry: withDiscordRetry,
896
+ ...(humanDelay ? { humanDelay } : {}),
654
897
  ...(files ? { files } : {}),
655
898
  ...(mentionLookup ? { mentionLookup } : {}),
656
899
  });
657
900
  }
658
901
 
659
902
  async function sendChunkedInteractionReply(
660
- interaction: Parameters<typeof sendChunkedInteractionReplyFromDelivery>[0]['interaction'],
903
+ interaction: Parameters<
904
+ typeof sendChunkedInteractionReplyFromDelivery
905
+ >[0]['interaction'],
661
906
  text: string,
662
907
  files?: AttachmentBuilder[],
663
908
  ): Promise<void> {
@@ -673,6 +918,7 @@ async function ensureSlashCommands(): Promise<void> {
673
918
  interface SlashCommandDefinition {
674
919
  name: string;
675
920
  description: string;
921
+ dmPermission?: boolean;
676
922
  options?: Array<{
677
923
  type: ApplicationCommandOptionType.String;
678
924
  name: string;
@@ -686,6 +932,33 @@ async function ensureSlashCommands(): Promise<void> {
686
932
  {
687
933
  name: 'status',
688
934
  description: 'Show HybridClaw runtime status (only visible to you)',
935
+ dmPermission: true,
936
+ },
937
+ {
938
+ name: 'approve',
939
+ description: 'View/respond to pending tool approval requests (private)',
940
+ dmPermission: true,
941
+ options: [
942
+ {
943
+ type: ApplicationCommandOptionType.String,
944
+ name: 'action',
945
+ description: 'Action to perform',
946
+ required: false,
947
+ choices: [
948
+ { name: 'view', value: 'view' },
949
+ { name: 'yes', value: 'yes' },
950
+ { name: 'session', value: 'session' },
951
+ { name: 'agent', value: 'agent' },
952
+ { name: 'no', value: 'no' },
953
+ ],
954
+ },
955
+ {
956
+ type: ApplicationCommandOptionType.String,
957
+ name: 'approval_id',
958
+ description: 'Optional approval id (defaults to latest pending)',
959
+ required: false,
960
+ },
961
+ ],
689
962
  },
690
963
  {
691
964
  name: 'channel-mode',
@@ -704,70 +977,251 @@ async function ensureSlashCommands(): Promise<void> {
704
977
  },
705
978
  ],
706
979
  },
980
+ {
981
+ name: 'channel-policy',
982
+ description: 'Set guild channel policy to open, allowlist, or disabled',
983
+ options: [
984
+ {
985
+ type: ApplicationCommandOptionType.String,
986
+ name: 'policy',
987
+ description: 'Guild channel policy',
988
+ required: true,
989
+ choices: [
990
+ { name: 'open', value: 'open' },
991
+ { name: 'allowlist', value: 'allowlist' },
992
+ { name: 'disabled', value: 'disabled' },
993
+ ],
994
+ },
995
+ ],
996
+ },
707
997
  ];
708
998
 
709
999
  if (!client.application) return;
1000
+ const globalDefinitions = definitions.filter(
1001
+ (definition) =>
1002
+ definition.name === 'status' || definition.name === 'approve',
1003
+ );
1004
+ try {
1005
+ for (const definition of globalDefinitions) {
1006
+ // POST is an upsert by name for global commands. Keep command IDs stable
1007
+ // to avoid stale-client command references in DMs.
1008
+ await client.application.commands.create(
1009
+ definition as unknown as ApplicationCommandDataResolvable,
1010
+ );
1011
+ logger.info(
1012
+ { scope: 'global', command: definition.name },
1013
+ 'Upserted slash command',
1014
+ );
1015
+ }
1016
+ } catch (error) {
1017
+ logger.warn({ error }, 'Failed to register global slash commands');
1018
+ }
1019
+
710
1020
  await Promise.allSettled(
711
1021
  [...client.guilds.cache.values()].map(async (guild) => {
712
1022
  try {
713
1023
  const existing = await guild.commands.fetch();
714
1024
  for (const definition of definitions) {
715
- const current = existing.find((command) => command.name === definition.name);
1025
+ const current = existing.find(
1026
+ (command) => command.name === definition.name,
1027
+ );
716
1028
  if (!current) {
717
- await guild.commands.create(definition as unknown as ApplicationCommandDataResolvable);
718
- logger.info({ guildId: guild.id, command: definition.name }, 'Registered slash command');
1029
+ await guild.commands.create(
1030
+ definition as unknown as ApplicationCommandDataResolvable,
1031
+ );
1032
+ logger.info(
1033
+ { guildId: guild.id, command: definition.name },
1034
+ 'Registered slash command',
1035
+ );
719
1036
  continue;
720
1037
  }
721
- await guild.commands.edit(current.id, definition as unknown as ApplicationCommandDataResolvable);
722
- logger.info({ guildId: guild.id, command: definition.name }, 'Updated slash command');
1038
+ await guild.commands.edit(
1039
+ current.id,
1040
+ definition as unknown as ApplicationCommandDataResolvable,
1041
+ );
1042
+ logger.info(
1043
+ { guildId: guild.id, command: definition.name },
1044
+ 'Updated slash command',
1045
+ );
723
1046
  }
724
1047
  } catch (error) {
725
- logger.warn({ error, guildId: guild.id }, 'Failed to register Discord slash commands');
1048
+ logger.warn(
1049
+ { error, guildId: guild.id },
1050
+ 'Failed to register Discord slash commands',
1051
+ );
726
1052
  }
727
1053
  }),
728
1054
  );
729
1055
  }
730
1056
 
731
- function updatePresence(): void {
732
- if (!client.user) return;
733
- if (activeConversationRuns > 0) {
734
- client.user.setPresence({
735
- activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
736
- status: 'online',
737
- });
1057
+ function trimRecentConversationMetrics(nowMs = Date.now()): void {
1058
+ const cutoff = nowMs - PRESENCE_WINDOW_MS;
1059
+ while (
1060
+ recentConversationMetrics.length > 0 &&
1061
+ recentConversationMetrics[0].atMs < cutoff
1062
+ ) {
1063
+ recentConversationMetrics.shift();
1064
+ }
1065
+ }
1066
+
1067
+ function recordConversationMetric(params: {
1068
+ durationMs: number;
1069
+ ok: boolean;
1070
+ error?: unknown;
1071
+ }): void {
1072
+ const nowMs = Date.now();
1073
+ const errorText =
1074
+ params.error instanceof Error
1075
+ ? params.error.message
1076
+ : String(params.error || '');
1077
+ const exhaustedHint =
1078
+ !params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
1079
+ if (params.ok) {
1080
+ consecutiveConversationFailures = 0;
1081
+ } else {
1082
+ consecutiveConversationFailures += 1;
1083
+ }
1084
+ recentConversationMetrics.push({
1085
+ atMs: nowMs,
1086
+ durationMs: Math.max(0, Math.floor(params.durationMs)),
1087
+ ok: params.ok,
1088
+ exhaustedHint,
1089
+ });
1090
+ trimRecentConversationMetrics(nowMs);
1091
+ }
1092
+
1093
+ function resolvePresenceHealthState(): PresenceHealthState {
1094
+ trimRecentConversationMetrics();
1095
+ if (activeConversationRuns >= 4) return 'degraded';
1096
+ if (consecutiveConversationFailures >= 3) return 'exhausted';
1097
+ if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
1098
+ return 'exhausted';
1099
+
1100
+ if (recentConversationMetrics.length === 0) return 'healthy';
1101
+ const totalDuration = recentConversationMetrics.reduce(
1102
+ (sum, entry) => sum + entry.durationMs,
1103
+ 0,
1104
+ );
1105
+ const avgDuration = totalDuration / recentConversationMetrics.length;
1106
+ const failureCount = recentConversationMetrics.filter(
1107
+ (entry) => !entry.ok,
1108
+ ).length;
1109
+ const errorRate = failureCount / recentConversationMetrics.length;
1110
+ if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
1111
+ return 'degraded';
1112
+ return 'healthy';
1113
+ }
1114
+
1115
+ function randomIntInRange(minMs: number, maxMs: number): number {
1116
+ const lo = Math.floor(Math.max(0, minMs));
1117
+ const hi = Math.floor(Math.max(lo, maxMs));
1118
+ if (hi <= lo) return lo;
1119
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
1120
+ }
1121
+
1122
+ function isNightOrWeekend(now: Date): boolean {
1123
+ const day = now.getDay();
1124
+ const hour = now.getHours();
1125
+ const weekend = day === 0 || day === 6;
1126
+ const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
1127
+ return weekend || night;
1128
+ }
1129
+
1130
+ function buildConversationCooldownKey(
1131
+ channelId: string,
1132
+ userId: string,
1133
+ ): string {
1134
+ return `${channelId}:${userId}`;
1135
+ }
1136
+
1137
+ function resolveHumanDelayWithBehavior(
1138
+ base: HumanDelayConfig,
1139
+ cooldownKey: string,
1140
+ ): HumanDelayConfig {
1141
+ if (base.mode === 'off') return base;
1142
+ const now = new Date();
1143
+ let factor = isNightOrWeekend(now) ? 1.5 : 1;
1144
+ const record = conversationExchangeByKey.get(cooldownKey);
1145
+ if (record) {
1146
+ const elapsedMs = Date.now() - record.lastAtMs;
1147
+ if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
1148
+ conversationExchangeByKey.delete(cooldownKey);
1149
+ } else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
1150
+ const extra = Math.min(
1151
+ CONVERSATION_COOLDOWN_MAX_FACTOR - 1,
1152
+ (record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12,
1153
+ );
1154
+ factor += Math.max(0, extra);
1155
+ }
1156
+ }
1157
+
1158
+ if (factor <= 1) return base;
1159
+ const minMs = Math.round((base.minMs ?? 800) * factor);
1160
+ const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
1161
+ return {
1162
+ mode: 'custom',
1163
+ minMs,
1164
+ maxMs: Math.max(minMs, maxMs),
1165
+ };
1166
+ }
1167
+
1168
+ function noteConversationExchange(cooldownKey: string): void {
1169
+ const nowMs = Date.now();
1170
+ const existing = conversationExchangeByKey.get(cooldownKey);
1171
+ if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
1172
+ conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
738
1173
  return;
739
1174
  }
740
- client.user.setPresence({
741
- activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
742
- status: 'online',
1175
+ conversationExchangeByKey.set(cooldownKey, {
1176
+ count: existing.count + 1,
1177
+ lastAtMs: nowMs,
743
1178
  });
744
1179
  }
745
1180
 
746
- export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler): Client {
1181
+ function pickReadWithoutReplyEmoji(): string {
1182
+ return Math.random() < 0.7 ? '👍' : '✅';
1183
+ }
1184
+
1185
+ export function initDiscord(
1186
+ onMessage: MessageHandler,
1187
+ onCommand: CommandHandler,
1188
+ ): Client {
747
1189
  messageHandler = onMessage;
748
1190
  commandHandler = onCommand;
749
1191
 
750
1192
  interface QueuedConversationMessage {
751
1193
  msg: DiscordMessage;
752
1194
  content: string;
753
- clearReaction: () => Promise<void>;
1195
+ behavior: ResolvedChannelBehavior;
1196
+ clearAckReaction: () => Promise<void>;
1197
+ wasExplicitlyAddressed: boolean;
1198
+ cooldownKey: string;
754
1199
  }
755
1200
  interface PendingConversationBatch {
756
1201
  items: QueuedConversationMessage[];
757
1202
  timer: ReturnType<typeof setTimeout>;
1203
+ typingController: ReturnType<typeof createTypingController>;
1204
+ lifecycleController: LifecycleReactionController | null;
758
1205
  }
759
1206
  interface InFlightConversation {
760
1207
  abortController: AbortController;
761
1208
  stream: DiscordStreamManager;
762
1209
  messageIds: Set<string>;
763
1210
  aborted: boolean;
1211
+ emitLifecyclePhase: (phase: LifecyclePhase) => void;
764
1212
  }
765
1213
  const pendingBatches = new Map<string, PendingConversationBatch>();
766
1214
  const inFlightByMessageId = new Map<string, InFlightConversation>();
1215
+ const channelConcurrencyById = new Map<string, number>();
767
1216
  const negativeFeedbackByChannel = new Map<string, string>();
768
- const participantMemoryByChannel = new Map<string, Map<string, Set<string>>>();
769
-
770
- const touchParticipantMemoryChannel = (channelId: string): Map<string, Set<string>> => {
1217
+ const participantMemoryByChannel = new Map<
1218
+ string,
1219
+ Map<string, Set<string>>
1220
+ >();
1221
+
1222
+ const touchParticipantMemoryChannel = (
1223
+ channelId: string,
1224
+ ): Map<string, Set<string>> => {
771
1225
  const existing = participantMemoryByChannel.get(channelId);
772
1226
  if (existing) {
773
1227
  participantMemoryByChannel.delete(channelId);
@@ -784,7 +1238,11 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
784
1238
  return created;
785
1239
  };
786
1240
 
787
- const rememberParticipantAliasForChannel = (channelId: string, userId: string, rawAlias: string | null | undefined): void => {
1241
+ const rememberParticipantAliasForChannel = (
1242
+ channelId: string,
1243
+ userId: string,
1244
+ rawAlias: string | null | undefined,
1245
+ ): void => {
788
1246
  if (!userId || userId === client.user?.id) return;
789
1247
  const alias = normalizeMentionAlias(rawAlias);
790
1248
  if (!alias) return;
@@ -801,7 +1259,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
801
1259
  }
802
1260
  aliases.add(alias);
803
1261
  if (aliases.size > PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER) {
804
- const kept = new Set(Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER));
1262
+ const kept = new Set(
1263
+ Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER),
1264
+ );
805
1265
  channelMemory.set(userId, kept);
806
1266
  }
807
1267
  // Refresh user recency.
@@ -812,14 +1272,21 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
812
1272
  }
813
1273
  };
814
1274
 
815
- const rememberParticipantForChannel = (channelId: string, userId: string, aliases: Array<string | null | undefined>): void => {
1275
+ const rememberParticipantForChannel = (
1276
+ channelId: string,
1277
+ userId: string,
1278
+ aliases: Array<string | null | undefined>,
1279
+ ): void => {
816
1280
  if (!userId || userId === client.user?.id) return;
817
1281
  for (const alias of aliases) {
818
1282
  rememberParticipantAliasForChannel(channelId, userId, alias);
819
1283
  }
820
1284
  };
821
1285
 
822
- const observeMessageParticipants = (msg: DiscordMessage, content: string): void => {
1286
+ const observeMessageParticipants = (
1287
+ msg: DiscordMessage,
1288
+ content: string,
1289
+ ): void => {
823
1290
  if (!msg.guild) return;
824
1291
  rememberParticipantForChannel(msg.channelId, msg.author.id, [
825
1292
  msg.author.username,
@@ -833,10 +1300,142 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
833
1300
  ]);
834
1301
  }
835
1302
  for (const hint of extractMentionAliasHints(content)) {
836
- rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
1303
+ rememberParticipantAliasForChannel(
1304
+ msg.channelId,
1305
+ hint.userId,
1306
+ hint.alias,
1307
+ );
1308
+ }
1309
+ };
1310
+
1311
+ const waitForChannelConcurrencySlot = async (
1312
+ channelId: string,
1313
+ maxConcurrent: number,
1314
+ abortSignal?: AbortSignal,
1315
+ ): Promise<() => void> => {
1316
+ const boundedMax = Math.max(1, Math.floor(maxConcurrent));
1317
+ while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
1318
+ if (abortSignal?.aborted) {
1319
+ throw new Error(
1320
+ 'Conversation aborted while waiting for channel concurrency slot.',
1321
+ );
1322
+ }
1323
+ await new Promise((resolve) =>
1324
+ setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS),
1325
+ );
1326
+ }
1327
+ if (abortSignal?.aborted) {
1328
+ throw new Error(
1329
+ 'Conversation aborted before acquiring channel concurrency slot.',
1330
+ );
1331
+ }
1332
+ channelConcurrencyById.set(
1333
+ channelId,
1334
+ (channelConcurrencyById.get(channelId) ?? 0) + 1,
1335
+ );
1336
+ return () => {
1337
+ const current = channelConcurrencyById.get(channelId) ?? 0;
1338
+ const next = Math.max(0, current - 1);
1339
+ if (next === 0) {
1340
+ channelConcurrencyById.delete(channelId);
1341
+ } else {
1342
+ channelConcurrencyById.set(channelId, next);
1343
+ }
1344
+ };
1345
+ };
1346
+
1347
+ const enforcePerUserRateLimit = async (
1348
+ msg: DiscordMessage,
1349
+ behavior: ResolvedChannelBehavior,
1350
+ ): Promise<boolean> => {
1351
+ const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
1352
+ if (limit === 0) return true;
1353
+ if (isRateLimitExempt(msg)) return true;
1354
+
1355
+ const key = `${msg.channelId}:${msg.author.id}`;
1356
+ const decision = userRateLimiter.check(key, limit);
1357
+ if (decision.allowed) return true;
1358
+
1359
+ if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
1360
+ try {
1361
+ await withDiscordRetry('rate-limit-reply', () =>
1362
+ msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }),
1363
+ );
1364
+ } catch (error) {
1365
+ logger.debug(
1366
+ { error, channelId: msg.channelId, userId: msg.author.id },
1367
+ 'Failed to send rate-limit warning',
1368
+ );
1369
+ }
1370
+ }
1371
+ return false;
1372
+ };
1373
+
1374
+ const maybeHandleReadWithoutReply = async (
1375
+ msg: DiscordMessage,
1376
+ content: string,
1377
+ ): Promise<boolean> => {
1378
+ if (!content.trim()) return false;
1379
+ if (!msg.guild) return false;
1380
+ if (msg.attachments.size > 0) return false;
1381
+ if (hasPrefixInvocation(msg.content || '')) return false;
1382
+ if (hasSlashCommandInvocation(msg.content || '')) return false;
1383
+ if (client.user && msg.mentions.has(client.user)) return false;
1384
+ if (content.trim().length > 80) return false;
1385
+ if (!READ_WITHOUT_REPLY_RE.test(content.trim())) return false;
1386
+ if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY) return false;
1387
+
1388
+ try {
1389
+ await withDiscordRetry('reaction-read-without-reply', () =>
1390
+ msg.react(pickReadWithoutReplyEmoji()),
1391
+ );
1392
+ return true;
1393
+ } catch (error) {
1394
+ logger.debug(
1395
+ { error, channelId: msg.channelId, messageId: msg.id },
1396
+ 'Failed read-without-reply reaction',
1397
+ );
1398
+ return false;
837
1399
  }
838
1400
  };
839
1401
 
1402
+ const shouldSelectivelySilence = (params: {
1403
+ sourceItem: QueuedConversationMessage;
1404
+ inboundHistory: PendingGuildHistoryEntry[];
1405
+ behavior: ResolvedChannelBehavior;
1406
+ }): boolean => {
1407
+ if (!params.sourceItem.msg.guild) return false;
1408
+ if (params.behavior.guildMessageMode !== 'free') return false;
1409
+ if (params.sourceItem.wasExplicitlyAddressed) return false;
1410
+
1411
+ const nowMs = Date.now();
1412
+ const peerMessages = params.inboundHistory
1413
+ .filter(
1414
+ (entry) =>
1415
+ !entry.isBot &&
1416
+ entry.userId !== params.sourceItem.msg.author.id &&
1417
+ nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS,
1418
+ )
1419
+ .sort((a, b) => a.timestampMs - b.timestampMs);
1420
+ if (peerMessages.length === 0) return false;
1421
+
1422
+ const sourceText = params.sourceItem.content.trim();
1423
+ const asksQuestion = sourceText.includes('?');
1424
+ const latestPeer =
1425
+ peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
1426
+ '';
1427
+ const peerLooksLikeAnswer =
1428
+ latestPeer.length >= 24 ||
1429
+ /\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(
1430
+ latestPeer,
1431
+ );
1432
+ const probability =
1433
+ asksQuestion && peerLooksLikeAnswer
1434
+ ? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
1435
+ : SELECTIVE_SILENCE_BASE_PROBABILITY;
1436
+ return Math.random() < probability;
1437
+ };
1438
+
840
1439
  const intents: GatewayIntentBits[] = [
841
1440
  GatewayIntentBits.Guilds,
842
1441
  GatewayIntentBits.GuildMessages,
@@ -844,12 +1443,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
844
1443
  GatewayIntentBits.MessageContent,
845
1444
  GatewayIntentBits.DirectMessages,
846
1445
  ];
847
- if (DISCORD_GUILD_MEMBERS_INTENT) intents.push(GatewayIntentBits.GuildMembers);
1446
+ if (DISCORD_GUILD_MEMBERS_INTENT)
1447
+ intents.push(GatewayIntentBits.GuildMembers);
848
1448
  if (DISCORD_PRESENCE_INTENT) intents.push(GatewayIntentBits.GuildPresences);
849
1449
 
850
1450
  client = new Client({
851
1451
  intents,
852
- partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
1452
+ partials: [
1453
+ Partials.Channel,
1454
+ Partials.Message,
1455
+ Partials.Reaction,
1456
+ Partials.User,
1457
+ ],
853
1458
  });
854
1459
 
855
1460
  client.on('presenceUpdate', (_oldPresence, nextPresence) => {
@@ -868,18 +1473,35 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
868
1473
 
869
1474
  client.on('clientReady', () => {
870
1475
  logger.info({ user: client.user?.tag }, 'Discord bot connected');
1476
+ startupConnectedAtMs = Date.now();
871
1477
  if (client.user) {
872
1478
  botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
873
1479
  }
874
- updatePresence();
1480
+ presenceController?.stop();
1481
+ presenceController = new DiscordAutoPresenceController({
1482
+ client,
1483
+ getConfig: () => DISCORD_SELF_PRESENCE,
1484
+ resolveState: resolvePresenceHealthState,
1485
+ });
1486
+ presenceController.start();
875
1487
  void ensureSlashCommands();
876
1488
  });
877
1489
 
878
1490
  client.on('interactionCreate', async (interaction) => {
879
1491
  if (!interaction.isChatInputCommand()) return;
880
- if (interaction.commandName !== 'status' && interaction.commandName !== 'channel-mode') return;
1492
+ if (
1493
+ interaction.commandName !== 'status' &&
1494
+ interaction.commandName !== 'approve' &&
1495
+ interaction.commandName !== 'channel-mode' &&
1496
+ interaction.commandName !== 'channel-policy'
1497
+ ) {
1498
+ return;
1499
+ }
881
1500
 
882
- if (!isAuthorizedCommandUserId(interaction.user.id)) {
1501
+ if (
1502
+ interaction.guildId &&
1503
+ !isAuthorizedCommandUserId(interaction.user.id)
1504
+ ) {
883
1505
  await sendChunkedInteractionReply(
884
1506
  interaction,
885
1507
  'You are not authorized to run commands for this bot.',
@@ -889,19 +1511,65 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
889
1511
 
890
1512
  const guildId = interaction.guildId ?? null;
891
1513
  const channelId = interaction.channelId;
892
- const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
893
- const args = interaction.commandName === 'status'
894
- ? ['status']
895
- : (() => {
896
- if (!interaction.guildId) return null;
897
- const selectedMode = interaction.options.getString('mode', true).trim().toLowerCase();
898
- if (selectedMode !== 'off' && selectedMode !== 'mention' && selectedMode !== 'free') return null;
899
- return ['channel', 'mode', selectedMode];
900
- })();
1514
+ const sessionId = buildSessionIdFromContext(
1515
+ guildId,
1516
+ channelId,
1517
+ interaction.user.id,
1518
+ );
1519
+ const args = (() => {
1520
+ if (interaction.commandName === 'status') return ['status'];
1521
+ if (interaction.commandName === 'approve') {
1522
+ const action =
1523
+ interaction.options
1524
+ .getString('action', false)
1525
+ ?.trim()
1526
+ .toLowerCase() || 'view';
1527
+ if (
1528
+ action !== 'view' &&
1529
+ action !== 'yes' &&
1530
+ action !== 'session' &&
1531
+ action !== 'agent' &&
1532
+ action !== 'no'
1533
+ ) {
1534
+ return null;
1535
+ }
1536
+ const approvalId =
1537
+ interaction.options.getString('approval_id', false)?.trim() || '';
1538
+ return approvalId
1539
+ ? ['approve', action, approvalId]
1540
+ : ['approve', action];
1541
+ }
1542
+ if (interaction.commandName === 'channel-mode') {
1543
+ if (!interaction.guildId) return null;
1544
+ const selectedMode = interaction.options
1545
+ .getString('mode', true)
1546
+ .trim()
1547
+ .toLowerCase();
1548
+ if (
1549
+ selectedMode !== 'off' &&
1550
+ selectedMode !== 'mention' &&
1551
+ selectedMode !== 'free'
1552
+ )
1553
+ return null;
1554
+ return ['channel', 'mode', selectedMode];
1555
+ }
1556
+ if (!interaction.guildId) return null;
1557
+ const selectedPolicy = interaction.options
1558
+ .getString('policy', true)
1559
+ .trim()
1560
+ .toLowerCase();
1561
+ if (
1562
+ selectedPolicy !== 'open' &&
1563
+ selectedPolicy !== 'allowlist' &&
1564
+ selectedPolicy !== 'disabled'
1565
+ )
1566
+ return null;
1567
+ return ['channel', 'policy', selectedPolicy];
1568
+ })();
901
1569
  if (!args) {
902
1570
  await sendChunkedInteractionReply(
903
1571
  interaction,
904
- 'This command can only be used in a server channel with mode `off`, `mention`, or `free`.',
1572
+ 'This command can only be used in a server channel with a valid mode/policy option.',
905
1573
  );
906
1574
  return;
907
1575
  }
@@ -910,8 +1578,11 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
910
1578
  sessionId,
911
1579
  guildId,
912
1580
  channelId,
1581
+ interaction.user.id,
1582
+ interaction.user.username,
913
1583
  args,
914
- async (text, files) => sendChunkedInteractionReply(interaction, text, files),
1584
+ async (text, files) =>
1585
+ sendChunkedInteractionReply(interaction, text, files),
915
1586
  );
916
1587
  } catch (error) {
917
1588
  const detail = error instanceof Error ? error.message : String(error);
@@ -919,7 +1590,10 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
919
1590
  { error, guildId, channelId, userId: interaction.user.id },
920
1591
  'Discord slash command failed',
921
1592
  );
922
- await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
1593
+ await sendChunkedInteractionReply(
1594
+ interaction,
1595
+ formatError('Gateway Error', detail),
1596
+ );
923
1597
  }
924
1598
  });
925
1599
 
@@ -937,10 +1611,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
937
1611
  const channelId = msg.channelId;
938
1612
  const userId = msg.author.id;
939
1613
  const username = msg.author.username;
940
-
941
- const batchedContent = items.length > 1
942
- ? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
943
- : sourceItem.content;
1614
+ const behavior = sourceItem.behavior;
1615
+ const startedAt = Date.now();
1616
+ let releaseChannelSlot: (() => void) | null = null;
1617
+
1618
+ const batchedContent =
1619
+ items.length > 1
1620
+ ? items
1621
+ .map((item, index) => `Message ${index + 1}:\n${item.content}`)
1622
+ .join('\n\n')
1623
+ : sourceItem.content;
944
1624
  const channelInfoContext = buildChannelInfoContext(msg);
945
1625
  const replyContext = await buildReplyContext(msg);
946
1626
  const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
@@ -948,9 +1628,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
948
1628
  negativeFeedbackByChannel.delete(channelId);
949
1629
  }
950
1630
  const currentBatchMessageIds = new Set(items.map((item) => item.msg.id));
951
- const inboundHistory = await buildInboundHistorySnapshot(msg, currentBatchMessageIds);
952
- const attachmentContext = await buildAttachmentContext(items.map((item) => item.msg));
953
- const rememberedParticipants = participantMemoryByChannel.get(msg.channelId);
1631
+ const inboundHistory = await buildInboundHistorySnapshot(
1632
+ msg,
1633
+ currentBatchMessageIds,
1634
+ );
1635
+ const attachmentContext = await buildAttachmentContext(
1636
+ items.map((item) => item.msg),
1637
+ );
1638
+ const rememberedParticipants = participantMemoryByChannel.get(
1639
+ msg.channelId,
1640
+ );
954
1641
  const participantContext = buildParticipantContext(
955
1642
  items.map((item) => item.msg),
956
1643
  inboundHistory.entries,
@@ -962,25 +1649,70 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
962
1649
  rememberedParticipants,
963
1650
  );
964
1651
  const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
1652
+ const selectiveSilence = shouldSelectivelySilence({
1653
+ sourceItem,
1654
+ inboundHistory: inboundHistory.entries,
1655
+ behavior,
1656
+ });
965
1657
 
966
1658
  const abortController = new AbortController();
967
- const typingLoop = startTypingLoop(msg);
1659
+ const typingController = pending.typingController;
1660
+ const lifecycleController = pending.lifecycleController;
1661
+ const emitLifecyclePhase = (phase: LifecyclePhase): void => {
1662
+ if (phase === 'queued') {
1663
+ typingController.setPhase('received');
1664
+ } else if (phase === 'thinking') {
1665
+ typingController.setPhase('thinking');
1666
+ } else if (phase === 'toolUse') {
1667
+ typingController.setPhase('toolUse');
1668
+ } else if (phase === 'streaming') {
1669
+ typingController.setPhase('streaming');
1670
+ } else {
1671
+ typingController.setPhase('done');
1672
+ }
1673
+ lifecycleController?.setPhase(phase);
1674
+ };
1675
+
968
1676
  const stream = new DiscordStreamManager(msg, {
969
- onFirstMessage: () => typingLoop.stop(),
1677
+ onFirstMessage: () => emitLifecyclePhase('streaming'),
1678
+ humanDelay: behavior.humanDelay,
970
1679
  });
971
1680
  const inFlight: InFlightConversation = {
972
1681
  abortController,
973
1682
  stream,
974
1683
  messageIds: new Set(items.map((item) => item.msg.id)),
975
1684
  aborted: false,
1685
+ emitLifecyclePhase,
976
1686
  };
977
1687
  for (const messageId of inFlight.messageIds) {
978
1688
  inFlightByMessageId.set(messageId, inFlight);
979
1689
  }
980
1690
 
981
1691
  try {
1692
+ if (selectiveSilence) {
1693
+ emitLifecyclePhase('done');
1694
+ if (Math.random() < 0.5) {
1695
+ await withDiscordRetry('reaction-selective-silence', () =>
1696
+ msg.react(pickReadWithoutReplyEmoji()),
1697
+ ).catch(() => {});
1698
+ }
1699
+ recordConversationMetric({
1700
+ durationMs: Date.now() - startedAt,
1701
+ ok: true,
1702
+ });
1703
+ return;
1704
+ }
1705
+
1706
+ releaseChannelSlot = await waitForChannelConcurrencySlot(
1707
+ channelId,
1708
+ behavior.maxConcurrentPerChannel,
1709
+ abortController.signal,
1710
+ );
1711
+ if (abortController.signal.aborted) {
1712
+ return;
1713
+ }
982
1714
  activeConversationRuns += 1;
983
- updatePresence();
1715
+ emitLifecyclePhase('thinking');
984
1716
  await messageHandler(
985
1717
  sessionId,
986
1718
  guildId,
@@ -990,8 +1722,14 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
990
1722
  combinedContent,
991
1723
  attachmentContext.media,
992
1724
  async (text, files) => {
993
- typingLoop.stop();
994
- await sendChunkedReply(msg, text, files, mentionLookup);
1725
+ emitLifecyclePhase('streaming');
1726
+ await sendChunkedReply(
1727
+ msg,
1728
+ text,
1729
+ files,
1730
+ mentionLookup,
1731
+ behavior.humanDelay,
1732
+ );
995
1733
  },
996
1734
  {
997
1735
  sourceMessage: msg,
@@ -999,66 +1737,190 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
999
1737
  abortSignal: abortController.signal,
1000
1738
  stream,
1001
1739
  mentionLookup,
1740
+ emitLifecyclePhase,
1002
1741
  },
1003
1742
  );
1743
+ emitLifecyclePhase('done');
1744
+ recordConversationMetric({
1745
+ durationMs: Date.now() - startedAt,
1746
+ ok: true,
1747
+ });
1748
+ noteConversationExchange(sourceItem.cooldownKey);
1004
1749
  } catch (error) {
1005
- logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
1750
+ if (abortController.signal.aborted || inFlight.aborted) {
1751
+ logger.debug(
1752
+ { channelId, sessionId },
1753
+ 'Conversation batch aborted before completion',
1754
+ );
1755
+ return;
1756
+ }
1757
+ emitLifecyclePhase('error');
1758
+ recordConversationMetric({
1759
+ durationMs: Date.now() - startedAt,
1760
+ ok: false,
1761
+ error,
1762
+ });
1763
+ logger.error(
1764
+ { error, channelId, sessionId },
1765
+ 'Conversation batch handling failed',
1766
+ );
1006
1767
  const detail = error instanceof Error ? error.message : String(error);
1007
1768
  if (stream.hasSentMessages()) {
1008
1769
  await stream.fail(formatError('Gateway Error', detail));
1009
1770
  } else {
1010
- await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
1771
+ await sendChunkedReply(
1772
+ msg,
1773
+ formatError('Gateway Error', detail),
1774
+ undefined,
1775
+ mentionLookup,
1776
+ behavior.humanDelay,
1777
+ );
1011
1778
  }
1012
1779
  } finally {
1013
1780
  activeConversationRuns = Math.max(0, activeConversationRuns - 1);
1014
- updatePresence();
1781
+ if (releaseChannelSlot) {
1782
+ releaseChannelSlot();
1783
+ }
1015
1784
  for (const messageId of inFlight.messageIds) {
1016
1785
  if (inFlightByMessageId.get(messageId) === inFlight) {
1017
1786
  inFlightByMessageId.delete(messageId);
1018
1787
  }
1019
1788
  }
1020
- typingLoop.stop();
1021
- await Promise.all(items.map(async (item) => {
1022
- await item.clearReaction();
1023
- }));
1789
+ typingController.stop();
1790
+ await Promise.all(
1791
+ items.map(async (item) => {
1792
+ await item.clearAckReaction();
1793
+ }),
1794
+ );
1024
1795
  }
1025
1796
  };
1026
1797
 
1027
1798
  const queueConversationMessage = async (
1028
1799
  msg: DiscordMessage,
1029
1800
  content: string,
1801
+ behavior: ResolvedChannelBehavior,
1030
1802
  ): Promise<void> => {
1031
1803
  const key = `${msg.channelId}:${msg.author.id}`;
1032
- const clearReaction = await addProcessingReaction(msg);
1033
- const queued: QueuedConversationMessage = { msg, content, clearReaction };
1804
+ const cooldownKey = buildConversationCooldownKey(
1805
+ msg.channelId,
1806
+ msg.author.id,
1807
+ );
1808
+ const adjustedHumanDelay = resolveHumanDelayWithBehavior(
1809
+ behavior.humanDelay,
1810
+ cooldownKey,
1811
+ );
1812
+ const queuedBehavior: ResolvedChannelBehavior = {
1813
+ ...behavior,
1814
+ humanDelay: adjustedHumanDelay,
1815
+ };
1816
+ const wasExplicitlyAddressed =
1817
+ !msg.guild ||
1818
+ hasPrefixInvocation(msg.content || '') ||
1819
+ Boolean(client.user && msg.mentions.has(client.user)) ||
1820
+ Boolean(client.user && msg.mentions.repliedUser?.id === client.user.id);
1821
+
1822
+ let clearAckReaction: () => Promise<void> = async () => {};
1823
+ if (client.user && shouldApplyAckReaction(msg, behavior)) {
1824
+ const clearReaction = await addAckReaction({
1825
+ message: msg,
1826
+ emoji: behavior.ackReaction,
1827
+ withRetry: withDiscordRetry,
1828
+ botUserId: client.user.id,
1829
+ });
1830
+ clearAckReaction = behavior.removeAckAfterReply
1831
+ ? clearReaction
1832
+ : async () => {};
1833
+ }
1834
+ const queued: QueuedConversationMessage = {
1835
+ msg,
1836
+ content,
1837
+ behavior: queuedBehavior,
1838
+ clearAckReaction,
1839
+ wasExplicitlyAddressed,
1840
+ cooldownKey,
1841
+ };
1034
1842
  const existing = pendingBatches.get(key);
1843
+ const shouldDebounceMessage = shouldDebounceInbound({
1844
+ content: msg.content || '',
1845
+ hasAttachments: msg.attachments.size > 0,
1846
+ isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
1847
+ });
1035
1848
 
1036
1849
  if (!existing) {
1850
+ const typingController = createTypingController(msg, behavior.typingMode);
1851
+ typingController.setPhase('received');
1852
+ const lifecycleController =
1853
+ client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
1854
+ ? new LifecycleReactionController({
1855
+ message: msg,
1856
+ withRetry: withDiscordRetry,
1857
+ botUserId: client.user.id,
1858
+ config: {
1859
+ enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
1860
+ removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
1861
+ phases: DISCORD_LIFECYCLE_REACTIONS.phases,
1862
+ },
1863
+ })
1864
+ : null;
1865
+ lifecycleController?.setPhase('queued');
1866
+ const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
1867
+ const startupStaggerMs =
1868
+ startupConnectedAtMs > 0 &&
1869
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1870
+ !wasExplicitlyAddressed
1871
+ ? randomIntInRange(
1872
+ STARTUP_STAGGER_MIN_DELAY_MS,
1873
+ STARTUP_STAGGER_MAX_DELAY_MS,
1874
+ )
1875
+ : 0;
1876
+ const delayMs = baseDelayMs + startupStaggerMs;
1037
1877
  const timer = setTimeout(() => {
1038
1878
  void dispatchConversationBatch(key);
1039
- }, MESSAGE_DEBOUNCE_MS);
1879
+ }, delayMs);
1040
1880
  pendingBatches.set(key, {
1041
1881
  items: [queued],
1042
1882
  timer,
1883
+ typingController,
1884
+ lifecycleController,
1043
1885
  });
1044
1886
  return;
1045
1887
  }
1046
1888
 
1889
+ existing.typingController.setPhase('received');
1890
+ existing.lifecycleController?.setPhase('queued');
1047
1891
  clearTimeout(existing.timer);
1048
1892
  existing.items.push(queued);
1893
+ const shouldFlushImmediately =
1894
+ !shouldDebounceMessage ||
1895
+ existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
1896
+ const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
1897
+ const startupStaggerMs =
1898
+ startupConnectedAtMs > 0 &&
1899
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1900
+ !wasExplicitlyAddressed
1901
+ ? randomIntInRange(
1902
+ STARTUP_STAGGER_MIN_DELAY_MS,
1903
+ STARTUP_STAGGER_MAX_DELAY_MS,
1904
+ )
1905
+ : 0;
1906
+ const delayMs = baseDelayMs + startupStaggerMs;
1049
1907
  existing.timer = setTimeout(() => {
1050
1908
  void dispatchConversationBatch(key);
1051
- }, MESSAGE_DEBOUNCE_MS);
1909
+ }, delayMs);
1052
1910
  };
1053
1911
 
1054
1912
  const dropPendingMessage = async (messageId: string): Promise<void> => {
1055
1913
  for (const [key, pending] of pendingBatches) {
1056
- const index = pending.items.findIndex((item) => item.msg.id === messageId);
1914
+ const index = pending.items.findIndex(
1915
+ (item) => item.msg.id === messageId,
1916
+ );
1057
1917
  if (index === -1) continue;
1058
1918
  const [removed] = pending.items.splice(index, 1);
1059
- await removed.clearReaction();
1919
+ await removed.clearAckReaction();
1060
1920
  if (pending.items.length === 0) {
1061
1921
  clearTimeout(pending.timer);
1922
+ pending.typingController.stop();
1923
+ await pending.lifecycleController?.clear();
1062
1924
  pendingBatches.delete(key);
1063
1925
  }
1064
1926
  return;
@@ -1069,21 +1931,35 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1069
1931
  messageId: string,
1070
1932
  nextMsg: DiscordMessage,
1071
1933
  nextContent: string,
1934
+ nextBehavior: ResolvedChannelBehavior,
1072
1935
  ): Promise<boolean> => {
1073
1936
  for (const [key, pending] of pendingBatches) {
1074
- const index = pending.items.findIndex((item) => item.msg.id === messageId);
1937
+ const index = pending.items.findIndex(
1938
+ (item) => item.msg.id === messageId,
1939
+ );
1075
1940
  if (index === -1) continue;
1076
1941
 
1077
1942
  if (!nextContent) {
1078
1943
  const [removed] = pending.items.splice(index, 1);
1079
- await removed.clearReaction();
1944
+ await removed.clearAckReaction();
1080
1945
  } else {
1081
1946
  pending.items[index].msg = nextMsg;
1082
1947
  pending.items[index].content = nextContent;
1948
+ pending.items[index].behavior = nextBehavior;
1949
+ pending.items[index].wasExplicitlyAddressed =
1950
+ !nextMsg.guild ||
1951
+ hasPrefixInvocation(nextMsg.content || '') ||
1952
+ Boolean(client.user && nextMsg.mentions.has(client.user));
1953
+ pending.items[index].cooldownKey = buildConversationCooldownKey(
1954
+ nextMsg.channelId,
1955
+ nextMsg.author.id,
1956
+ );
1083
1957
  }
1084
1958
 
1085
1959
  if (pending.items.length === 0) {
1086
1960
  clearTimeout(pending.timer);
1961
+ pending.typingController.stop();
1962
+ await pending.lifecycleController?.clear();
1087
1963
  pendingBatches.delete(key);
1088
1964
  }
1089
1965
  return true;
@@ -1097,6 +1973,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1097
1973
  const sessionId = getSessionId(msg);
1098
1974
  const guildId = msg.guild?.id || null;
1099
1975
  const channelId = msg.channelId;
1976
+ const behavior = resolveChannelBehavior(msg);
1100
1977
  const content = cleanIncomingContent(msg.content);
1101
1978
  observeMessageParticipants(msg, content);
1102
1979
  const immediateMentionLookup = buildMentionLookup(
@@ -1106,11 +1983,23 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1106
1983
  );
1107
1984
 
1108
1985
  const reply: ReplyFn = async (text, files) => {
1109
- await sendChunkedReply(msg, text, files, immediateMentionLookup);
1986
+ await sendChunkedReply(
1987
+ msg,
1988
+ text,
1989
+ files,
1990
+ immediateMentionLookup,
1991
+ behavior.humanDelay,
1992
+ );
1110
1993
  };
1111
1994
  const commandReply: ReplyFn = async (text, files) => {
1112
1995
  try {
1113
- await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
1996
+ await sendChunkedDirectReply(
1997
+ msg,
1998
+ text,
1999
+ files,
2000
+ immediateMentionLookup,
2001
+ behavior.humanDelay,
2002
+ );
1114
2003
  } catch (error) {
1115
2004
  logger.warn(
1116
2005
  { error, userId: msg.author.id, channelId: msg.channelId },
@@ -1120,50 +2009,73 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1120
2009
  };
1121
2010
 
1122
2011
  const parsed = parseCommand(msg.content);
1123
- const prefixedToken = hasPrefixInvocation(msg.content)
1124
- ? cleanIncomingContent(msg.content).split(/\s+/)[0]?.toLowerCase() || ''
1125
- : '';
1126
- const ignorePrefixCommand = prefixedToken === 'status';
2012
+ const hasPrefixedInvocation = hasPrefixInvocation(msg.content);
2013
+ const hasSlashInvocation = hasSlashCommandInvocation(msg.content);
2014
+ const hasCommandInvocation = hasPrefixedInvocation || hasSlashInvocation;
1127
2015
  if (DISCORD_COMMANDS_ONLY) {
1128
- if (!hasPrefixInvocation(msg.content)) return;
1129
- if (!isAuthorizedCommandUserId(msg.author.id)) {
2016
+ if (!hasCommandInvocation) return;
2017
+ if (msg.guild && !isAuthorizedCommandUserId(msg.author.id)) {
1130
2018
  logger.debug(
1131
2019
  { userId: msg.author.id, channelId: msg.channelId },
1132
2020
  'Ignoring unauthorized Discord command in commands-only mode',
1133
2021
  );
1134
2022
  return;
1135
2023
  }
1136
- if (ignorePrefixCommand) {
1137
- return;
1138
- }
1139
2024
  if (!parsed.isCommand) {
1140
2025
  if (!content) {
1141
2026
  await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
1142
2027
  } else {
1143
- await commandReply(`Unknown command. Try \`${DISCORD_PREFIX} help\`.`);
2028
+ await commandReply(
2029
+ `Unknown command. Try \`${DISCORD_PREFIX} help\`.`,
2030
+ );
1144
2031
  }
1145
2032
  return;
1146
2033
  }
1147
- await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
2034
+ await commandHandler(
2035
+ sessionId,
2036
+ guildId,
2037
+ channelId,
2038
+ msg.author.id,
2039
+ msg.author.username,
2040
+ [parsed.command, ...parsed.args],
2041
+ commandReply,
2042
+ );
2043
+ return;
2044
+ }
2045
+
2046
+ if (
2047
+ msg.guild &&
2048
+ hasCommandInvocation &&
2049
+ !isAuthorizedCommandUserId(msg.author.id)
2050
+ ) {
2051
+ logger.debug(
2052
+ { userId: msg.author.id, channelId: msg.channelId },
2053
+ 'Ignoring unauthorized Discord prefixed command',
2054
+ );
1148
2055
  return;
1149
2056
  }
1150
2057
 
1151
- if (!isTrigger(msg)) return;
2058
+ if (!isTrigger(msg, behavior)) return;
1152
2059
 
1153
- if (ignorePrefixCommand) {
2060
+ if (parsed.isCommand && hasCommandInvocation) {
2061
+ await commandHandler(
2062
+ sessionId,
2063
+ guildId,
2064
+ channelId,
2065
+ msg.author.id,
2066
+ msg.author.username,
2067
+ [parsed.command, ...parsed.args],
2068
+ commandReply,
2069
+ );
1154
2070
  return;
1155
2071
  }
1156
2072
 
1157
- if (parsed.isCommand && hasPrefixInvocation(msg.content)) {
1158
- if (!isAuthorizedCommandUserId(msg.author.id)) {
1159
- logger.debug(
1160
- { userId: msg.author.id, channelId: msg.channelId },
1161
- 'Ignoring unauthorized Discord command; processing as normal chat message',
1162
- );
1163
- } else {
1164
- await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
1165
- return;
1166
- }
2073
+ if (!shouldHandleFreeModeMessage(msg, behavior, content)) {
2074
+ logger.debug(
2075
+ { channelId: msg.channelId, messageId: msg.id, userId: msg.author.id },
2076
+ 'Skipping Discord free-mode message by relevance/mention gate',
2077
+ );
2078
+ return;
1167
2079
  }
1168
2080
 
1169
2081
  if (!content) {
@@ -1171,7 +2083,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1171
2083
  return;
1172
2084
  }
1173
2085
 
1174
- await queueConversationMessage(msg, content);
2086
+ const readWithoutReplyHandled = await maybeHandleReadWithoutReply(
2087
+ msg,
2088
+ content,
2089
+ );
2090
+ if (readWithoutReplyHandled) {
2091
+ return;
2092
+ }
2093
+
2094
+ const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
2095
+ if (!rateLimitAllowed) return;
2096
+
2097
+ await queueConversationMessage(msg, content, behavior);
1175
2098
  });
1176
2099
 
1177
2100
  client.on('messageUpdate', async (_oldMsg, nextMsg) => {
@@ -1183,14 +2106,30 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1183
2106
  if (fetched.author?.bot) return;
1184
2107
 
1185
2108
  const updatedContent = cleanIncomingContent(fetched.content || '');
2109
+ const behavior = resolveChannelBehavior(fetched);
1186
2110
  observeMessageParticipants(fetched, updatedContent);
1187
- if (!isTrigger(fetched)) return;
1188
- await updatePendingMessage(fetched.id, fetched, updatedContent);
2111
+ if (!isTrigger(fetched, behavior)) {
2112
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
2113
+ return;
2114
+ }
2115
+ if (
2116
+ hasPrefixInvocation(fetched.content || '') &&
2117
+ !isAuthorizedCommandUserId(fetched.author.id)
2118
+ ) {
2119
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
2120
+ return;
2121
+ }
2122
+ if (!shouldHandleFreeModeMessage(fetched, behavior, updatedContent)) {
2123
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
2124
+ return;
2125
+ }
2126
+ await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
1189
2127
 
1190
2128
  const inFlight = inFlightByMessageId.get(fetched.id);
1191
2129
  if (!inFlight || inFlight.aborted) return;
1192
2130
  inFlight.aborted = true;
1193
2131
  inFlight.abortController.abort();
2132
+ inFlight.emitLifecyclePhase('error');
1194
2133
  for (const messageId of inFlight.messageIds) {
1195
2134
  if (inFlightByMessageId.get(messageId) === inFlight) {
1196
2135
  inFlightByMessageId.delete(messageId);
@@ -1198,7 +2137,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1198
2137
  }
1199
2138
  await inFlight.stream.discard();
1200
2139
  if (updatedContent) {
1201
- await queueConversationMessage(fetched, updatedContent);
2140
+ const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
2141
+ if (!rateLimitAllowed) return;
2142
+ await queueConversationMessage(fetched, updatedContent, behavior);
1202
2143
  }
1203
2144
  });
1204
2145
 
@@ -1208,6 +2149,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1208
2149
  if (!inFlight || inFlight.aborted) return;
1209
2150
  inFlight.aborted = true;
1210
2151
  inFlight.abortController.abort();
2152
+ inFlight.emitLifecyclePhase('error');
1211
2153
  for (const messageId of inFlight.messageIds) {
1212
2154
  if (inFlightByMessageId.get(messageId) === inFlight) {
1213
2155
  inFlightByMessageId.delete(messageId);
@@ -1243,16 +2185,30 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1243
2185
  return client;
1244
2186
  }
1245
2187
 
2188
+ export async function setDiscordMaintenancePresence(): Promise<void> {
2189
+ if (!presenceController) return;
2190
+ await presenceController.setMaintenance();
2191
+ }
2192
+
1246
2193
  /**
1247
2194
  * Send a message to a channel by ID (used by scheduler).
1248
2195
  */
1249
- export async function sendToChannel(channelId: string, text: string, files?: AttachmentBuilder[]): Promise<void> {
2196
+ export async function sendToChannel(
2197
+ channelId: string,
2198
+ text: string,
2199
+ files?: AttachmentBuilder[],
2200
+ ): Promise<void> {
1250
2201
  const channel = await client.channels.fetch(channelId);
1251
2202
  if (channel && 'send' in channel) {
1252
2203
  const payloads = prepareChunkedPayloads(text, files);
1253
- const send = (channel as unknown as {
1254
- send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
1255
- }).send;
2204
+ const send = (
2205
+ channel as unknown as {
2206
+ send: (payload: {
2207
+ content: string;
2208
+ files?: AttachmentBuilder[];
2209
+ }) => Promise<void>;
2210
+ }
2211
+ ).send;
1256
2212
  for (const payload of payloads) {
1257
2213
  await withDiscordRetry('send-channel', () => send(payload));
1258
2214
  }