@hybridaione/hybridclaw 0.2.2 → 0.2.6

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 (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  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 +19 -3
  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 +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  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 +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  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 +16 -8
  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 +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -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 +58 -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 +563 -73
  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 +179 -24
  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 +48 -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/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -1,53 +1,90 @@
1
1
  import {
2
- ActivityType,
3
- AttachmentBuilder,
2
+ type ApplicationCommandDataResolvable,
3
+ ApplicationCommandOptionType,
4
+ type AttachmentBuilder,
4
5
  Client,
5
- GatewayIntentBits,
6
6
  type Message as DiscordMessage,
7
+ GatewayIntentBits,
7
8
  Partials,
8
9
  } from 'discord.js';
9
10
 
10
11
  import {
12
+ DISCORD_ACK_REACTION,
13
+ DISCORD_ACK_REACTION_SCOPE,
11
14
  DISCORD_COMMAND_USER_ID,
12
15
  DISCORD_COMMANDS_ONLY,
16
+ DISCORD_DEBOUNCE_MS,
17
+ DISCORD_FREE_RESPONSE_CHANNELS,
18
+ DISCORD_GROUP_POLICY,
13
19
  DISCORD_GUILD_MEMBERS_INTENT,
14
- DISCORD_PRESENCE_INTENT,
20
+ DISCORD_GUILDS,
21
+ DISCORD_HUMAN_DELAY,
22
+ DISCORD_LIFECYCLE_REACTIONS,
23
+ DISCORD_MAX_CONCURRENT_PER_CHANNEL,
15
24
  DISCORD_PREFIX,
25
+ DISCORD_PRESENCE_INTENT,
26
+ DISCORD_RATE_LIMIT_EXEMPT_ROLES,
27
+ DISCORD_RATE_LIMIT_PER_USER,
28
+ DISCORD_REMOVE_ACK_AFTER_REPLY,
16
29
  DISCORD_RESPOND_TO_ALL_MESSAGES,
30
+ DISCORD_SELF_PRESENCE,
31
+ DISCORD_SUPPRESS_PATTERNS,
17
32
  DISCORD_TOKEN,
33
+ DISCORD_TYPING_MODE,
18
34
  } from '../../config.js';
35
+ import { logger } from '../../logger.js';
36
+ import type { MediaContextItem } from '../../types.js';
19
37
  import { buildAttachmentContext } from './attachments.js';
38
+ import {
39
+ DEFAULT_DEBOUNCE_MAX_BUFFER,
40
+ resolveInboundDebounceMs,
41
+ shouldDebounceInbound,
42
+ } from './debounce.js';
43
+ import {
44
+ formatError,
45
+ prepareChunkedPayloads,
46
+ sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
47
+ sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
48
+ sendChunkedReply as sendChunkedReplyFromDelivery,
49
+ } from './delivery.js';
50
+ import type { HumanDelayConfig } from './human-delay.js';
20
51
  import {
21
52
  buildSessionIdFromContext as buildSessionIdFromContextInbound,
22
53
  cleanIncomingContent as cleanIncomingContentInbound,
54
+ type DiscordGuildMessageMode,
23
55
  hasPrefixInvocation as hasPrefixInvocationInbound,
24
56
  isTrigger as isTriggerInbound,
25
- parseCommand as parseCommandInbound,
26
57
  type ParsedCommand,
58
+ parseCommand as parseCommandInbound,
27
59
  } from './inbound.js';
28
60
  import {
29
61
  addMentionAlias,
30
62
  extractMentionAliasHints,
31
- normalizeMentionAlias,
32
63
  type MentionLookup,
64
+ normalizeMentionAlias,
33
65
  } from './mentions.js';
34
66
  import {
35
- formatError,
36
- prepareChunkedPayloads,
37
- sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
38
- sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
39
- sendChunkedReply as sendChunkedReplyFromDelivery,
40
- } from './delivery.js';
67
+ DiscordAutoPresenceController,
68
+ type PresenceHealthState,
69
+ } from './presence.js';
70
+ import { SlidingWindowRateLimiter } from './rate-limiter.js';
71
+ import {
72
+ addAckReaction,
73
+ type LifecyclePhase,
74
+ LifecycleReactionController,
75
+ } from './reactions.js';
76
+ import { DiscordStreamManager } from './stream.js';
41
77
  import {
42
- createDiscordToolActionRunner,
43
78
  type CachedDiscordPresence,
79
+ createDiscordToolActionRunner,
44
80
  type DiscordToolActionRequest,
45
81
  } from './tool-actions.js';
46
- import { DiscordStreamManager } from './stream.js';
47
- import { logger } from '../../logger.js';
48
- import type { MediaContextItem } from '../../types.js';
82
+ import { createTypingController } from './typing.js';
49
83
 
50
- export type ReplyFn = (content: string, files?: AttachmentBuilder[]) => Promise<void>;
84
+ export type ReplyFn = (
85
+ content: string,
86
+ files?: AttachmentBuilder[],
87
+ ) => Promise<void>;
51
88
 
52
89
  interface PendingGuildHistoryEntry {
53
90
  messageId: string;
@@ -70,6 +107,7 @@ export interface MessageRunContext {
70
107
  abortSignal: AbortSignal;
71
108
  stream: DiscordStreamManager;
72
109
  mentionLookup: MentionLookup;
110
+ emitLifecyclePhase: (phase: LifecyclePhase) => void;
73
111
  }
74
112
 
75
113
  export type MessageHandler = (
@@ -97,7 +135,6 @@ let messageHandler: MessageHandler;
97
135
  let commandHandler: CommandHandler;
98
136
  let activeConversationRuns = 0;
99
137
  let botMentionRegex: RegExp | null = null;
100
- const MESSAGE_DEBOUNCE_MS = 2_500;
101
138
  const DISCORD_RETRY_MAX_ATTEMPTS = 3;
102
139
  const DISCORD_RETRY_BASE_DELAY_MS = 500;
103
140
  const GUILD_INBOUND_HISTORY_LIMIT = 20;
@@ -107,8 +144,44 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
107
144
  const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
108
145
  const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
109
146
  const MAX_PRESENCE_CACHE_USERS = 5_000;
147
+ const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
148
+ const CONCURRENCY_RETRY_DELAY_MS = 250;
149
+ const PRESENCE_WINDOW_MS = 5 * 60_000;
150
+ const PRESENCE_DEGRADED_DURATION_MS = 45_000;
151
+ const PRESENCE_EXHAUSTED_ERROR_RE =
152
+ /(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
153
+ const FRIENDLY_RATE_LIMIT_MESSAGE =
154
+ "You're sending messages too fast — give me a moment to catch up!";
155
+ const READ_WITHOUT_REPLY_RE =
156
+ /^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
157
+ const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
158
+ const STARTUP_STAGGER_WINDOW_MS = 120_000;
159
+ const STARTUP_STAGGER_MIN_DELAY_MS = 500;
160
+ const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
161
+ const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
162
+ const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
163
+ const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
164
+ const NIGHT_HOURS_START = 22;
165
+ const NIGHT_HOURS_END = 7;
166
+ const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
167
+ const CONVERSATION_COOLDOWN_THRESHOLD = 5;
168
+ const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
110
169
 
111
170
  const discordPresenceCache = new Map<string, CachedDiscordPresence>();
171
+ const userRateLimiter = new SlidingWindowRateLimiter(60_000);
172
+ const recentConversationMetrics: Array<{
173
+ atMs: number;
174
+ durationMs: number;
175
+ ok: boolean;
176
+ exhaustedHint: boolean;
177
+ }> = [];
178
+ let consecutiveConversationFailures = 0;
179
+ let presenceController: DiscordAutoPresenceController | null = null;
180
+ let startupConnectedAtMs = 0;
181
+ const conversationExchangeByKey = new Map<
182
+ string,
183
+ { count: number; lastAtMs: number }
184
+ >();
112
185
 
113
186
  function setDiscordPresence(userId: string, data: CachedDiscordPresence): void {
114
187
  discordPresenceCache.set(userId, data);
@@ -132,7 +205,10 @@ function buildMentionLookup(
132
205
  const lookup: MentionLookup = { byAlias: new Map<string, Set<string>>() };
133
206
  const botUserId = client.user?.id || '';
134
207
 
135
- const addUser = (userId: string, aliases: Array<string | null | undefined>): void => {
208
+ const addUser = (
209
+ userId: string,
210
+ aliases: Array<string | null | undefined>,
211
+ ): void => {
136
212
  if (!userId || userId === botUserId) return;
137
213
  for (const alias of aliases) {
138
214
  addMentionAlias(lookup, alias, userId);
@@ -147,7 +223,8 @@ function buildMentionLookup(
147
223
  for (const mentioned of msg.mentions.users.values()) {
148
224
  const aliases = [mentioned.username];
149
225
  const mentionedMember = msg.mentions.members?.get(mentioned.id);
150
- if (mentionedMember?.displayName) aliases.push(mentionedMember.displayName);
226
+ if (mentionedMember?.displayName)
227
+ aliases.push(mentionedMember.displayName);
151
228
  addUser(mentioned.id, aliases);
152
229
  }
153
230
 
@@ -176,18 +253,25 @@ function summarizePendingHistoryEntry(entry: PendingGuildHistoryEntry): string {
176
253
  const author = entry.displayName || entry.username || 'user';
177
254
  const authorLabel = entry.isBot ? `${author} [bot]` : author;
178
255
  const content = entry.content.trim();
179
- const snippet = content.length > 300 ? `${content.slice(0, 297)}...` : content;
256
+ const snippet =
257
+ content.length > 300 ? `${content.slice(0, 297)}...` : content;
180
258
  return `${authorLabel}: ${snippet}`;
181
259
  }
182
260
 
183
- function buildPendingHistoryContext(entries: PendingGuildHistoryEntry[]): string {
261
+ function buildPendingHistoryContext(
262
+ entries: PendingGuildHistoryEntry[],
263
+ ): string {
184
264
  if (entries.length === 0) return '';
185
265
  const selected: string[] = [];
186
266
  let totalChars = 0;
187
267
  for (let i = entries.length - 1; i >= 0; i -= 1) {
188
268
  const line = summarizePendingHistoryEntry(entries[i]);
189
269
  if (!line) continue;
190
- if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS && selected.length > 0) break;
270
+ if (
271
+ totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
272
+ selected.length > 0
273
+ )
274
+ break;
191
275
  selected.push(line);
192
276
  totalChars += line.length + 1;
193
277
  }
@@ -206,10 +290,13 @@ async function buildInboundHistorySnapshot(
206
290
  msg: DiscordMessage,
207
291
  excludeMessageIds: Set<string>,
208
292
  ): Promise<{ entries: PendingGuildHistoryEntry[]; context: string }> {
209
- if (!msg.guild || !('messages' in msg.channel)) return { entries: [], context: '' };
293
+ if (!msg.guild || !('messages' in msg.channel))
294
+ return { entries: [], context: '' };
210
295
 
211
296
  try {
212
- const recentMessages = await msg.channel.messages.fetch({ limit: GUILD_INBOUND_HISTORY_LIMIT });
297
+ const recentMessages = await msg.channel.messages.fetch({
298
+ limit: GUILD_INBOUND_HISTORY_LIMIT,
299
+ });
213
300
  const entries: PendingGuildHistoryEntry[] = [];
214
301
  let hiddenTextCount = 0;
215
302
  let hiddenBotTextCount = 0;
@@ -219,7 +306,11 @@ async function buildInboundHistorySnapshot(
219
306
  if (plainText) return plainText;
220
307
 
221
308
  const embedChunks = recent.embeds
222
- .map((embed) => [embed.title?.trim(), embed.description?.trim()].filter(Boolean).join(' — '))
309
+ .map((embed) =>
310
+ [embed.title?.trim(), embed.description?.trim()]
311
+ .filter(Boolean)
312
+ .join(' — '),
313
+ )
223
314
  .map((part) => part.trim())
224
315
  .filter(Boolean)
225
316
  .slice(0, 3);
@@ -235,7 +326,9 @@ async function buildInboundHistorySnapshot(
235
326
  return `[attachments] ${attachmentNames.join(', ')}`;
236
327
  }
237
328
 
238
- const systemContent = recent.system ? (recent.cleanContent || '').trim() : '';
329
+ const systemContent = recent.system
330
+ ? (recent.cleanContent || '').trim()
331
+ : '';
239
332
  if (systemContent) return `[system] ${systemContent}`;
240
333
 
241
334
  hiddenTextCount += 1;
@@ -255,11 +348,16 @@ async function buildInboundHistorySnapshot(
255
348
  username: recent.author.username || 'user',
256
349
  displayName: recent.member?.displayName || null,
257
350
  isBot: Boolean(recent.author.bot),
258
- timestampMs: Number.isFinite(recent.createdTimestamp) ? recent.createdTimestamp : 0,
351
+ timestampMs: Number.isFinite(recent.createdTimestamp)
352
+ ? recent.createdTimestamp
353
+ : 0,
259
354
  content,
260
355
  });
261
356
  }
262
- entries.sort((a, b) => a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId));
357
+ entries.sort(
358
+ (a, b) =>
359
+ a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId),
360
+ );
263
361
  let context = buildPendingHistoryContext(entries);
264
362
  if (hiddenTextCount > 0) {
265
363
  const visibilityNote = [
@@ -276,18 +374,26 @@ async function buildInboundHistorySnapshot(
276
374
  context,
277
375
  };
278
376
  } catch (error) {
279
- logger.debug({ error, guildId: msg.guild.id, channelId: msg.channelId }, 'Failed to build inbound channel history snapshot');
377
+ logger.debug(
378
+ { error, guildId: msg.guild.id, channelId: msg.channelId },
379
+ 'Failed to build inbound channel history snapshot',
380
+ );
280
381
  return { entries: [], context: '' };
281
382
  }
282
383
  }
283
384
 
284
- function addParticipantAlias(info: ParticipantInfo, alias: string | null | undefined): void {
385
+ function addParticipantAlias(
386
+ info: ParticipantInfo,
387
+ alias: string | null | undefined,
388
+ ): void {
285
389
  const normalized = normalizeMentionAlias(alias);
286
390
  if (!normalized) return;
287
391
  info.aliases.add(normalized);
288
392
  }
289
393
 
290
- function formatDiscordHandleFromAlias(alias: string | null | undefined): string | null {
394
+ function formatDiscordHandleFromAlias(
395
+ alias: string | null | undefined,
396
+ ): string | null {
291
397
  const normalized = normalizeMentionAlias(alias);
292
398
  if (!normalized) return null;
293
399
  return `@${normalized}`;
@@ -370,7 +476,8 @@ function buildParticipantContext(
370
476
  .slice(0, PARTICIPANT_CONTEXT_MAX_USERS)
371
477
  .map((entry) => {
372
478
  const aliases = Array.from(entry.aliases).slice(0, 3);
373
- const preferredHandle = formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
479
+ const preferredHandle =
480
+ formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
374
481
  const botSuffix = botParticipantIds.has(entry.id) ? ' [bot]' : '';
375
482
  return `- ${preferredHandle}${botSuffix} id:${entry.id} aliases: ${aliases.join(', ')}`;
376
483
  });
@@ -416,7 +523,11 @@ export async function runDiscordToolAction(
416
523
  }
417
524
 
418
525
  function getSessionId(msg: DiscordMessage): string {
419
- return buildSessionIdFromContext(msg.guild?.id ?? null, msg.channelId, msg.author.id);
526
+ return buildSessionIdFromContext(
527
+ msg.guild?.id ?? null,
528
+ msg.channelId,
529
+ msg.author.id,
530
+ );
420
531
  }
421
532
 
422
533
  function hasPrefixInvocation(content: string): boolean {
@@ -429,22 +540,123 @@ function isAuthorizedCommandUserId(userId: string): boolean {
429
540
  return userId === configuredUserId;
430
541
  }
431
542
 
432
- function buildSessionIdFromContext(guildId: string | null, channelId: string, userId: string): string {
543
+ function buildSessionIdFromContext(
544
+ guildId: string | null,
545
+ channelId: string,
546
+ userId: string,
547
+ ): string {
433
548
  return buildSessionIdFromContextInbound(guildId, channelId, userId);
434
549
  }
435
550
 
436
- function isTrigger(msg: DiscordMessage): boolean {
551
+ interface ResolvedChannelBehavior {
552
+ guildMessageMode: DiscordGuildMessageMode;
553
+ typingMode: 'instant' | 'thinking' | 'streaming' | 'never';
554
+ debounceMs: number;
555
+ ackReaction: string;
556
+ ackReactionScope: 'all' | 'group-mentions' | 'direct' | 'off';
557
+ removeAckAfterReply: boolean;
558
+ humanDelay: HumanDelayConfig;
559
+ rateLimitPerUser: number;
560
+ suppressPatterns: string[];
561
+ maxConcurrentPerChannel: number;
562
+ }
563
+
564
+ function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
565
+ if (!msg.guild) return 'free';
566
+ if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
567
+
568
+ const guildConfig = DISCORD_GUILDS[msg.guild.id];
569
+ const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
570
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
571
+ return explicitMode ?? 'off';
572
+ }
573
+ if (explicitMode) return explicitMode;
574
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId)) return 'free';
575
+ if (guildConfig) return guildConfig.defaultMode;
576
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'free';
577
+ return 'mention';
578
+ }
579
+
580
+ function resolveChannelBehavior(msg: DiscordMessage): ResolvedChannelBehavior {
581
+ const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
582
+ const channelConfig = guildConfig?.channels[msg.channelId];
583
+ return {
584
+ guildMessageMode: resolveGuildMessageMode(msg),
585
+ typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
586
+ debounceMs: resolveInboundDebounceMs(
587
+ DISCORD_DEBOUNCE_MS,
588
+ channelConfig?.debounceMs,
589
+ ),
590
+ ackReaction:
591
+ (channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
592
+ DISCORD_ACK_REACTION,
593
+ ackReactionScope:
594
+ channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
595
+ removeAckAfterReply:
596
+ channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
597
+ humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
598
+ rateLimitPerUser: Math.max(
599
+ 0,
600
+ channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER,
601
+ ),
602
+ suppressPatterns:
603
+ channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
604
+ maxConcurrentPerChannel: Math.max(
605
+ 1,
606
+ channelConfig?.maxConcurrentPerChannel ??
607
+ DISCORD_MAX_CONCURRENT_PER_CHANNEL,
608
+ ),
609
+ };
610
+ }
611
+
612
+ function isTrigger(
613
+ msg: DiscordMessage,
614
+ behavior: ResolvedChannelBehavior,
615
+ ): boolean {
437
616
  return isTriggerInbound({
438
617
  content: msg.content,
439
618
  isDm: !msg.guild,
440
619
  commandsOnly: DISCORD_COMMANDS_ONLY,
441
620
  respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
621
+ guildMessageMode: behavior.guildMessageMode,
442
622
  prefix: DISCORD_PREFIX,
443
623
  botMentionRegex,
444
624
  hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
625
+ suppressPatterns: behavior.suppressPatterns,
445
626
  });
446
627
  }
447
628
 
629
+ function shouldApplyAckReaction(
630
+ msg: DiscordMessage,
631
+ behavior: ResolvedChannelBehavior,
632
+ ): boolean {
633
+ const scope = behavior.ackReactionScope;
634
+ if (scope === 'off') return false;
635
+ if (scope === 'all') return true;
636
+ if (scope === 'direct') return !msg.guild;
637
+ if (!msg.guild || !client.user) return false;
638
+ return msg.mentions.has(client.user);
639
+ }
640
+
641
+ function isRateLimitExempt(msg: DiscordMessage): boolean {
642
+ if (msg.author.id === DISCORD_COMMAND_USER_ID.trim()) return true;
643
+ if (!msg.guild) return false;
644
+ if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0) return false;
645
+
646
+ const exemptByName = new Set(
647
+ DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) =>
648
+ role.trim().toLowerCase(),
649
+ ).filter(Boolean),
650
+ );
651
+ if (exemptByName.size === 0) return false;
652
+
653
+ for (const role of msg.member.roles.cache.values()) {
654
+ const normalized = role.name.trim().toLowerCase();
655
+ if (exemptByName.has(normalized)) return true;
656
+ }
657
+ return false;
658
+ }
659
+
448
660
  function parseCommand(content: string): ParsedCommand {
449
661
  return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
450
662
  }
@@ -452,19 +664,29 @@ function parseCommand(content: string): ParsedCommand {
452
664
  function isRetryableDiscordError(error: unknown): boolean {
453
665
  const maybe = error as DiscordErrorLike;
454
666
  const status = maybe.status ?? maybe.httpStatus;
455
- return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
667
+ return (
668
+ status === 429 ||
669
+ (typeof status === 'number' && status >= 500 && status <= 599)
670
+ );
456
671
  }
457
672
 
458
673
  function retryDelayMs(error: unknown, fallbackMs: number): number {
459
674
  const maybe = error as DiscordErrorLike;
460
675
  const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
461
- if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
676
+ if (
677
+ typeof retryAfterSeconds === 'number' &&
678
+ Number.isFinite(retryAfterSeconds) &&
679
+ retryAfterSeconds > 0
680
+ ) {
462
681
  return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
463
682
  }
464
683
  return fallbackMs + Math.floor(Math.random() * 250);
465
684
  }
466
685
 
467
- async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
686
+ async function withDiscordRetry<T>(
687
+ label: string,
688
+ fn: () => Promise<T>,
689
+ ): Promise<T> {
468
690
  let attempt = 0;
469
691
  let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
470
692
  while (true) {
@@ -472,11 +694,17 @@ async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise
472
694
  try {
473
695
  return await fn();
474
696
  } catch (error) {
475
- if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
697
+ if (
698
+ attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
699
+ !isRetryableDiscordError(error)
700
+ ) {
476
701
  throw error;
477
702
  }
478
703
  const waitMs = retryDelayMs(error, delayMs);
479
- logger.warn({ label, attempt, waitMs, error }, 'Discord API call failed; retrying');
704
+ logger.warn(
705
+ { label, attempt, waitMs, error },
706
+ 'Discord API call failed; retrying',
707
+ );
480
708
  await new Promise((resolve) => setTimeout(resolve, waitMs));
481
709
  delayMs = Math.min(delayMs * 2, 4_000);
482
710
  }
@@ -490,7 +718,8 @@ function cleanIncomingContent(content: string): string {
490
718
  function summarizeContextMessage(msg: DiscordMessage): string {
491
719
  const author = msg.author?.username || 'user';
492
720
  const content = (msg.content || '').trim();
493
- const snippet = content.length > 500 ? `${content.slice(0, 497)}...` : content;
721
+ const snippet =
722
+ content.length > 500 ? `${content.slice(0, 497)}...` : content;
494
723
  return `${author}: ${snippet || '(no text)'}`;
495
724
  }
496
725
 
@@ -503,16 +732,25 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
503
732
  `- channel_id: ${msg.channelId}`,
504
733
  ];
505
734
 
506
- const namedChannel = msg.channel as unknown as { name?: string; topic?: string; parent?: { name?: string | null } | null };
507
- const channelName = typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
735
+ const namedChannel = msg.channel as unknown as {
736
+ name?: string;
737
+ topic?: string;
738
+ parent?: { name?: string | null } | null;
739
+ };
740
+ const channelName =
741
+ typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
508
742
  if (channelName) {
509
743
  lines.push(`- channel_name: #${channelName}`);
510
744
  }
511
- const channelTopic = typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
745
+ const channelTopic =
746
+ typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
512
747
  if (channelTopic) {
513
748
  lines.push(`- channel_topic: ${channelTopic}`);
514
749
  }
515
- const parentName = typeof namedChannel.parent?.name === 'string' ? namedChannel.parent.name.trim() : '';
750
+ const parentName =
751
+ typeof namedChannel.parent?.name === 'string'
752
+ ? namedChannel.parent.name.trim()
753
+ : '';
516
754
  if (parentName) {
517
755
  lines.push(`- parent_channel: ${parentName}`);
518
756
  }
@@ -524,14 +762,21 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
524
762
  async function buildReplyContext(msg: DiscordMessage): Promise<string> {
525
763
  const blocks: string[] = [];
526
764
 
527
- if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
765
+ if (
766
+ 'isThread' in msg.channel &&
767
+ typeof msg.channel.isThread === 'function' &&
768
+ msg.channel.isThread()
769
+ ) {
528
770
  try {
529
771
  const starter = await msg.channel.fetchStarterMessage();
530
772
  if (starter) {
531
773
  blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
532
774
  }
533
775
  } catch (error) {
534
- logger.debug({ error, channelId: msg.channelId }, 'Failed to fetch thread starter message');
776
+ logger.debug(
777
+ { error, channelId: msg.channelId },
778
+ 'Failed to fetch thread starter message',
779
+ );
535
780
  }
536
781
  }
537
782
 
@@ -556,63 +801,18 @@ async function buildReplyContext(msg: DiscordMessage): Promise<string> {
556
801
  return `${blocks.join('\n\n')}\n\n`;
557
802
  }
558
803
 
559
- async function addProcessingReaction(msg: DiscordMessage): Promise<() => Promise<void>> {
560
- if (!client.user) return async () => {};
561
- const botUserId = client.user.id;
562
- try {
563
- await withDiscordRetry('react', () => msg.react('👀'));
564
- } catch (error) {
565
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
566
- return async () => {};
567
- }
568
-
569
- return async () => {
570
- try {
571
- const reaction = msg.reactions.resolve('👀');
572
- if (!reaction) return;
573
- await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
574
- } catch (error) {
575
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
576
- }
577
- };
578
- }
579
-
580
- function startTypingLoop(msg: DiscordMessage): { stop: () => void } {
581
- let stopped = false;
582
- const sendTyping = async (): Promise<void> => {
583
- if (stopped) return;
584
- if (!('sendTyping' in msg.channel)) return;
585
- try {
586
- await msg.channel.sendTyping();
587
- } catch (error) {
588
- logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
589
- }
590
- };
591
-
592
- void sendTyping();
593
- const timer = setInterval(() => {
594
- void sendTyping();
595
- }, 8_000);
596
-
597
- return {
598
- stop: () => {
599
- if (stopped) return;
600
- stopped = true;
601
- clearInterval(timer);
602
- },
603
- };
604
- }
605
-
606
804
  async function sendChunkedReply(
607
805
  msg: DiscordMessage,
608
806
  text: string,
609
807
  files?: AttachmentBuilder[],
610
808
  mentionLookup?: MentionLookup,
809
+ humanDelay?: HumanDelayConfig,
611
810
  ): Promise<void> {
612
811
  await sendChunkedReplyFromDelivery({
613
812
  msg,
614
813
  text,
615
814
  withRetry: withDiscordRetry,
815
+ ...(humanDelay ? { humanDelay } : {}),
616
816
  ...(files ? { files } : {}),
617
817
  ...(mentionLookup ? { mentionLookup } : {}),
618
818
  });
@@ -623,18 +823,22 @@ async function sendChunkedDirectReply(
623
823
  text: string,
624
824
  files?: AttachmentBuilder[],
625
825
  mentionLookup?: MentionLookup,
826
+ humanDelay?: HumanDelayConfig,
626
827
  ): Promise<void> {
627
828
  await sendChunkedDirectReplyFromDelivery({
628
829
  msg,
629
830
  text,
630
831
  withRetry: withDiscordRetry,
832
+ ...(humanDelay ? { humanDelay } : {}),
631
833
  ...(files ? { files } : {}),
632
834
  ...(mentionLookup ? { mentionLookup } : {}),
633
835
  });
634
836
  }
635
837
 
636
838
  async function sendChunkedInteractionReply(
637
- interaction: Parameters<typeof sendChunkedInteractionReplyFromDelivery>[0]['interaction'],
839
+ interaction: Parameters<
840
+ typeof sendChunkedInteractionReplyFromDelivery
841
+ >[0]['interaction'],
638
842
  text: string,
639
843
  files?: AttachmentBuilder[],
640
844
  ): Promise<void> {
@@ -646,74 +850,266 @@ async function sendChunkedInteractionReply(
646
850
  });
647
851
  }
648
852
 
649
- async function ensureSlashStatusCommand(): Promise<void> {
650
- const definition = {
651
- name: 'status',
652
- description: 'Show HybridClaw runtime status (only visible to you)',
653
- };
853
+ async function ensureSlashCommands(): Promise<void> {
854
+ interface SlashCommandDefinition {
855
+ name: string;
856
+ description: string;
857
+ options?: Array<{
858
+ type: ApplicationCommandOptionType.String;
859
+ name: string;
860
+ description: string;
861
+ required?: boolean;
862
+ choices?: Array<{ name: string; value: string }>;
863
+ }>;
864
+ }
865
+
866
+ const definitions: SlashCommandDefinition[] = [
867
+ {
868
+ name: 'status',
869
+ description: 'Show HybridClaw runtime status (only visible to you)',
870
+ },
871
+ {
872
+ name: 'channel-mode',
873
+ description: 'Set this channel to off, mention-only, or free-response',
874
+ options: [
875
+ {
876
+ type: ApplicationCommandOptionType.String,
877
+ name: 'mode',
878
+ description: 'Response mode for this channel',
879
+ required: true,
880
+ choices: [
881
+ { name: 'off', value: 'off' },
882
+ { name: 'mention', value: 'mention' },
883
+ { name: 'free', value: 'free' },
884
+ ],
885
+ },
886
+ ],
887
+ },
888
+ {
889
+ name: 'channel-policy',
890
+ description: 'Set guild channel policy to open, allowlist, or disabled',
891
+ options: [
892
+ {
893
+ type: ApplicationCommandOptionType.String,
894
+ name: 'policy',
895
+ description: 'Guild channel policy',
896
+ required: true,
897
+ choices: [
898
+ { name: 'open', value: 'open' },
899
+ { name: 'allowlist', value: 'allowlist' },
900
+ { name: 'disabled', value: 'disabled' },
901
+ ],
902
+ },
903
+ ],
904
+ },
905
+ ];
654
906
 
655
907
  if (!client.application) return;
656
908
  await Promise.allSettled(
657
909
  [...client.guilds.cache.values()].map(async (guild) => {
658
910
  try {
659
911
  const existing = await guild.commands.fetch();
660
- const current = existing.find((command) => command.name === definition.name);
661
- if (!current) {
662
- await guild.commands.create(definition);
663
- logger.info({ guildId: guild.id }, 'Registered slash command /status');
664
- return;
665
- }
666
- if (current.description !== definition.description) {
667
- await guild.commands.edit(current.id, definition);
668
- logger.info({ guildId: guild.id }, 'Updated slash command /status');
912
+ for (const definition of definitions) {
913
+ const current = existing.find(
914
+ (command) => command.name === definition.name,
915
+ );
916
+ if (!current) {
917
+ await guild.commands.create(
918
+ definition as unknown as ApplicationCommandDataResolvable,
919
+ );
920
+ logger.info(
921
+ { guildId: guild.id, command: definition.name },
922
+ 'Registered slash command',
923
+ );
924
+ continue;
925
+ }
926
+ await guild.commands.edit(
927
+ current.id,
928
+ definition as unknown as ApplicationCommandDataResolvable,
929
+ );
930
+ logger.info(
931
+ { guildId: guild.id, command: definition.name },
932
+ 'Updated slash command',
933
+ );
669
934
  }
670
935
  } catch (error) {
671
- logger.warn({ error, guildId: guild.id }, 'Failed to register slash command /status');
936
+ logger.warn(
937
+ { error, guildId: guild.id },
938
+ 'Failed to register Discord slash commands',
939
+ );
672
940
  }
673
941
  }),
674
942
  );
675
943
  }
676
944
 
677
- function updatePresence(): void {
678
- if (!client.user) return;
679
- if (activeConversationRuns > 0) {
680
- client.user.setPresence({
681
- activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
682
- status: 'online',
683
- });
945
+ function trimRecentConversationMetrics(nowMs = Date.now()): void {
946
+ const cutoff = nowMs - PRESENCE_WINDOW_MS;
947
+ while (
948
+ recentConversationMetrics.length > 0 &&
949
+ recentConversationMetrics[0].atMs < cutoff
950
+ ) {
951
+ recentConversationMetrics.shift();
952
+ }
953
+ }
954
+
955
+ function recordConversationMetric(params: {
956
+ durationMs: number;
957
+ ok: boolean;
958
+ error?: unknown;
959
+ }): void {
960
+ const nowMs = Date.now();
961
+ const errorText =
962
+ params.error instanceof Error
963
+ ? params.error.message
964
+ : String(params.error || '');
965
+ const exhaustedHint =
966
+ !params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
967
+ if (params.ok) {
968
+ consecutiveConversationFailures = 0;
969
+ } else {
970
+ consecutiveConversationFailures += 1;
971
+ }
972
+ recentConversationMetrics.push({
973
+ atMs: nowMs,
974
+ durationMs: Math.max(0, Math.floor(params.durationMs)),
975
+ ok: params.ok,
976
+ exhaustedHint,
977
+ });
978
+ trimRecentConversationMetrics(nowMs);
979
+ }
980
+
981
+ function resolvePresenceHealthState(): PresenceHealthState {
982
+ trimRecentConversationMetrics();
983
+ if (activeConversationRuns >= 4) return 'degraded';
984
+ if (consecutiveConversationFailures >= 3) return 'exhausted';
985
+ if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
986
+ return 'exhausted';
987
+
988
+ if (recentConversationMetrics.length === 0) return 'healthy';
989
+ const totalDuration = recentConversationMetrics.reduce(
990
+ (sum, entry) => sum + entry.durationMs,
991
+ 0,
992
+ );
993
+ const avgDuration = totalDuration / recentConversationMetrics.length;
994
+ const failureCount = recentConversationMetrics.filter(
995
+ (entry) => !entry.ok,
996
+ ).length;
997
+ const errorRate = failureCount / recentConversationMetrics.length;
998
+ if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
999
+ return 'degraded';
1000
+ return 'healthy';
1001
+ }
1002
+
1003
+ function randomIntInRange(minMs: number, maxMs: number): number {
1004
+ const lo = Math.floor(Math.max(0, minMs));
1005
+ const hi = Math.floor(Math.max(lo, maxMs));
1006
+ if (hi <= lo) return lo;
1007
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
1008
+ }
1009
+
1010
+ function isNightOrWeekend(now: Date): boolean {
1011
+ const day = now.getDay();
1012
+ const hour = now.getHours();
1013
+ const weekend = day === 0 || day === 6;
1014
+ const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
1015
+ return weekend || night;
1016
+ }
1017
+
1018
+ function buildConversationCooldownKey(
1019
+ channelId: string,
1020
+ userId: string,
1021
+ ): string {
1022
+ return `${channelId}:${userId}`;
1023
+ }
1024
+
1025
+ function resolveHumanDelayWithBehavior(
1026
+ base: HumanDelayConfig,
1027
+ cooldownKey: string,
1028
+ ): HumanDelayConfig {
1029
+ if (base.mode === 'off') return base;
1030
+ const now = new Date();
1031
+ let factor = isNightOrWeekend(now) ? 1.5 : 1;
1032
+ const record = conversationExchangeByKey.get(cooldownKey);
1033
+ if (record) {
1034
+ const elapsedMs = Date.now() - record.lastAtMs;
1035
+ if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
1036
+ conversationExchangeByKey.delete(cooldownKey);
1037
+ } else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
1038
+ const extra = Math.min(
1039
+ CONVERSATION_COOLDOWN_MAX_FACTOR - 1,
1040
+ (record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12,
1041
+ );
1042
+ factor += Math.max(0, extra);
1043
+ }
1044
+ }
1045
+
1046
+ if (factor <= 1) return base;
1047
+ const minMs = Math.round((base.minMs ?? 800) * factor);
1048
+ const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
1049
+ return {
1050
+ mode: 'custom',
1051
+ minMs,
1052
+ maxMs: Math.max(minMs, maxMs),
1053
+ };
1054
+ }
1055
+
1056
+ function noteConversationExchange(cooldownKey: string): void {
1057
+ const nowMs = Date.now();
1058
+ const existing = conversationExchangeByKey.get(cooldownKey);
1059
+ if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
1060
+ conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
684
1061
  return;
685
1062
  }
686
- client.user.setPresence({
687
- activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
688
- status: 'online',
1063
+ conversationExchangeByKey.set(cooldownKey, {
1064
+ count: existing.count + 1,
1065
+ lastAtMs: nowMs,
689
1066
  });
690
1067
  }
691
1068
 
692
- export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler): Client {
1069
+ function pickReadWithoutReplyEmoji(): string {
1070
+ return Math.random() < 0.7 ? '👍' : '✅';
1071
+ }
1072
+
1073
+ export function initDiscord(
1074
+ onMessage: MessageHandler,
1075
+ onCommand: CommandHandler,
1076
+ ): Client {
693
1077
  messageHandler = onMessage;
694
1078
  commandHandler = onCommand;
695
1079
 
696
1080
  interface QueuedConversationMessage {
697
1081
  msg: DiscordMessage;
698
1082
  content: string;
699
- clearReaction: () => Promise<void>;
1083
+ behavior: ResolvedChannelBehavior;
1084
+ clearAckReaction: () => Promise<void>;
1085
+ wasExplicitlyAddressed: boolean;
1086
+ cooldownKey: string;
700
1087
  }
701
1088
  interface PendingConversationBatch {
702
1089
  items: QueuedConversationMessage[];
703
1090
  timer: ReturnType<typeof setTimeout>;
1091
+ typingController: ReturnType<typeof createTypingController>;
1092
+ lifecycleController: LifecycleReactionController | null;
704
1093
  }
705
1094
  interface InFlightConversation {
706
1095
  abortController: AbortController;
707
1096
  stream: DiscordStreamManager;
708
1097
  messageIds: Set<string>;
709
1098
  aborted: boolean;
1099
+ emitLifecyclePhase: (phase: LifecyclePhase) => void;
710
1100
  }
711
1101
  const pendingBatches = new Map<string, PendingConversationBatch>();
712
1102
  const inFlightByMessageId = new Map<string, InFlightConversation>();
1103
+ const channelConcurrencyById = new Map<string, number>();
713
1104
  const negativeFeedbackByChannel = new Map<string, string>();
714
- const participantMemoryByChannel = new Map<string, Map<string, Set<string>>>();
715
-
716
- const touchParticipantMemoryChannel = (channelId: string): Map<string, Set<string>> => {
1105
+ const participantMemoryByChannel = new Map<
1106
+ string,
1107
+ Map<string, Set<string>>
1108
+ >();
1109
+
1110
+ const touchParticipantMemoryChannel = (
1111
+ channelId: string,
1112
+ ): Map<string, Set<string>> => {
717
1113
  const existing = participantMemoryByChannel.get(channelId);
718
1114
  if (existing) {
719
1115
  participantMemoryByChannel.delete(channelId);
@@ -730,7 +1126,11 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
730
1126
  return created;
731
1127
  };
732
1128
 
733
- const rememberParticipantAliasForChannel = (channelId: string, userId: string, rawAlias: string | null | undefined): void => {
1129
+ const rememberParticipantAliasForChannel = (
1130
+ channelId: string,
1131
+ userId: string,
1132
+ rawAlias: string | null | undefined,
1133
+ ): void => {
734
1134
  if (!userId || userId === client.user?.id) return;
735
1135
  const alias = normalizeMentionAlias(rawAlias);
736
1136
  if (!alias) return;
@@ -747,7 +1147,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
747
1147
  }
748
1148
  aliases.add(alias);
749
1149
  if (aliases.size > PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER) {
750
- const kept = new Set(Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER));
1150
+ const kept = new Set(
1151
+ Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER),
1152
+ );
751
1153
  channelMemory.set(userId, kept);
752
1154
  }
753
1155
  // Refresh user recency.
@@ -758,14 +1160,21 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
758
1160
  }
759
1161
  };
760
1162
 
761
- const rememberParticipantForChannel = (channelId: string, userId: string, aliases: Array<string | null | undefined>): void => {
1163
+ const rememberParticipantForChannel = (
1164
+ channelId: string,
1165
+ userId: string,
1166
+ aliases: Array<string | null | undefined>,
1167
+ ): void => {
762
1168
  if (!userId || userId === client.user?.id) return;
763
1169
  for (const alias of aliases) {
764
1170
  rememberParticipantAliasForChannel(channelId, userId, alias);
765
1171
  }
766
1172
  };
767
1173
 
768
- const observeMessageParticipants = (msg: DiscordMessage, content: string): void => {
1174
+ const observeMessageParticipants = (
1175
+ msg: DiscordMessage,
1176
+ content: string,
1177
+ ): void => {
769
1178
  if (!msg.guild) return;
770
1179
  rememberParticipantForChannel(msg.channelId, msg.author.id, [
771
1180
  msg.author.username,
@@ -779,10 +1188,140 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
779
1188
  ]);
780
1189
  }
781
1190
  for (const hint of extractMentionAliasHints(content)) {
782
- rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
1191
+ rememberParticipantAliasForChannel(
1192
+ msg.channelId,
1193
+ hint.userId,
1194
+ hint.alias,
1195
+ );
1196
+ }
1197
+ };
1198
+
1199
+ const waitForChannelConcurrencySlot = async (
1200
+ channelId: string,
1201
+ maxConcurrent: number,
1202
+ abortSignal?: AbortSignal,
1203
+ ): Promise<() => void> => {
1204
+ const boundedMax = Math.max(1, Math.floor(maxConcurrent));
1205
+ while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
1206
+ if (abortSignal?.aborted) {
1207
+ throw new Error(
1208
+ 'Conversation aborted while waiting for channel concurrency slot.',
1209
+ );
1210
+ }
1211
+ await new Promise((resolve) =>
1212
+ setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS),
1213
+ );
1214
+ }
1215
+ if (abortSignal?.aborted) {
1216
+ throw new Error(
1217
+ 'Conversation aborted before acquiring channel concurrency slot.',
1218
+ );
1219
+ }
1220
+ channelConcurrencyById.set(
1221
+ channelId,
1222
+ (channelConcurrencyById.get(channelId) ?? 0) + 1,
1223
+ );
1224
+ return () => {
1225
+ const current = channelConcurrencyById.get(channelId) ?? 0;
1226
+ const next = Math.max(0, current - 1);
1227
+ if (next === 0) {
1228
+ channelConcurrencyById.delete(channelId);
1229
+ } else {
1230
+ channelConcurrencyById.set(channelId, next);
1231
+ }
1232
+ };
1233
+ };
1234
+
1235
+ const enforcePerUserRateLimit = async (
1236
+ msg: DiscordMessage,
1237
+ behavior: ResolvedChannelBehavior,
1238
+ ): Promise<boolean> => {
1239
+ const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
1240
+ if (limit === 0) return true;
1241
+ if (isRateLimitExempt(msg)) return true;
1242
+
1243
+ const key = `${msg.channelId}:${msg.author.id}`;
1244
+ const decision = userRateLimiter.check(key, limit);
1245
+ if (decision.allowed) return true;
1246
+
1247
+ if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
1248
+ try {
1249
+ await withDiscordRetry('rate-limit-reply', () =>
1250
+ msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }),
1251
+ );
1252
+ } catch (error) {
1253
+ logger.debug(
1254
+ { error, channelId: msg.channelId, userId: msg.author.id },
1255
+ 'Failed to send rate-limit warning',
1256
+ );
1257
+ }
1258
+ }
1259
+ return false;
1260
+ };
1261
+
1262
+ const maybeHandleReadWithoutReply = async (
1263
+ msg: DiscordMessage,
1264
+ content: string,
1265
+ ): Promise<boolean> => {
1266
+ if (!content.trim()) return false;
1267
+ if (msg.attachments.size > 0) return false;
1268
+ if (hasPrefixInvocation(msg.content || '')) return false;
1269
+ if (client.user && msg.mentions.has(client.user)) return false;
1270
+ if (content.trim().length > 80) return false;
1271
+ if (!READ_WITHOUT_REPLY_RE.test(content.trim())) return false;
1272
+ if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY) return false;
1273
+
1274
+ try {
1275
+ await withDiscordRetry('reaction-read-without-reply', () =>
1276
+ msg.react(pickReadWithoutReplyEmoji()),
1277
+ );
1278
+ return true;
1279
+ } catch (error) {
1280
+ logger.debug(
1281
+ { error, channelId: msg.channelId, messageId: msg.id },
1282
+ 'Failed read-without-reply reaction',
1283
+ );
1284
+ return false;
783
1285
  }
784
1286
  };
785
1287
 
1288
+ const shouldSelectivelySilence = (params: {
1289
+ sourceItem: QueuedConversationMessage;
1290
+ inboundHistory: PendingGuildHistoryEntry[];
1291
+ behavior: ResolvedChannelBehavior;
1292
+ }): boolean => {
1293
+ if (!params.sourceItem.msg.guild) return false;
1294
+ if (params.behavior.guildMessageMode !== 'free') return false;
1295
+ if (params.sourceItem.wasExplicitlyAddressed) return false;
1296
+
1297
+ const nowMs = Date.now();
1298
+ const peerMessages = params.inboundHistory
1299
+ .filter(
1300
+ (entry) =>
1301
+ !entry.isBot &&
1302
+ entry.userId !== params.sourceItem.msg.author.id &&
1303
+ nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS,
1304
+ )
1305
+ .sort((a, b) => a.timestampMs - b.timestampMs);
1306
+ if (peerMessages.length === 0) return false;
1307
+
1308
+ const sourceText = params.sourceItem.content.trim();
1309
+ const asksQuestion = sourceText.includes('?');
1310
+ const latestPeer =
1311
+ peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
1312
+ '';
1313
+ const peerLooksLikeAnswer =
1314
+ latestPeer.length >= 24 ||
1315
+ /\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(
1316
+ latestPeer,
1317
+ );
1318
+ const probability =
1319
+ asksQuestion && peerLooksLikeAnswer
1320
+ ? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
1321
+ : SELECTIVE_SILENCE_BASE_PROBABILITY;
1322
+ return Math.random() < probability;
1323
+ };
1324
+
786
1325
  const intents: GatewayIntentBits[] = [
787
1326
  GatewayIntentBits.Guilds,
788
1327
  GatewayIntentBits.GuildMessages,
@@ -790,12 +1329,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
790
1329
  GatewayIntentBits.MessageContent,
791
1330
  GatewayIntentBits.DirectMessages,
792
1331
  ];
793
- if (DISCORD_GUILD_MEMBERS_INTENT) intents.push(GatewayIntentBits.GuildMembers);
1332
+ if (DISCORD_GUILD_MEMBERS_INTENT)
1333
+ intents.push(GatewayIntentBits.GuildMembers);
794
1334
  if (DISCORD_PRESENCE_INTENT) intents.push(GatewayIntentBits.GuildPresences);
795
1335
 
796
1336
  client = new Client({
797
1337
  intents,
798
- partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
1338
+ partials: [
1339
+ Partials.Channel,
1340
+ Partials.Message,
1341
+ Partials.Reaction,
1342
+ Partials.User,
1343
+ ],
799
1344
  });
800
1345
 
801
1346
  client.on('presenceUpdate', (_oldPresence, nextPresence) => {
@@ -814,16 +1359,29 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
814
1359
 
815
1360
  client.on('clientReady', () => {
816
1361
  logger.info({ user: client.user?.tag }, 'Discord bot connected');
1362
+ startupConnectedAtMs = Date.now();
817
1363
  if (client.user) {
818
1364
  botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
819
1365
  }
820
- updatePresence();
821
- void ensureSlashStatusCommand();
1366
+ presenceController?.stop();
1367
+ presenceController = new DiscordAutoPresenceController({
1368
+ client,
1369
+ getConfig: () => DISCORD_SELF_PRESENCE,
1370
+ resolveState: resolvePresenceHealthState,
1371
+ });
1372
+ presenceController.start();
1373
+ void ensureSlashCommands();
822
1374
  });
823
1375
 
824
1376
  client.on('interactionCreate', async (interaction) => {
825
1377
  if (!interaction.isChatInputCommand()) return;
826
- if (interaction.commandName !== 'status') return;
1378
+ if (
1379
+ interaction.commandName !== 'status' &&
1380
+ interaction.commandName !== 'channel-mode' &&
1381
+ interaction.commandName !== 'channel-policy'
1382
+ ) {
1383
+ return;
1384
+ }
827
1385
 
828
1386
  if (!isAuthorizedCommandUserId(interaction.user.id)) {
829
1387
  await sendChunkedInteractionReply(
@@ -835,22 +1393,69 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
835
1393
 
836
1394
  const guildId = interaction.guildId ?? null;
837
1395
  const channelId = interaction.channelId;
838
- const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
1396
+ const sessionId = buildSessionIdFromContext(
1397
+ guildId,
1398
+ channelId,
1399
+ interaction.user.id,
1400
+ );
1401
+ const args =
1402
+ interaction.commandName === 'status'
1403
+ ? ['status']
1404
+ : interaction.commandName === 'channel-mode'
1405
+ ? (() => {
1406
+ if (!interaction.guildId) return null;
1407
+ const selectedMode = interaction.options
1408
+ .getString('mode', true)
1409
+ .trim()
1410
+ .toLowerCase();
1411
+ if (
1412
+ selectedMode !== 'off' &&
1413
+ selectedMode !== 'mention' &&
1414
+ selectedMode !== 'free'
1415
+ )
1416
+ return null;
1417
+ return ['channel', 'mode', selectedMode];
1418
+ })()
1419
+ : (() => {
1420
+ if (!interaction.guildId) return null;
1421
+ const selectedPolicy = interaction.options
1422
+ .getString('policy', true)
1423
+ .trim()
1424
+ .toLowerCase();
1425
+ if (
1426
+ selectedPolicy !== 'open' &&
1427
+ selectedPolicy !== 'allowlist' &&
1428
+ selectedPolicy !== 'disabled'
1429
+ )
1430
+ return null;
1431
+ return ['channel', 'policy', selectedPolicy];
1432
+ })();
1433
+ if (!args) {
1434
+ await sendChunkedInteractionReply(
1435
+ interaction,
1436
+ 'This command can only be used in a server channel with a valid mode/policy option.',
1437
+ );
1438
+ return;
1439
+ }
839
1440
  try {
840
1441
  await commandHandler(
841
1442
  sessionId,
842
1443
  guildId,
843
1444
  channelId,
844
- ['status'],
845
- async (text, files) => sendChunkedInteractionReply(interaction, text, files),
1445
+ args,
1446
+ async (text, files) =>
1447
+ sendChunkedInteractionReply(interaction, text, files),
846
1448
  );
847
1449
  } catch (error) {
848
1450
  const detail = error instanceof Error ? error.message : String(error);
849
1451
  logger.error(
850
1452
  { error, guildId, channelId, userId: interaction.user.id },
851
- 'Discord slash /status command failed',
1453
+ 'Discord slash command failed',
1454
+ );
1455
+ await sendChunkedInteractionReply(
1456
+ interaction,
1457
+ formatError('Gateway Error', detail),
852
1458
  );
853
- await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
854
1459
  }
855
1460
  });
856
1461
 
@@ -868,10 +1473,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
868
1473
  const channelId = msg.channelId;
869
1474
  const userId = msg.author.id;
870
1475
  const username = msg.author.username;
871
-
872
- const batchedContent = items.length > 1
873
- ? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
874
- : sourceItem.content;
1476
+ const behavior = sourceItem.behavior;
1477
+ const startedAt = Date.now();
1478
+ let releaseChannelSlot: (() => void) | null = null;
1479
+
1480
+ const batchedContent =
1481
+ items.length > 1
1482
+ ? items
1483
+ .map((item, index) => `Message ${index + 1}:\n${item.content}`)
1484
+ .join('\n\n')
1485
+ : sourceItem.content;
875
1486
  const channelInfoContext = buildChannelInfoContext(msg);
876
1487
  const replyContext = await buildReplyContext(msg);
877
1488
  const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
@@ -879,9 +1490,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
879
1490
  negativeFeedbackByChannel.delete(channelId);
880
1491
  }
881
1492
  const currentBatchMessageIds = new Set(items.map((item) => item.msg.id));
882
- const inboundHistory = await buildInboundHistorySnapshot(msg, currentBatchMessageIds);
883
- const attachmentContext = await buildAttachmentContext(items.map((item) => item.msg));
884
- const rememberedParticipants = participantMemoryByChannel.get(msg.channelId);
1493
+ const inboundHistory = await buildInboundHistorySnapshot(
1494
+ msg,
1495
+ currentBatchMessageIds,
1496
+ );
1497
+ const attachmentContext = await buildAttachmentContext(
1498
+ items.map((item) => item.msg),
1499
+ );
1500
+ const rememberedParticipants = participantMemoryByChannel.get(
1501
+ msg.channelId,
1502
+ );
885
1503
  const participantContext = buildParticipantContext(
886
1504
  items.map((item) => item.msg),
887
1505
  inboundHistory.entries,
@@ -893,25 +1511,70 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
893
1511
  rememberedParticipants,
894
1512
  );
895
1513
  const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
1514
+ const selectiveSilence = shouldSelectivelySilence({
1515
+ sourceItem,
1516
+ inboundHistory: inboundHistory.entries,
1517
+ behavior,
1518
+ });
896
1519
 
897
1520
  const abortController = new AbortController();
898
- const typingLoop = startTypingLoop(msg);
1521
+ const typingController = pending.typingController;
1522
+ const lifecycleController = pending.lifecycleController;
1523
+ const emitLifecyclePhase = (phase: LifecyclePhase): void => {
1524
+ if (phase === 'queued') {
1525
+ typingController.setPhase('received');
1526
+ } else if (phase === 'thinking') {
1527
+ typingController.setPhase('thinking');
1528
+ } else if (phase === 'toolUse') {
1529
+ typingController.setPhase('toolUse');
1530
+ } else if (phase === 'streaming') {
1531
+ typingController.setPhase('streaming');
1532
+ } else {
1533
+ typingController.setPhase('done');
1534
+ }
1535
+ lifecycleController?.setPhase(phase);
1536
+ };
1537
+
899
1538
  const stream = new DiscordStreamManager(msg, {
900
- onFirstMessage: () => typingLoop.stop(),
1539
+ onFirstMessage: () => emitLifecyclePhase('streaming'),
1540
+ humanDelay: behavior.humanDelay,
901
1541
  });
902
1542
  const inFlight: InFlightConversation = {
903
1543
  abortController,
904
1544
  stream,
905
1545
  messageIds: new Set(items.map((item) => item.msg.id)),
906
1546
  aborted: false,
1547
+ emitLifecyclePhase,
907
1548
  };
908
1549
  for (const messageId of inFlight.messageIds) {
909
1550
  inFlightByMessageId.set(messageId, inFlight);
910
1551
  }
911
1552
 
912
1553
  try {
1554
+ if (selectiveSilence) {
1555
+ emitLifecyclePhase('done');
1556
+ if (Math.random() < 0.5) {
1557
+ await withDiscordRetry('reaction-selective-silence', () =>
1558
+ msg.react(pickReadWithoutReplyEmoji()),
1559
+ ).catch(() => {});
1560
+ }
1561
+ recordConversationMetric({
1562
+ durationMs: Date.now() - startedAt,
1563
+ ok: true,
1564
+ });
1565
+ return;
1566
+ }
1567
+
1568
+ releaseChannelSlot = await waitForChannelConcurrencySlot(
1569
+ channelId,
1570
+ behavior.maxConcurrentPerChannel,
1571
+ abortController.signal,
1572
+ );
1573
+ if (abortController.signal.aborted) {
1574
+ return;
1575
+ }
913
1576
  activeConversationRuns += 1;
914
- updatePresence();
1577
+ emitLifecyclePhase('thinking');
915
1578
  await messageHandler(
916
1579
  sessionId,
917
1580
  guildId,
@@ -921,8 +1584,14 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
921
1584
  combinedContent,
922
1585
  attachmentContext.media,
923
1586
  async (text, files) => {
924
- typingLoop.stop();
925
- await sendChunkedReply(msg, text, files, mentionLookup);
1587
+ emitLifecyclePhase('streaming');
1588
+ await sendChunkedReply(
1589
+ msg,
1590
+ text,
1591
+ files,
1592
+ mentionLookup,
1593
+ behavior.humanDelay,
1594
+ );
926
1595
  },
927
1596
  {
928
1597
  sourceMessage: msg,
@@ -930,66 +1599,189 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
930
1599
  abortSignal: abortController.signal,
931
1600
  stream,
932
1601
  mentionLookup,
1602
+ emitLifecyclePhase,
933
1603
  },
934
1604
  );
1605
+ emitLifecyclePhase('done');
1606
+ recordConversationMetric({
1607
+ durationMs: Date.now() - startedAt,
1608
+ ok: true,
1609
+ });
1610
+ noteConversationExchange(sourceItem.cooldownKey);
935
1611
  } catch (error) {
936
- logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
1612
+ if (abortController.signal.aborted || inFlight.aborted) {
1613
+ logger.debug(
1614
+ { channelId, sessionId },
1615
+ 'Conversation batch aborted before completion',
1616
+ );
1617
+ return;
1618
+ }
1619
+ emitLifecyclePhase('error');
1620
+ recordConversationMetric({
1621
+ durationMs: Date.now() - startedAt,
1622
+ ok: false,
1623
+ error,
1624
+ });
1625
+ logger.error(
1626
+ { error, channelId, sessionId },
1627
+ 'Conversation batch handling failed',
1628
+ );
937
1629
  const detail = error instanceof Error ? error.message : String(error);
938
1630
  if (stream.hasSentMessages()) {
939
1631
  await stream.fail(formatError('Gateway Error', detail));
940
1632
  } else {
941
- await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
1633
+ await sendChunkedReply(
1634
+ msg,
1635
+ formatError('Gateway Error', detail),
1636
+ undefined,
1637
+ mentionLookup,
1638
+ behavior.humanDelay,
1639
+ );
942
1640
  }
943
1641
  } finally {
944
1642
  activeConversationRuns = Math.max(0, activeConversationRuns - 1);
945
- updatePresence();
1643
+ if (releaseChannelSlot) {
1644
+ releaseChannelSlot();
1645
+ }
946
1646
  for (const messageId of inFlight.messageIds) {
947
1647
  if (inFlightByMessageId.get(messageId) === inFlight) {
948
1648
  inFlightByMessageId.delete(messageId);
949
1649
  }
950
1650
  }
951
- typingLoop.stop();
952
- await Promise.all(items.map(async (item) => {
953
- await item.clearReaction();
954
- }));
1651
+ typingController.stop();
1652
+ await Promise.all(
1653
+ items.map(async (item) => {
1654
+ await item.clearAckReaction();
1655
+ }),
1656
+ );
955
1657
  }
956
1658
  };
957
1659
 
958
1660
  const queueConversationMessage = async (
959
1661
  msg: DiscordMessage,
960
1662
  content: string,
1663
+ behavior: ResolvedChannelBehavior,
961
1664
  ): Promise<void> => {
962
1665
  const key = `${msg.channelId}:${msg.author.id}`;
963
- const clearReaction = await addProcessingReaction(msg);
964
- const queued: QueuedConversationMessage = { msg, content, clearReaction };
1666
+ const cooldownKey = buildConversationCooldownKey(
1667
+ msg.channelId,
1668
+ msg.author.id,
1669
+ );
1670
+ const adjustedHumanDelay = resolveHumanDelayWithBehavior(
1671
+ behavior.humanDelay,
1672
+ cooldownKey,
1673
+ );
1674
+ const queuedBehavior: ResolvedChannelBehavior = {
1675
+ ...behavior,
1676
+ humanDelay: adjustedHumanDelay,
1677
+ };
1678
+ const wasExplicitlyAddressed =
1679
+ !msg.guild ||
1680
+ hasPrefixInvocation(msg.content || '') ||
1681
+ Boolean(client.user && msg.mentions.has(client.user));
1682
+
1683
+ let clearAckReaction: () => Promise<void> = async () => {};
1684
+ if (client.user && shouldApplyAckReaction(msg, behavior)) {
1685
+ const clearReaction = await addAckReaction({
1686
+ message: msg,
1687
+ emoji: behavior.ackReaction,
1688
+ withRetry: withDiscordRetry,
1689
+ botUserId: client.user.id,
1690
+ });
1691
+ clearAckReaction = behavior.removeAckAfterReply
1692
+ ? clearReaction
1693
+ : async () => {};
1694
+ }
1695
+ const queued: QueuedConversationMessage = {
1696
+ msg,
1697
+ content,
1698
+ behavior: queuedBehavior,
1699
+ clearAckReaction,
1700
+ wasExplicitlyAddressed,
1701
+ cooldownKey,
1702
+ };
965
1703
  const existing = pendingBatches.get(key);
1704
+ const shouldDebounceMessage = shouldDebounceInbound({
1705
+ content: msg.content || '',
1706
+ hasAttachments: msg.attachments.size > 0,
1707
+ isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
1708
+ });
966
1709
 
967
1710
  if (!existing) {
1711
+ const typingController = createTypingController(msg, behavior.typingMode);
1712
+ typingController.setPhase('received');
1713
+ const lifecycleController =
1714
+ client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
1715
+ ? new LifecycleReactionController({
1716
+ message: msg,
1717
+ withRetry: withDiscordRetry,
1718
+ botUserId: client.user.id,
1719
+ config: {
1720
+ enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
1721
+ removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
1722
+ phases: DISCORD_LIFECYCLE_REACTIONS.phases,
1723
+ },
1724
+ })
1725
+ : null;
1726
+ lifecycleController?.setPhase('queued');
1727
+ const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
1728
+ const startupStaggerMs =
1729
+ startupConnectedAtMs > 0 &&
1730
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1731
+ !wasExplicitlyAddressed
1732
+ ? randomIntInRange(
1733
+ STARTUP_STAGGER_MIN_DELAY_MS,
1734
+ STARTUP_STAGGER_MAX_DELAY_MS,
1735
+ )
1736
+ : 0;
1737
+ const delayMs = baseDelayMs + startupStaggerMs;
968
1738
  const timer = setTimeout(() => {
969
1739
  void dispatchConversationBatch(key);
970
- }, MESSAGE_DEBOUNCE_MS);
1740
+ }, delayMs);
971
1741
  pendingBatches.set(key, {
972
1742
  items: [queued],
973
1743
  timer,
1744
+ typingController,
1745
+ lifecycleController,
974
1746
  });
975
1747
  return;
976
1748
  }
977
1749
 
1750
+ existing.typingController.setPhase('received');
1751
+ existing.lifecycleController?.setPhase('queued');
978
1752
  clearTimeout(existing.timer);
979
1753
  existing.items.push(queued);
1754
+ const shouldFlushImmediately =
1755
+ !shouldDebounceMessage ||
1756
+ existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
1757
+ const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
1758
+ const startupStaggerMs =
1759
+ startupConnectedAtMs > 0 &&
1760
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1761
+ !wasExplicitlyAddressed
1762
+ ? randomIntInRange(
1763
+ STARTUP_STAGGER_MIN_DELAY_MS,
1764
+ STARTUP_STAGGER_MAX_DELAY_MS,
1765
+ )
1766
+ : 0;
1767
+ const delayMs = baseDelayMs + startupStaggerMs;
980
1768
  existing.timer = setTimeout(() => {
981
1769
  void dispatchConversationBatch(key);
982
- }, MESSAGE_DEBOUNCE_MS);
1770
+ }, delayMs);
983
1771
  };
984
1772
 
985
1773
  const dropPendingMessage = async (messageId: string): Promise<void> => {
986
1774
  for (const [key, pending] of pendingBatches) {
987
- const index = pending.items.findIndex((item) => item.msg.id === messageId);
1775
+ const index = pending.items.findIndex(
1776
+ (item) => item.msg.id === messageId,
1777
+ );
988
1778
  if (index === -1) continue;
989
1779
  const [removed] = pending.items.splice(index, 1);
990
- await removed.clearReaction();
1780
+ await removed.clearAckReaction();
991
1781
  if (pending.items.length === 0) {
992
1782
  clearTimeout(pending.timer);
1783
+ pending.typingController.stop();
1784
+ await pending.lifecycleController?.clear();
993
1785
  pendingBatches.delete(key);
994
1786
  }
995
1787
  return;
@@ -1000,21 +1792,35 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1000
1792
  messageId: string,
1001
1793
  nextMsg: DiscordMessage,
1002
1794
  nextContent: string,
1795
+ nextBehavior: ResolvedChannelBehavior,
1003
1796
  ): Promise<boolean> => {
1004
1797
  for (const [key, pending] of pendingBatches) {
1005
- const index = pending.items.findIndex((item) => item.msg.id === messageId);
1798
+ const index = pending.items.findIndex(
1799
+ (item) => item.msg.id === messageId,
1800
+ );
1006
1801
  if (index === -1) continue;
1007
1802
 
1008
1803
  if (!nextContent) {
1009
1804
  const [removed] = pending.items.splice(index, 1);
1010
- await removed.clearReaction();
1805
+ await removed.clearAckReaction();
1011
1806
  } else {
1012
1807
  pending.items[index].msg = nextMsg;
1013
1808
  pending.items[index].content = nextContent;
1809
+ pending.items[index].behavior = nextBehavior;
1810
+ pending.items[index].wasExplicitlyAddressed =
1811
+ !nextMsg.guild ||
1812
+ hasPrefixInvocation(nextMsg.content || '') ||
1813
+ Boolean(client.user && nextMsg.mentions.has(client.user));
1814
+ pending.items[index].cooldownKey = buildConversationCooldownKey(
1815
+ nextMsg.channelId,
1816
+ nextMsg.author.id,
1817
+ );
1014
1818
  }
1015
1819
 
1016
1820
  if (pending.items.length === 0) {
1017
1821
  clearTimeout(pending.timer);
1822
+ pending.typingController.stop();
1823
+ await pending.lifecycleController?.clear();
1018
1824
  pendingBatches.delete(key);
1019
1825
  }
1020
1826
  return true;
@@ -1028,6 +1834,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1028
1834
  const sessionId = getSessionId(msg);
1029
1835
  const guildId = msg.guild?.id || null;
1030
1836
  const channelId = msg.channelId;
1837
+ const behavior = resolveChannelBehavior(msg);
1031
1838
  const content = cleanIncomingContent(msg.content);
1032
1839
  observeMessageParticipants(msg, content);
1033
1840
  const immediateMentionLookup = buildMentionLookup(
@@ -1037,11 +1844,23 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1037
1844
  );
1038
1845
 
1039
1846
  const reply: ReplyFn = async (text, files) => {
1040
- await sendChunkedReply(msg, text, files, immediateMentionLookup);
1847
+ await sendChunkedReply(
1848
+ msg,
1849
+ text,
1850
+ files,
1851
+ immediateMentionLookup,
1852
+ behavior.humanDelay,
1853
+ );
1041
1854
  };
1042
1855
  const commandReply: ReplyFn = async (text, files) => {
1043
1856
  try {
1044
- await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
1857
+ await sendChunkedDirectReply(
1858
+ msg,
1859
+ text,
1860
+ files,
1861
+ immediateMentionLookup,
1862
+ behavior.humanDelay,
1863
+ );
1045
1864
  } catch (error) {
1046
1865
  logger.warn(
1047
1866
  { error, userId: msg.author.id, channelId: msg.channelId },
@@ -1071,15 +1890,23 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1071
1890
  if (!content) {
1072
1891
  await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
1073
1892
  } else {
1074
- await commandReply(`Unknown command. Try \`${DISCORD_PREFIX} help\`.`);
1893
+ await commandReply(
1894
+ `Unknown command. Try \`${DISCORD_PREFIX} help\`.`,
1895
+ );
1075
1896
  }
1076
1897
  return;
1077
1898
  }
1078
- await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
1899
+ await commandHandler(
1900
+ sessionId,
1901
+ guildId,
1902
+ channelId,
1903
+ [parsed.command, ...parsed.args],
1904
+ commandReply,
1905
+ );
1079
1906
  return;
1080
1907
  }
1081
1908
 
1082
- if (!isTrigger(msg)) return;
1909
+ if (!isTrigger(msg, behavior)) return;
1083
1910
 
1084
1911
  if (ignorePrefixCommand) {
1085
1912
  return;
@@ -1092,7 +1919,13 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1092
1919
  'Ignoring unauthorized Discord command; processing as normal chat message',
1093
1920
  );
1094
1921
  } else {
1095
- await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
1922
+ await commandHandler(
1923
+ sessionId,
1924
+ guildId,
1925
+ channelId,
1926
+ [parsed.command, ...parsed.args],
1927
+ commandReply,
1928
+ );
1096
1929
  return;
1097
1930
  }
1098
1931
  }
@@ -1102,7 +1935,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1102
1935
  return;
1103
1936
  }
1104
1937
 
1105
- await queueConversationMessage(msg, content);
1938
+ const readWithoutReplyHandled = await maybeHandleReadWithoutReply(
1939
+ msg,
1940
+ content,
1941
+ );
1942
+ if (readWithoutReplyHandled) {
1943
+ return;
1944
+ }
1945
+
1946
+ const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
1947
+ if (!rateLimitAllowed) return;
1948
+
1949
+ await queueConversationMessage(msg, content, behavior);
1106
1950
  });
1107
1951
 
1108
1952
  client.on('messageUpdate', async (_oldMsg, nextMsg) => {
@@ -1114,14 +1958,19 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1114
1958
  if (fetched.author?.bot) return;
1115
1959
 
1116
1960
  const updatedContent = cleanIncomingContent(fetched.content || '');
1961
+ const behavior = resolveChannelBehavior(fetched);
1117
1962
  observeMessageParticipants(fetched, updatedContent);
1118
- if (!isTrigger(fetched)) return;
1119
- await updatePendingMessage(fetched.id, fetched, updatedContent);
1963
+ if (!isTrigger(fetched, behavior)) {
1964
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
1965
+ return;
1966
+ }
1967
+ await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
1120
1968
 
1121
1969
  const inFlight = inFlightByMessageId.get(fetched.id);
1122
1970
  if (!inFlight || inFlight.aborted) return;
1123
1971
  inFlight.aborted = true;
1124
1972
  inFlight.abortController.abort();
1973
+ inFlight.emitLifecyclePhase('error');
1125
1974
  for (const messageId of inFlight.messageIds) {
1126
1975
  if (inFlightByMessageId.get(messageId) === inFlight) {
1127
1976
  inFlightByMessageId.delete(messageId);
@@ -1129,7 +1978,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1129
1978
  }
1130
1979
  await inFlight.stream.discard();
1131
1980
  if (updatedContent) {
1132
- await queueConversationMessage(fetched, updatedContent);
1981
+ const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
1982
+ if (!rateLimitAllowed) return;
1983
+ await queueConversationMessage(fetched, updatedContent, behavior);
1133
1984
  }
1134
1985
  });
1135
1986
 
@@ -1139,6 +1990,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1139
1990
  if (!inFlight || inFlight.aborted) return;
1140
1991
  inFlight.aborted = true;
1141
1992
  inFlight.abortController.abort();
1993
+ inFlight.emitLifecyclePhase('error');
1142
1994
  for (const messageId of inFlight.messageIds) {
1143
1995
  if (inFlightByMessageId.get(messageId) === inFlight) {
1144
1996
  inFlightByMessageId.delete(messageId);
@@ -1174,16 +2026,30 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1174
2026
  return client;
1175
2027
  }
1176
2028
 
2029
+ export async function setDiscordMaintenancePresence(): Promise<void> {
2030
+ if (!presenceController) return;
2031
+ await presenceController.setMaintenance();
2032
+ }
2033
+
1177
2034
  /**
1178
2035
  * Send a message to a channel by ID (used by scheduler).
1179
2036
  */
1180
- export async function sendToChannel(channelId: string, text: string, files?: AttachmentBuilder[]): Promise<void> {
2037
+ export async function sendToChannel(
2038
+ channelId: string,
2039
+ text: string,
2040
+ files?: AttachmentBuilder[],
2041
+ ): Promise<void> {
1181
2042
  const channel = await client.channels.fetch(channelId);
1182
2043
  if (channel && 'send' in channel) {
1183
2044
  const payloads = prepareChunkedPayloads(text, files);
1184
- const send = (channel as unknown as {
1185
- send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
1186
- }).send;
2045
+ const send = (
2046
+ channel as unknown as {
2047
+ send: (payload: {
2048
+ content: string;
2049
+ files?: AttachmentBuilder[];
2050
+ }) => Promise<void>;
2051
+ }
2052
+ ).send;
1187
2053
  for (const payload of payloads) {
1188
2054
  await withDiscordRetry('send-channel', () => send(payload));
1189
2055
  }