@gakr-gakr/discord 0.1.0

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 (353) hide show
  1. package/account-inspect-api.ts +6 -0
  2. package/action-runtime-api.ts +1 -0
  3. package/api.ts +130 -0
  4. package/autobot.plugin.json +15 -0
  5. package/channel-config-api.ts +1 -0
  6. package/channel-plugin-api.ts +3 -0
  7. package/config-api.ts +4 -0
  8. package/configured-state.ts +6 -0
  9. package/contract-api.ts +21 -0
  10. package/directory-contract-api.ts +4 -0
  11. package/doctor-contract-api.ts +1 -0
  12. package/index.ts +24 -0
  13. package/package.json +79 -0
  14. package/runtime-api.actions.ts +15 -0
  15. package/runtime-api.lookup.ts +22 -0
  16. package/runtime-api.monitor.ts +50 -0
  17. package/runtime-api.send.ts +79 -0
  18. package/runtime-api.threads.ts +31 -0
  19. package/runtime-api.ts +181 -0
  20. package/runtime-setter-api.ts +3 -0
  21. package/secret-contract-api.ts +4 -0
  22. package/security-audit-contract-api.ts +1 -0
  23. package/security-contract-api.ts +4 -0
  24. package/session-key-api.ts +1 -0
  25. package/setup-entry.ts +9 -0
  26. package/setup-plugin-api.ts +3 -0
  27. package/src/account-inspect.ts +131 -0
  28. package/src/accounts.ts +205 -0
  29. package/src/actions/handle-action.guild-admin.ts +421 -0
  30. package/src/actions/handle-action.ts +402 -0
  31. package/src/actions/runtime.guild.ts +446 -0
  32. package/src/actions/runtime.messaging.messages.ts +226 -0
  33. package/src/actions/runtime.messaging.reactions.ts +67 -0
  34. package/src/actions/runtime.messaging.runtime.ts +73 -0
  35. package/src/actions/runtime.messaging.send.ts +336 -0
  36. package/src/actions/runtime.messaging.shared.ts +97 -0
  37. package/src/actions/runtime.messaging.ts +37 -0
  38. package/src/actions/runtime.moderation-shared.ts +48 -0
  39. package/src/actions/runtime.moderation.ts +116 -0
  40. package/src/actions/runtime.presence.ts +117 -0
  41. package/src/actions/runtime.shared.ts +86 -0
  42. package/src/actions/runtime.ts +87 -0
  43. package/src/api.ts +219 -0
  44. package/src/approval-handler.runtime.ts +636 -0
  45. package/src/approval-native.ts +219 -0
  46. package/src/approval-runtime.ts +14 -0
  47. package/src/approval-shared.ts +56 -0
  48. package/src/audit-core.ts +178 -0
  49. package/src/audit.ts +32 -0
  50. package/src/channel-actions.runtime.ts +1 -0
  51. package/src/channel-actions.ts +254 -0
  52. package/src/channel-api.ts +29 -0
  53. package/src/channel.conversation.ts +159 -0
  54. package/src/channel.loaders.ts +50 -0
  55. package/src/channel.runtime.ts +1 -0
  56. package/src/channel.setup.ts +12 -0
  57. package/src/channel.ts +728 -0
  58. package/src/chunk.ts +321 -0
  59. package/src/client.ts +143 -0
  60. package/src/component-custom-id.ts +72 -0
  61. package/src/components-registry.ts +356 -0
  62. package/src/components.builders.ts +410 -0
  63. package/src/components.modal.ts +124 -0
  64. package/src/components.parse.ts +407 -0
  65. package/src/components.ts +54 -0
  66. package/src/components.types.ts +187 -0
  67. package/src/config-schema.ts +6 -0
  68. package/src/config-ui-hints.ts +354 -0
  69. package/src/conversation-identity.ts +58 -0
  70. package/src/delivery-retry.ts +56 -0
  71. package/src/directory-cache.ts +116 -0
  72. package/src/directory-config.ts +58 -0
  73. package/src/directory-live.ts +135 -0
  74. package/src/doctor-contract.ts +477 -0
  75. package/src/doctor-shared.ts +5 -0
  76. package/src/doctor.ts +340 -0
  77. package/src/draft-chunking.ts +43 -0
  78. package/src/draft-stream.ts +162 -0
  79. package/src/error-body.ts +38 -0
  80. package/src/exec-approvals.ts +110 -0
  81. package/src/gateway-logging.ts +67 -0
  82. package/src/group-policy.ts +113 -0
  83. package/src/guilds.ts +29 -0
  84. package/src/inbound-event-delivery.ts +135 -0
  85. package/src/interactive-dispatch.ts +104 -0
  86. package/src/internal/api.commands.ts +51 -0
  87. package/src/internal/api.guild.ts +164 -0
  88. package/src/internal/api.interactions.ts +53 -0
  89. package/src/internal/api.messages.ts +113 -0
  90. package/src/internal/api.reactions.ts +38 -0
  91. package/src/internal/api.ts +61 -0
  92. package/src/internal/api.users.ts +19 -0
  93. package/src/internal/api.webhooks.ts +13 -0
  94. package/src/internal/client.ts +310 -0
  95. package/src/internal/command-deploy.ts +352 -0
  96. package/src/internal/commands.ts +188 -0
  97. package/src/internal/components.base.ts +65 -0
  98. package/src/internal/components.message.ts +279 -0
  99. package/src/internal/components.modal.ts +95 -0
  100. package/src/internal/components.ts +31 -0
  101. package/src/internal/discord.ts +11 -0
  102. package/src/internal/embeds.ts +35 -0
  103. package/src/internal/entity-cache.ts +98 -0
  104. package/src/internal/event-queue.ts +185 -0
  105. package/src/internal/gateway-close-codes.ts +25 -0
  106. package/src/internal/gateway-dispatch.ts +96 -0
  107. package/src/internal/gateway-identify-limiter.ts +26 -0
  108. package/src/internal/gateway-lifecycle.ts +75 -0
  109. package/src/internal/gateway-rate-limit.ts +104 -0
  110. package/src/internal/gateway.ts +479 -0
  111. package/src/internal/interaction-dispatch.ts +162 -0
  112. package/src/internal/interaction-options.ts +98 -0
  113. package/src/internal/interaction-response.ts +53 -0
  114. package/src/internal/interactions.ts +378 -0
  115. package/src/internal/listeners.ts +91 -0
  116. package/src/internal/modal-fields.ts +95 -0
  117. package/src/internal/payload.ts +69 -0
  118. package/src/internal/rest-body.ts +115 -0
  119. package/src/internal/rest-errors.ts +88 -0
  120. package/src/internal/rest-routes.ts +50 -0
  121. package/src/internal/rest-scheduler.ts +557 -0
  122. package/src/internal/rest.ts +322 -0
  123. package/src/internal/schemas.ts +36 -0
  124. package/src/internal/structures.ts +280 -0
  125. package/src/internal/test-builders.test-support.ts +167 -0
  126. package/src/internal/voice.ts +49 -0
  127. package/src/media-detection.ts +28 -0
  128. package/src/mentions.ts +147 -0
  129. package/src/monitor/ack-reactions.ts +70 -0
  130. package/src/monitor/agent-components-auth.ts +7 -0
  131. package/src/monitor/agent-components-context.ts +154 -0
  132. package/src/monitor/agent-components-data.ts +224 -0
  133. package/src/monitor/agent-components-dm-auth.ts +177 -0
  134. package/src/monitor/agent-components-guild-auth.ts +322 -0
  135. package/src/monitor/agent-components-helpers.runtime.ts +3 -0
  136. package/src/monitor/agent-components-helpers.ts +34 -0
  137. package/src/monitor/agent-components-reply.ts +10 -0
  138. package/src/monitor/agent-components.deps.runtime.ts +2 -0
  139. package/src/monitor/agent-components.dispatch.ts +359 -0
  140. package/src/monitor/agent-components.handlers.ts +303 -0
  141. package/src/monitor/agent-components.modal.ts +160 -0
  142. package/src/monitor/agent-components.plugin-interactive.ts +187 -0
  143. package/src/monitor/agent-components.runtime.ts +14 -0
  144. package/src/monitor/agent-components.system-controls.ts +215 -0
  145. package/src/monitor/agent-components.ts +70 -0
  146. package/src/monitor/agent-components.types.ts +58 -0
  147. package/src/monitor/agent-components.wildcard-controls.ts +171 -0
  148. package/src/monitor/allow-list.ts +631 -0
  149. package/src/monitor/auto-presence.ts +356 -0
  150. package/src/monitor/channel-access.ts +102 -0
  151. package/src/monitor/commands.ts +9 -0
  152. package/src/monitor/dm-command-auth.ts +259 -0
  153. package/src/monitor/dm-command-decision.ts +49 -0
  154. package/src/monitor/exec-approvals.ts +161 -0
  155. package/src/monitor/format.ts +45 -0
  156. package/src/monitor/gateway-handle.ts +34 -0
  157. package/src/monitor/gateway-metadata.ts +298 -0
  158. package/src/monitor/gateway-plugin.ts +302 -0
  159. package/src/monitor/gateway-registry.ts +37 -0
  160. package/src/monitor/gateway-supervisor.ts +206 -0
  161. package/src/monitor/inbound-context.ts +95 -0
  162. package/src/monitor/inbound-dedupe.ts +79 -0
  163. package/src/monitor/inbound-job.ts +118 -0
  164. package/src/monitor/listeners.queue.ts +91 -0
  165. package/src/monitor/listeners.reactions.ts +594 -0
  166. package/src/monitor/listeners.ts +150 -0
  167. package/src/monitor/message-channel-info.ts +96 -0
  168. package/src/monitor/message-forwarded.ts +114 -0
  169. package/src/monitor/message-handler.batch-gate.ts +19 -0
  170. package/src/monitor/message-handler.context.ts +492 -0
  171. package/src/monitor/message-handler.dm-preflight.ts +119 -0
  172. package/src/monitor/message-handler.draft-preview.ts +436 -0
  173. package/src/monitor/message-handler.hydration.ts +198 -0
  174. package/src/monitor/message-handler.module-test-helpers.ts +31 -0
  175. package/src/monitor/message-handler.preflight-channel-access.ts +86 -0
  176. package/src/monitor/message-handler.preflight-channel-context.ts +58 -0
  177. package/src/monitor/message-handler.preflight-context.ts +54 -0
  178. package/src/monitor/message-handler.preflight-helpers.ts +164 -0
  179. package/src/monitor/message-handler.preflight-history.ts +23 -0
  180. package/src/monitor/message-handler.preflight-logging.ts +36 -0
  181. package/src/monitor/message-handler.preflight-pluralkit.ts +28 -0
  182. package/src/monitor/message-handler.preflight-runtime.ts +28 -0
  183. package/src/monitor/message-handler.preflight-thread.ts +49 -0
  184. package/src/monitor/message-handler.preflight.ts +822 -0
  185. package/src/monitor/message-handler.preflight.types.ts +115 -0
  186. package/src/monitor/message-handler.process.ts +1033 -0
  187. package/src/monitor/message-handler.routing-preflight.ts +112 -0
  188. package/src/monitor/message-handler.ts +309 -0
  189. package/src/monitor/message-media.ts +536 -0
  190. package/src/monitor/message-run-queue.ts +101 -0
  191. package/src/monitor/message-text.ts +171 -0
  192. package/src/monitor/message-utils.ts +34 -0
  193. package/src/monitor/model-picker-preferences.ts +184 -0
  194. package/src/monitor/model-picker.state.ts +364 -0
  195. package/src/monitor/model-picker.test-utils.ts +26 -0
  196. package/src/monitor/model-picker.ts +38 -0
  197. package/src/monitor/model-picker.view.ts +722 -0
  198. package/src/monitor/native-command-agent-reply.ts +125 -0
  199. package/src/monitor/native-command-arg-ui.ts +233 -0
  200. package/src/monitor/native-command-auth.ts +309 -0
  201. package/src/monitor/native-command-bypass.ts +13 -0
  202. package/src/monitor/native-command-context.ts +109 -0
  203. package/src/monitor/native-command-dispatch.ts +35 -0
  204. package/src/monitor/native-command-model-picker-apply.ts +209 -0
  205. package/src/monitor/native-command-model-picker-interaction.ts +516 -0
  206. package/src/monitor/native-command-model-picker-ui.ts +357 -0
  207. package/src/monitor/native-command-reply.ts +185 -0
  208. package/src/monitor/native-command-route.ts +91 -0
  209. package/src/monitor/native-command-status.ts +76 -0
  210. package/src/monitor/native-command-ui.ts +26 -0
  211. package/src/monitor/native-command-ui.types.ts +20 -0
  212. package/src/monitor/native-command.args.ts +45 -0
  213. package/src/monitor/native-command.options.ts +153 -0
  214. package/src/monitor/native-command.runtime.ts +51 -0
  215. package/src/monitor/native-command.ts +747 -0
  216. package/src/monitor/native-command.types.ts +9 -0
  217. package/src/monitor/native-interaction-channel-context.ts +50 -0
  218. package/src/monitor/preflight-audio.runtime.ts +9 -0
  219. package/src/monitor/preflight-audio.ts +130 -0
  220. package/src/monitor/presence-cache.ts +61 -0
  221. package/src/monitor/presence.ts +50 -0
  222. package/src/monitor/provider-session.runtime.ts +12 -0
  223. package/src/monitor/provider.acp.ts +89 -0
  224. package/src/monitor/provider.allowlist.ts +398 -0
  225. package/src/monitor/provider.cleanup.ts +41 -0
  226. package/src/monitor/provider.commands.ts +129 -0
  227. package/src/monitor/provider.config-log.ts +45 -0
  228. package/src/monitor/provider.deploy-errors.ts +362 -0
  229. package/src/monitor/provider.deploy.ts +221 -0
  230. package/src/monitor/provider.interactions.ts +160 -0
  231. package/src/monitor/provider.lifecycle.ts +562 -0
  232. package/src/monitor/provider.runtime.ts +1 -0
  233. package/src/monitor/provider.startup-log.ts +32 -0
  234. package/src/monitor/provider.startup.ts +323 -0
  235. package/src/monitor/provider.ts +688 -0
  236. package/src/monitor/reply-context.ts +64 -0
  237. package/src/monitor/reply-delivery.ts +216 -0
  238. package/src/monitor/reply-safety.ts +96 -0
  239. package/src/monitor/rest-fetch.ts +97 -0
  240. package/src/monitor/route-resolution.ts +140 -0
  241. package/src/monitor/sender-identity.ts +81 -0
  242. package/src/monitor/startup-status.ts +10 -0
  243. package/src/monitor/status.ts +22 -0
  244. package/src/monitor/system-events.ts +55 -0
  245. package/src/monitor/thread-bindings.config.ts +35 -0
  246. package/src/monitor/thread-bindings.discord-api.ts +310 -0
  247. package/src/monitor/thread-bindings.lifecycle.ts +354 -0
  248. package/src/monitor/thread-bindings.manager.ts +554 -0
  249. package/src/monitor/thread-bindings.messages.ts +6 -0
  250. package/src/monitor/thread-bindings.persona.ts +25 -0
  251. package/src/monitor/thread-bindings.session-adapter.ts +229 -0
  252. package/src/monitor/thread-bindings.session-shared.ts +59 -0
  253. package/src/monitor/thread-bindings.session-updates.ts +35 -0
  254. package/src/monitor/thread-bindings.state.ts +540 -0
  255. package/src/monitor/thread-bindings.ts +48 -0
  256. package/src/monitor/thread-bindings.types.ts +83 -0
  257. package/src/monitor/thread-channel-context.ts +112 -0
  258. package/src/monitor/thread-session-close.ts +63 -0
  259. package/src/monitor/thread-title.ts +181 -0
  260. package/src/monitor/threading.auto-thread.ts +287 -0
  261. package/src/monitor/threading.cache.ts +45 -0
  262. package/src/monitor/threading.starter.ts +288 -0
  263. package/src/monitor/threading.ts +20 -0
  264. package/src/monitor/threading.types.ts +102 -0
  265. package/src/monitor/timeouts.ts +84 -0
  266. package/src/monitor/typing.ts +17 -0
  267. package/src/monitor.gateway.ts +75 -0
  268. package/src/monitor.ts +28 -0
  269. package/src/network-config.ts +79 -0
  270. package/src/normalize.ts +86 -0
  271. package/src/outbound-adapter.ts +327 -0
  272. package/src/outbound-approval.ts +29 -0
  273. package/src/outbound-components.ts +86 -0
  274. package/src/outbound-payload.ts +208 -0
  275. package/src/outbound-send-context.ts +92 -0
  276. package/src/outbound-session-route.ts +72 -0
  277. package/src/pluralkit.ts +58 -0
  278. package/src/preview-streaming.ts +18 -0
  279. package/src/probe.runtime.ts +1 -0
  280. package/src/probe.ts +237 -0
  281. package/src/proxy-fetch.ts +92 -0
  282. package/src/proxy-request-client.ts +21 -0
  283. package/src/recipient-resolution.ts +39 -0
  284. package/src/resolve-allowlist-common.ts +39 -0
  285. package/src/resolve-channels.ts +369 -0
  286. package/src/resolve-users.ts +184 -0
  287. package/src/retry.ts +98 -0
  288. package/src/runtime-api.ts +64 -0
  289. package/src/runtime-config.ts +16 -0
  290. package/src/runtime.ts +23 -0
  291. package/src/secret-config-contract.ts +140 -0
  292. package/src/security-audit.runtime.ts +1 -0
  293. package/src/security-audit.ts +208 -0
  294. package/src/security-contract.ts +47 -0
  295. package/src/security-doctor.ts +20 -0
  296. package/src/security.ts +60 -0
  297. package/src/send-target-parsing.ts +14 -0
  298. package/src/send.channels.ts +139 -0
  299. package/src/send.components.ts +391 -0
  300. package/src/send.emojis-stickers.ts +57 -0
  301. package/src/send.guild.ts +170 -0
  302. package/src/send.message-request.ts +112 -0
  303. package/src/send.messages.ts +229 -0
  304. package/src/send.outbound.ts +459 -0
  305. package/src/send.permissions.ts +283 -0
  306. package/src/send.reactions.ts +155 -0
  307. package/src/send.receipt.ts +69 -0
  308. package/src/send.shared.ts +469 -0
  309. package/src/send.ts +82 -0
  310. package/src/send.types.ts +191 -0
  311. package/src/send.typing.ts +9 -0
  312. package/src/send.voice.ts +140 -0
  313. package/src/send.webhook.ts +137 -0
  314. package/src/session-contract.ts +3 -0
  315. package/src/session-key-normalization.ts +47 -0
  316. package/src/setup-account-state.ts +144 -0
  317. package/src/setup-adapter.ts +14 -0
  318. package/src/setup-core.ts +215 -0
  319. package/src/setup-runtime-helpers.ts +10 -0
  320. package/src/setup-surface.ts +132 -0
  321. package/src/shared-interactive.ts +167 -0
  322. package/src/shared.ts +197 -0
  323. package/src/status-issues.ts +201 -0
  324. package/src/subagent-hooks.ts +232 -0
  325. package/src/target-parsing.ts +70 -0
  326. package/src/target-resolver.ts +129 -0
  327. package/src/targets.ts +12 -0
  328. package/src/token.ts +107 -0
  329. package/src/ui-colors.ts +27 -0
  330. package/src/ui.ts +20 -0
  331. package/src/voice/access.ts +126 -0
  332. package/src/voice/audio.ts +249 -0
  333. package/src/voice/capture-state.ts +120 -0
  334. package/src/voice/command.ts +284 -0
  335. package/src/voice/config.ts +8 -0
  336. package/src/voice/ingress.ts +164 -0
  337. package/src/voice/manager.runtime.ts +14 -0
  338. package/src/voice/manager.ts +1155 -0
  339. package/src/voice/prompt.ts +22 -0
  340. package/src/voice/realtime.ts +1370 -0
  341. package/src/voice/receive-recovery.ts +159 -0
  342. package/src/voice/sanitize.ts +29 -0
  343. package/src/voice/sdk-runtime.ts +14 -0
  344. package/src/voice/segment.ts +160 -0
  345. package/src/voice/session.ts +81 -0
  346. package/src/voice/speaker-context.ts +127 -0
  347. package/src/voice/tts.ts +151 -0
  348. package/src/voice-message.ts +474 -0
  349. package/subagent-hooks-api.ts +27 -0
  350. package/test-api.ts +4 -0
  351. package/thread-binding-api.ts +1 -0
  352. package/timeouts.ts +6 -0
  353. package/tsconfig.json +16 -0
@@ -0,0 +1,1155 @@
1
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
2
+ import type { DiscordAccountConfig } from "autobot/plugin-sdk/config-contracts";
3
+ import { resolveAgentRoute } from "autobot/plugin-sdk/routing";
4
+ import { createSubsystemLogger } from "autobot/plugin-sdk/runtime-env";
5
+ import type { RuntimeEnv } from "autobot/plugin-sdk/runtime-env";
6
+ import { formatErrorMessage } from "autobot/plugin-sdk/ssrf-runtime";
7
+ import { resolveDiscordAccountAllowFrom } from "../accounts.js";
8
+ import {
9
+ type APIVoiceState,
10
+ type Client,
11
+ ReadyListener,
12
+ ResumedListener,
13
+ VoiceStateUpdateListener,
14
+ } from "../internal/discord.js";
15
+ import type { VoicePlugin } from "../internal/voice.js";
16
+ import { formatMention } from "../mentions.js";
17
+ import { parseDiscordTarget } from "../target-parsing.js";
18
+ import { decodeOpusStream, decodeOpusStreamChunks, writeVoiceWavFile } from "./audio.js";
19
+ import {
20
+ beginVoiceCapture,
21
+ clearVoiceCaptureFinalizeTimer,
22
+ createVoiceCaptureState,
23
+ finishVoiceCapture,
24
+ getActiveVoiceCapture,
25
+ isVoiceCaptureActive,
26
+ scheduleVoiceCaptureFinalize,
27
+ stopVoiceCaptureState,
28
+ } from "./capture-state.js";
29
+ import { resolveDiscordVoiceEnabled } from "./config.js";
30
+ import {
31
+ type DiscordVoiceIngressContext,
32
+ resolveDiscordVoiceIngressContext,
33
+ runDiscordVoiceAgentTurn,
34
+ } from "./ingress.js";
35
+ import {
36
+ DiscordRealtimeVoiceSession,
37
+ isDiscordRealtimeVoiceMode,
38
+ resolveDiscordVoiceMode,
39
+ } from "./realtime.js";
40
+ import {
41
+ analyzeVoiceReceiveError,
42
+ createVoiceReceiveRecoveryState,
43
+ DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS,
44
+ DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS,
45
+ enableDaveReceivePassthrough as tryEnableDaveReceivePassthrough,
46
+ finishVoiceDecryptRecovery,
47
+ noteVoiceDecryptFailure,
48
+ resetVoiceReceiveRecoveryState,
49
+ } from "./receive-recovery.js";
50
+ import { loadDiscordVoiceSdk } from "./sdk-runtime.js";
51
+ import { processDiscordVoiceSegment } from "./segment.js";
52
+ import {
53
+ CAPTURE_FINALIZE_GRACE_MS,
54
+ isVoiceChannel,
55
+ logVoiceVerbose,
56
+ resolveVoiceTimeoutMs,
57
+ MIN_SEGMENT_SECONDS,
58
+ VOICE_CONNECT_READY_TIMEOUT_MS,
59
+ VOICE_RECONNECT_GRACE_MS,
60
+ type VoiceOperationResult,
61
+ type VoiceSessionEntry,
62
+ } from "./session.js";
63
+ import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
64
+
65
+ const logger = createSubsystemLogger("discord/voice");
66
+ const VOICE_LOG_PREVIEW_CHARS = 500;
67
+ const DISCORD_VOICE_FATAL_AUTOJOIN_ERROR_PATTERNS = [
68
+ "api key missing",
69
+ "incorrect api key",
70
+ "invalid api key",
71
+ "unauthorized",
72
+ "authentication",
73
+ "permission denied",
74
+ "forbidden",
75
+ ];
76
+
77
+ type DiscordVoiceSdk = ReturnType<typeof loadDiscordVoiceSdk>;
78
+ type DiscordVoiceConnection = ReturnType<DiscordVoiceSdk["joinVoiceChannel"]>;
79
+ type VoiceChannelResidency = {
80
+ guildId: string;
81
+ channelId: string;
82
+ };
83
+
84
+ function formatVoiceLogPreview(text: string): string {
85
+ const oneLine = text.replace(/\s+/g, " ").trim();
86
+ if (oneLine.length <= VOICE_LOG_PREVIEW_CHARS) {
87
+ return oneLine;
88
+ }
89
+ return `${oneLine.slice(0, VOICE_LOG_PREVIEW_CHARS)}...`;
90
+ }
91
+
92
+ function isVoiceConnectionDestroyed(
93
+ connection: DiscordVoiceConnection,
94
+ voiceSdk: DiscordVoiceSdk,
95
+ ): boolean {
96
+ return connection.state.status === voiceSdk.VoiceConnectionStatus.Destroyed;
97
+ }
98
+
99
+ function destroyVoiceConnectionSafely(params: {
100
+ connection: DiscordVoiceConnection;
101
+ voiceSdk: DiscordVoiceSdk;
102
+ reason: string;
103
+ }): void {
104
+ if (isVoiceConnectionDestroyed(params.connection, params.voiceSdk)) {
105
+ logVoiceVerbose(`destroy skipped: ${params.reason}; connection already destroyed`);
106
+ return;
107
+ }
108
+ try {
109
+ params.connection.destroy();
110
+ } catch (err) {
111
+ const message = formatErrorMessage(err);
112
+ if (message.includes("already been destroyed")) {
113
+ logVoiceVerbose(`destroy skipped: ${params.reason}; ${message}`);
114
+ return;
115
+ }
116
+ logger.warn(`discord voice: destroy failed: ${params.reason}: ${message}`);
117
+ }
118
+ }
119
+
120
+ function normalizeVoiceChannelResidencies(
121
+ entries: Array<{ guildId?: string; channelId?: string }> | undefined,
122
+ ): VoiceChannelResidency[] {
123
+ const normalized: VoiceChannelResidency[] = [];
124
+ for (const entry of entries ?? []) {
125
+ const guildId = entry.guildId?.trim();
126
+ const channelId = entry.channelId?.trim();
127
+ if (guildId && channelId) {
128
+ normalized.push({ guildId, channelId });
129
+ }
130
+ }
131
+ return normalized;
132
+ }
133
+
134
+ function isVoiceChannelAllowed(params: {
135
+ allowedChannels: VoiceChannelResidency[] | null;
136
+ guildId: string;
137
+ channelId: string;
138
+ }): boolean {
139
+ return (
140
+ params.allowedChannels === null ||
141
+ params.allowedChannels.some(
142
+ (entry) => entry.guildId === params.guildId && entry.channelId === params.channelId,
143
+ )
144
+ );
145
+ }
146
+
147
+ function formatAutoJoinFailureKey(entry: { guildId: string; channelId: string }): string {
148
+ return `${entry.guildId}:${entry.channelId}`;
149
+ }
150
+
151
+ function isFatalAutoJoinFailure(message: string): boolean {
152
+ const normalized = message.toLowerCase();
153
+ return DISCORD_VOICE_FATAL_AUTOJOIN_ERROR_PATTERNS.some((pattern) =>
154
+ normalized.includes(pattern),
155
+ );
156
+ }
157
+
158
+ function startAutoJoin(manager: Pick<DiscordVoiceManager, "autoJoin">) {
159
+ void manager
160
+ .autoJoin()
161
+ .catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`));
162
+ }
163
+
164
+ function resolveDiscordVoiceAgentRoute(params: {
165
+ cfg: AutoBotConfig;
166
+ accountId: string;
167
+ guildId: string;
168
+ sessionChannelId: string;
169
+ voiceConfig: DiscordAccountConfig["voice"];
170
+ }) {
171
+ const voiceRoute = resolveAgentRoute({
172
+ cfg: params.cfg,
173
+ channel: "discord",
174
+ accountId: params.accountId,
175
+ guildId: params.guildId,
176
+ peer: { kind: "channel", id: params.sessionChannelId },
177
+ });
178
+ const agentSession = params.voiceConfig?.agentSession;
179
+ if (agentSession?.mode !== "target") {
180
+ return {
181
+ route: voiceRoute,
182
+ voiceRoute,
183
+ agentSessionMode: "voice" as const,
184
+ agentSessionTarget: undefined,
185
+ };
186
+ }
187
+ const target = agentSession.target?.trim();
188
+ if (!target) {
189
+ throw new Error('channels.discord.voice.agentSession.target is required when mode is "target"');
190
+ }
191
+ const parsed = parseDiscordTarget(target, { defaultKind: "channel" });
192
+ if (!parsed) {
193
+ throw new Error(`Invalid Discord voice agent session target "${target}"`);
194
+ }
195
+ const route = resolveAgentRoute({
196
+ cfg: params.cfg,
197
+ channel: "discord",
198
+ accountId: params.accountId,
199
+ guildId: params.guildId,
200
+ peer: {
201
+ kind: parsed.kind === "user" ? "direct" : "channel",
202
+ id: parsed.id,
203
+ },
204
+ });
205
+ return {
206
+ route,
207
+ voiceRoute,
208
+ agentSessionMode: "target" as const,
209
+ agentSessionTarget: parsed.normalized,
210
+ };
211
+ }
212
+
213
+ export class DiscordVoiceManager {
214
+ private sessions = new Map<string, VoiceSessionEntry>();
215
+ private botUserId?: string;
216
+ private readonly voiceEnabled: boolean;
217
+ private autoJoinTask: Promise<void> | null = null;
218
+ private readonly fatalAutoJoinFailures = new Map<
219
+ string,
220
+ { message: string; skipLogged: boolean }
221
+ >();
222
+ private readonly ownerAllowFrom?: string[];
223
+ private readonly speakerContext: DiscordVoiceSpeakerContextResolver;
224
+ private readonly allowedChannels: VoiceChannelResidency[] | null;
225
+
226
+ constructor(
227
+ private params: {
228
+ client: Client;
229
+ cfg: AutoBotConfig;
230
+ discordConfig: DiscordAccountConfig;
231
+ accountId: string;
232
+ runtime: RuntimeEnv;
233
+ botUserId?: string;
234
+ },
235
+ ) {
236
+ this.botUserId = params.botUserId;
237
+ this.voiceEnabled = resolveDiscordVoiceEnabled(params.discordConfig.voice);
238
+ this.ownerAllowFrom =
239
+ resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: params.accountId }) ??
240
+ params.discordConfig.allowFrom ??
241
+ params.discordConfig.dm?.allowFrom ??
242
+ [];
243
+ this.allowedChannels =
244
+ params.discordConfig.voice?.allowedChannels === undefined
245
+ ? null
246
+ : normalizeVoiceChannelResidencies(params.discordConfig.voice.allowedChannels);
247
+ this.speakerContext = new DiscordVoiceSpeakerContextResolver({
248
+ client: params.client,
249
+ ownerAllowFrom: this.ownerAllowFrom,
250
+ });
251
+ }
252
+
253
+ setBotUserId(id?: string) {
254
+ if (id) {
255
+ this.botUserId = id;
256
+ }
257
+ }
258
+
259
+ isEnabled() {
260
+ return this.voiceEnabled;
261
+ }
262
+
263
+ async autoJoin(): Promise<void> {
264
+ if (!this.voiceEnabled) {
265
+ return;
266
+ }
267
+ if (this.autoJoinTask) {
268
+ return this.autoJoinTask;
269
+ }
270
+ this.autoJoinTask = (async () => {
271
+ const entries = this.params.discordConfig.voice?.autoJoin ?? [];
272
+ const entriesByGuild = new Map<string, { guildId: string; channelId: string }>();
273
+ const duplicateGuilds = new Set<string>();
274
+ for (const entry of entries) {
275
+ const guildId = entry.guildId.trim();
276
+ const channelId = entry.channelId.trim();
277
+ if (!guildId || !channelId) {
278
+ continue;
279
+ }
280
+ if (entriesByGuild.has(guildId)) {
281
+ duplicateGuilds.add(guildId);
282
+ }
283
+ entriesByGuild.set(guildId, { guildId, channelId });
284
+ }
285
+
286
+ logVoiceVerbose(`autoJoin: ${entries.length} entries, ${entriesByGuild.size} guilds`);
287
+ for (const guildId of duplicateGuilds) {
288
+ const selected = entriesByGuild.get(guildId);
289
+ if (selected) {
290
+ logger.warn(
291
+ `discord voice: autoJoin has multiple entries for guild ${guildId}; using channel ${selected.channelId}`,
292
+ );
293
+ }
294
+ }
295
+
296
+ for (const entry of entriesByGuild.values()) {
297
+ const failureKey = formatAutoJoinFailureKey(entry);
298
+ const fatalFailure = this.fatalAutoJoinFailures.get(failureKey);
299
+ if (fatalFailure) {
300
+ if (!fatalFailure.skipLogged) {
301
+ logger.warn(
302
+ `discord voice: autoJoin suppressed guild=${entry.guildId} channel=${entry.channelId} after fatal startup failure; retry with /vc join or reload config after fixing credentials: ${fatalFailure.message}`,
303
+ );
304
+ fatalFailure.skipLogged = true;
305
+ }
306
+ continue;
307
+ }
308
+ logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`);
309
+ const result = await this.join({
310
+ guildId: entry.guildId,
311
+ channelId: entry.channelId,
312
+ });
313
+ if (!result.ok) {
314
+ logger.warn(
315
+ `discord voice: autoJoin skipped guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`,
316
+ );
317
+ if (isFatalAutoJoinFailure(result.message)) {
318
+ this.fatalAutoJoinFailures.set(failureKey, {
319
+ message: result.message,
320
+ skipLogged: false,
321
+ });
322
+ }
323
+ }
324
+ }
325
+ })().finally(() => {
326
+ this.autoJoinTask = null;
327
+ });
328
+ return this.autoJoinTask;
329
+ }
330
+
331
+ status(): VoiceOperationResult[] {
332
+ return Array.from(this.sessions.values()).map((session) => ({
333
+ ok: true,
334
+ message: `connected: guild ${session.guildId} channel ${session.channelId}`,
335
+ guildId: session.guildId,
336
+ channelId: session.channelId,
337
+ }));
338
+ }
339
+
340
+ isAllowedVoiceChannel(params: { guildId: string; channelId: string }): boolean {
341
+ return isVoiceChannelAllowed({
342
+ allowedChannels: this.allowedChannels,
343
+ guildId: params.guildId.trim(),
344
+ channelId: params.channelId.trim(),
345
+ });
346
+ }
347
+
348
+ async join(params: { guildId: string; channelId: string }): Promise<VoiceOperationResult> {
349
+ if (!this.voiceEnabled) {
350
+ return {
351
+ ok: false,
352
+ message: "Discord voice is disabled (channels.discord.voice.enabled).",
353
+ };
354
+ }
355
+ const guildId = params.guildId.trim();
356
+ const channelId = params.channelId.trim();
357
+ if (!guildId || !channelId) {
358
+ return { ok: false, message: "Missing guildId or channelId." };
359
+ }
360
+ if (!this.isAllowedVoiceChannel({ guildId, channelId })) {
361
+ logger.warn(
362
+ `discord voice: join rejected for non-allowed channel guild=${guildId} channel=${channelId}`,
363
+ );
364
+ return {
365
+ ok: false,
366
+ message: `${formatMention({ channelId })} is not allowed by channels.discord.voice.allowedChannels.`,
367
+ guildId,
368
+ channelId,
369
+ };
370
+ }
371
+ logVoiceVerbose(`join requested: guild ${guildId} channel ${channelId}`);
372
+
373
+ const existing = this.sessions.get(guildId);
374
+ if (existing && existing.channelId === channelId) {
375
+ logVoiceVerbose(`join: already connected to guild ${guildId} channel ${channelId}`);
376
+ return {
377
+ ok: true,
378
+ message: `Already connected to ${formatMention({ channelId })}.`,
379
+ guildId,
380
+ channelId,
381
+ };
382
+ }
383
+ if (existing) {
384
+ logVoiceVerbose(`join: replacing existing session for guild ${guildId}`);
385
+ await this.leave({ guildId });
386
+ }
387
+
388
+ const channelInfo = await this.params.client.fetchChannel(channelId).catch(() => null);
389
+ if (!channelInfo || ("type" in channelInfo && !isVoiceChannel(channelInfo.type))) {
390
+ return { ok: false, message: `Channel ${channelId} is not a voice channel.` };
391
+ }
392
+ const channelGuildId = "guildId" in channelInfo ? channelInfo.guildId : undefined;
393
+ if (channelGuildId && channelGuildId !== guildId) {
394
+ return { ok: false, message: "Voice channel is not in this guild." };
395
+ }
396
+
397
+ const voicePlugin = this.params.client.getPlugin<VoicePlugin>("voice");
398
+ if (!voicePlugin) {
399
+ return { ok: false, message: "Discord voice plugin is not available." };
400
+ }
401
+
402
+ const voiceConfig = this.params.discordConfig.voice;
403
+ const voiceMode = resolveDiscordVoiceMode(voiceConfig);
404
+ const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId);
405
+ const daveEncryption = voiceConfig?.daveEncryption;
406
+ const decryptionFailureTolerance = voiceConfig?.decryptionFailureTolerance;
407
+ const connectReadyTimeoutMs = resolveVoiceTimeoutMs(
408
+ voiceConfig?.connectTimeoutMs,
409
+ VOICE_CONNECT_READY_TIMEOUT_MS,
410
+ );
411
+ const reconnectGraceMs = resolveVoiceTimeoutMs(
412
+ voiceConfig?.reconnectGraceMs,
413
+ VOICE_RECONNECT_GRACE_MS,
414
+ );
415
+ logVoiceVerbose(
416
+ `join: DAVE settings encryption=${daveEncryption === false ? "off" : "on"} tolerance=${
417
+ decryptionFailureTolerance ?? "default"
418
+ } connectTimeout=${connectReadyTimeoutMs}ms reconnectGrace=${reconnectGraceMs}ms`,
419
+ );
420
+ const voiceSdk = loadDiscordVoiceSdk();
421
+ const existingEntry = this.sessions.get(guildId);
422
+ if (existingEntry) {
423
+ existingEntry.stop();
424
+ this.sessions.delete(guildId);
425
+ }
426
+ const staleConnection = voiceSdk.getVoiceConnection(guildId);
427
+ if (staleConnection) {
428
+ destroyVoiceConnectionSafely({
429
+ connection: staleConnection,
430
+ voiceSdk,
431
+ reason: `stale connection before join guild ${guildId}`,
432
+ });
433
+ }
434
+ const connection = voiceSdk.joinVoiceChannel({
435
+ channelId,
436
+ guildId,
437
+ adapterCreator,
438
+ selfDeaf: false,
439
+ selfMute: false,
440
+ daveEncryption,
441
+ decryptionFailureTolerance,
442
+ });
443
+
444
+ try {
445
+ await voiceSdk.entersState(
446
+ connection,
447
+ voiceSdk.VoiceConnectionStatus.Ready,
448
+ connectReadyTimeoutMs,
449
+ );
450
+ logVoiceVerbose(`join: connected to guild ${guildId} channel ${channelId}`);
451
+ } catch (err) {
452
+ logger.warn(
453
+ `discord voice: join failed before ready: guild ${guildId} channel ${channelId} timeout=${connectReadyTimeoutMs}ms error=${formatErrorMessage(err)}`,
454
+ );
455
+ destroyVoiceConnectionSafely({
456
+ connection,
457
+ voiceSdk,
458
+ reason: `failed join cleanup guild ${guildId} channel ${channelId}`,
459
+ });
460
+ return { ok: false, message: `Failed to join voice channel: ${formatErrorMessage(err)}` };
461
+ }
462
+
463
+ const sessionChannelId = channelInfo?.id ?? channelId;
464
+ // Use the voice channel id as the session channel so text chat in the voice channel
465
+ // shares the same session as spoken audio.
466
+ if (sessionChannelId !== channelId) {
467
+ logVoiceVerbose(
468
+ `join: using session channel ${sessionChannelId} for voice channel ${channelId}`,
469
+ );
470
+ }
471
+ let routeInfo: ReturnType<typeof resolveDiscordVoiceAgentRoute>;
472
+ try {
473
+ routeInfo = resolveDiscordVoiceAgentRoute({
474
+ cfg: this.params.cfg,
475
+ accountId: this.params.accountId,
476
+ guildId,
477
+ sessionChannelId,
478
+ voiceConfig,
479
+ });
480
+ } catch (err) {
481
+ destroyVoiceConnectionSafely({
482
+ connection,
483
+ voiceSdk,
484
+ reason: `voice agent session route failed guild ${guildId} channel ${channelId}`,
485
+ });
486
+ return {
487
+ ok: false,
488
+ message: `Failed to resolve Discord voice agent session: ${formatErrorMessage(err)}`,
489
+ guildId,
490
+ channelId,
491
+ };
492
+ }
493
+ const { route, voiceRoute, agentSessionMode, agentSessionTarget } = routeInfo;
494
+ logger.info(
495
+ `discord voice: joining guild=${guildId} channel=${channelId} mode=${voiceMode} agent=${route.agentId} voiceSession=${voiceRoute.sessionKey} supervisorSession=${route.sessionKey} agentSessionMode=${agentSessionMode}${agentSessionTarget ? ` agentSessionTarget=${agentSessionTarget}` : ""} voiceModel=${voiceConfig?.model ?? "route-default"} realtimeProvider=${voiceConfig?.realtime?.provider ?? "auto"} realtimeModel=${voiceConfig?.realtime?.model ?? "provider-default"} realtimeVoice=${voiceConfig?.realtime?.voice ?? "provider-default"}`,
496
+ );
497
+
498
+ const player = voiceSdk.createAudioPlayer();
499
+ connection.subscribe(player);
500
+
501
+ let speakingHandler: ((userId: string) => void) | undefined;
502
+ let speakingEndHandler: ((userId: string) => void) | undefined;
503
+ let disconnectedHandler: (() => Promise<void>) | undefined;
504
+ let destroyedHandler: (() => void) | undefined;
505
+ let playerErrorHandler: ((err: Error) => void) | undefined;
506
+ let stopped = false;
507
+ const clearSessionIfCurrent = () => {
508
+ const active = this.sessions.get(guildId);
509
+ if (active?.connection === connection) {
510
+ this.sessions.delete(guildId);
511
+ }
512
+ };
513
+ const stopEntry = (
514
+ entry: VoiceSessionEntry,
515
+ options: { destroyConnection: boolean; reason: string },
516
+ ) => {
517
+ if (stopped) {
518
+ return;
519
+ }
520
+ stopped = true;
521
+ if (speakingHandler) {
522
+ connection.receiver.speaking.off("start", speakingHandler);
523
+ }
524
+ if (speakingEndHandler) {
525
+ connection.receiver.speaking.off("end", speakingEndHandler);
526
+ }
527
+ stopVoiceCaptureState(entry.capture);
528
+ if (disconnectedHandler) {
529
+ connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler);
530
+ }
531
+ if (destroyedHandler) {
532
+ connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler);
533
+ }
534
+ if (playerErrorHandler) {
535
+ player.off("error", playerErrorHandler);
536
+ }
537
+ entry.realtime?.close();
538
+ entry.realtime = undefined;
539
+ player.stop();
540
+ if (options.destroyConnection) {
541
+ destroyVoiceConnectionSafely({
542
+ connection,
543
+ voiceSdk,
544
+ reason: options.reason,
545
+ });
546
+ }
547
+ };
548
+
549
+ const entry: VoiceSessionEntry = {
550
+ guildId,
551
+ guildName:
552
+ channelInfo &&
553
+ "guild" in channelInfo &&
554
+ channelInfo.guild &&
555
+ typeof channelInfo.guild.name === "string"
556
+ ? channelInfo.guild.name
557
+ : undefined,
558
+ channelId,
559
+ channelName:
560
+ channelInfo && "name" in channelInfo && typeof channelInfo.name === "string"
561
+ ? channelInfo.name
562
+ : undefined,
563
+ sessionChannelId,
564
+ voiceSessionKey: voiceRoute.sessionKey,
565
+ route,
566
+ connection,
567
+ player,
568
+ playbackQueue: Promise.resolve(),
569
+ processingQueue: Promise.resolve(),
570
+ capture: createVoiceCaptureState(),
571
+ receiveRecovery: createVoiceReceiveRecoveryState(),
572
+ stop: () => {
573
+ stopEntry(entry, {
574
+ destroyConnection: true,
575
+ reason: `stop guild ${guildId} channel ${channelId}`,
576
+ });
577
+ },
578
+ };
579
+
580
+ if (voiceMode !== "stt-tts") {
581
+ entry.realtime = new DiscordRealtimeVoiceSession({
582
+ cfg: this.params.cfg,
583
+ discordConfig: this.params.discordConfig,
584
+ entry,
585
+ mode: voiceMode,
586
+ runAgentTurn: ({ context, message, toolsAllow, userId }) =>
587
+ this.runDiscordRealtimeAgentTurn({ context, entry, message, toolsAllow, userId }),
588
+ });
589
+ try {
590
+ await entry.realtime.connect();
591
+ } catch (err) {
592
+ entry.realtime.close();
593
+ destroyVoiceConnectionSafely({
594
+ connection,
595
+ voiceSdk,
596
+ reason: `realtime setup failed guild ${guildId} channel ${channelId}`,
597
+ });
598
+ return {
599
+ ok: false,
600
+ message: `Failed to start Discord realtime voice: ${formatErrorMessage(err)}`,
601
+ guildId,
602
+ channelId,
603
+ };
604
+ }
605
+ }
606
+
607
+ speakingHandler = (userId: string) => {
608
+ void this.handleSpeakingStart(entry, userId).catch((err) => {
609
+ logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`);
610
+ });
611
+ };
612
+ speakingEndHandler = (userId: string) => {
613
+ this.scheduleCaptureFinalize(entry, userId, "speaker end");
614
+ };
615
+
616
+ disconnectedHandler = async () => {
617
+ try {
618
+ logVoiceVerbose(
619
+ `disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
620
+ );
621
+ await Promise.race([
622
+ voiceSdk.entersState(
623
+ connection,
624
+ voiceSdk.VoiceConnectionStatus.Signalling,
625
+ reconnectGraceMs,
626
+ ),
627
+ voiceSdk.entersState(
628
+ connection,
629
+ voiceSdk.VoiceConnectionStatus.Connecting,
630
+ reconnectGraceMs,
631
+ ),
632
+ ]);
633
+ logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
634
+ } catch (err) {
635
+ logger.warn(
636
+ `discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
637
+ );
638
+ clearSessionIfCurrent();
639
+ stopEntry(entry, {
640
+ destroyConnection: true,
641
+ reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
642
+ });
643
+ }
644
+ };
645
+ destroyedHandler = () => {
646
+ clearSessionIfCurrent();
647
+ stopEntry(entry, {
648
+ destroyConnection: false,
649
+ reason: `destroyed guild ${guildId} channel ${channelId}`,
650
+ });
651
+ };
652
+ playerErrorHandler = (err: Error) => {
653
+ logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`);
654
+ };
655
+
656
+ this.enableDaveReceivePassthrough(
657
+ entry,
658
+ "post-join warmup",
659
+ DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS,
660
+ );
661
+ connection.receiver.speaking.on("start", speakingHandler);
662
+ connection.receiver.speaking.on("end", speakingEndHandler);
663
+ connection.on(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler);
664
+ connection.on(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler);
665
+ player.on("error", playerErrorHandler);
666
+
667
+ this.sessions.set(guildId, entry);
668
+ this.fatalAutoJoinFailures.delete(formatAutoJoinFailureKey({ guildId, channelId }));
669
+ logger.info(
670
+ `discord voice: joined guild=${guildId} channel=${channelId} mode=${voiceMode} agent=${route.agentId} voiceSession=${voiceRoute.sessionKey} supervisorSession=${route.sessionKey} voiceModel=${voiceConfig?.model ?? "route-default"}`,
671
+ );
672
+ return {
673
+ ok: true,
674
+ message: `Joined ${formatMention({ channelId })}.`,
675
+ guildId,
676
+ channelId,
677
+ };
678
+ }
679
+
680
+ async leave(params: { guildId: string; channelId?: string }): Promise<VoiceOperationResult> {
681
+ const guildId = params.guildId.trim();
682
+ logVoiceVerbose(`leave requested: guild ${guildId} channel ${params.channelId ?? "current"}`);
683
+ const entry = this.sessions.get(guildId);
684
+ if (!entry) {
685
+ return { ok: false, message: "Not connected to a voice channel." };
686
+ }
687
+ if (params.channelId && params.channelId !== entry.channelId) {
688
+ return { ok: false, message: "Not connected to that voice channel." };
689
+ }
690
+ entry.stop();
691
+ this.sessions.delete(guildId);
692
+ logVoiceVerbose(`leave: disconnected from guild ${guildId} channel ${entry.channelId}`);
693
+ return {
694
+ ok: true,
695
+ message: `Left ${formatMention({ channelId: entry.channelId })}.`,
696
+ guildId,
697
+ channelId: entry.channelId,
698
+ };
699
+ }
700
+
701
+ async handleVoiceStateUpdate(data: APIVoiceState): Promise<void> {
702
+ if (!this.botUserId || data.user_id !== this.botUserId) {
703
+ return;
704
+ }
705
+ const guildId = data.guild_id?.trim();
706
+ const channelId = data.channel_id?.trim();
707
+ if (!guildId || !channelId) {
708
+ return;
709
+ }
710
+
711
+ const existing = this.sessions.get(guildId);
712
+ if (this.isAllowedVoiceChannel({ guildId, channelId })) {
713
+ if (existing && existing.channelId !== channelId) {
714
+ logger.warn(
715
+ `discord voice: bot moved to allowed channel guild=${guildId} from=${existing.channelId} to=${channelId}; rebuilding voice session`,
716
+ );
717
+ await this.join({ guildId, channelId });
718
+ }
719
+ return;
720
+ }
721
+
722
+ logger.warn(
723
+ `discord voice: bot moved to non-allowed channel guild=${guildId} channel=${channelId}; leaving`,
724
+ );
725
+ if (existing) {
726
+ await this.leave({ guildId });
727
+ } else {
728
+ const voiceSdk = loadDiscordVoiceSdk();
729
+ const connection = voiceSdk.getVoiceConnection(guildId);
730
+ if (connection) {
731
+ destroyVoiceConnectionSafely({
732
+ connection,
733
+ voiceSdk,
734
+ reason: `non-allowed voice state guild ${guildId} channel ${channelId}`,
735
+ });
736
+ }
737
+ }
738
+
739
+ const target = this.resolveVoiceResidencyTarget(guildId);
740
+ if (target) {
741
+ logger.warn(
742
+ `discord voice: rejoining allowed voice channel guild=${guildId} channel=${target.channelId}`,
743
+ );
744
+ await this.join(target);
745
+ }
746
+ }
747
+
748
+ async destroy(): Promise<void> {
749
+ for (const entry of this.sessions.values()) {
750
+ entry.stop();
751
+ }
752
+ this.sessions.clear();
753
+ }
754
+
755
+ private resolveVoiceResidencyTarget(guildId: string): VoiceChannelResidency | null {
756
+ const autoJoinTarget = normalizeVoiceChannelResidencies(
757
+ this.params.discordConfig.voice?.autoJoin,
758
+ )
759
+ .toReversed()
760
+ .find((entry) => entry.guildId === guildId);
761
+ if (autoJoinTarget && this.isAllowedVoiceChannel(autoJoinTarget)) {
762
+ return autoJoinTarget;
763
+ }
764
+ if (this.allowedChannels === null) {
765
+ return null;
766
+ }
767
+ const guildAllowed = this.allowedChannels.filter((entry) => entry.guildId === guildId);
768
+ return guildAllowed.length === 1 ? guildAllowed[0] : null;
769
+ }
770
+
771
+ private enqueueProcessing(entry: VoiceSessionEntry, task: () => Promise<void>) {
772
+ entry.processingQueue = entry.processingQueue
773
+ .then(task)
774
+ .catch((err) => logger.warn(`discord voice: processing failed: ${formatErrorMessage(err)}`));
775
+ }
776
+
777
+ private enqueuePlayback(entry: VoiceSessionEntry, task: () => Promise<void>) {
778
+ entry.playbackQueue = entry.playbackQueue
779
+ .then(task)
780
+ .catch((err) => logger.warn(`discord voice: playback failed: ${formatErrorMessage(err)}`));
781
+ }
782
+
783
+ private clearCaptureFinalizeTimer(entry: VoiceSessionEntry, userId: string, generation?: number) {
784
+ return clearVoiceCaptureFinalizeTimer(entry.capture, userId, generation);
785
+ }
786
+
787
+ private scheduleCaptureFinalize(entry: VoiceSessionEntry, userId: string, reason: string) {
788
+ const graceMs = resolveVoiceTimeoutMs(
789
+ this.params.discordConfig.voice?.captureSilenceGraceMs,
790
+ CAPTURE_FINALIZE_GRACE_MS,
791
+ );
792
+ scheduleVoiceCaptureFinalize({
793
+ state: entry.capture,
794
+ userId,
795
+ delayMs: graceMs,
796
+ onFinalize: () => {
797
+ logVoiceVerbose(
798
+ `capture finalize: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${reason} grace=${graceMs}ms`,
799
+ );
800
+ },
801
+ });
802
+ }
803
+
804
+ private async handleSpeakingStart(entry: VoiceSessionEntry, userId: string) {
805
+ if (!userId) {
806
+ return;
807
+ }
808
+ if (this.botUserId && userId === this.botUserId) {
809
+ return;
810
+ }
811
+ if (isVoiceCaptureActive(entry.capture, userId)) {
812
+ const activeCapture = getActiveVoiceCapture(entry.capture, userId);
813
+ const extended = activeCapture
814
+ ? this.clearCaptureFinalizeTimer(entry, userId, activeCapture.generation)
815
+ : false;
816
+ logVoiceVerbose(
817
+ `capture start ignored (already active): guild ${entry.guildId} channel ${entry.channelId} user ${userId}${extended ? " (finalize canceled)" : ""}`,
818
+ );
819
+ return;
820
+ }
821
+
822
+ logVoiceVerbose(
823
+ `capture start: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
824
+ );
825
+ const voiceSdk = loadDiscordVoiceSdk();
826
+ const voiceMode = resolveDiscordVoiceMode(this.params.discordConfig.voice);
827
+ const realtime =
828
+ entry.realtime && isDiscordRealtimeVoiceMode(voiceMode) ? entry.realtime : undefined;
829
+ if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing && !realtime) {
830
+ logVoiceVerbose(
831
+ `capture ignored during playback: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
832
+ );
833
+ return;
834
+ }
835
+ const realtimeIngress = realtime
836
+ ? await this.resolveDiscordVoiceIngressContext(entry, userId)
837
+ : undefined;
838
+ if (realtime && !realtimeIngress) {
839
+ logVoiceVerbose(
840
+ `realtime capture unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
841
+ );
842
+ return;
843
+ }
844
+ if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing && realtime) {
845
+ if (!realtime.isBargeInEnabled()) {
846
+ logger.info(
847
+ `discord voice: realtime capture ignored during playback (barge-in disabled): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
848
+ );
849
+ return;
850
+ }
851
+ logVoiceVerbose(
852
+ `realtime barge-in: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
853
+ );
854
+ logger.info(
855
+ `discord voice: realtime barge-in detected source=speaker-start guild=${entry.guildId} channel=${entry.channelId} user=${userId} playerStatus=${entry.player.state.status}`,
856
+ );
857
+ realtime.handleBargeIn("speaker-start");
858
+ }
859
+ this.enableDaveReceivePassthrough(
860
+ entry,
861
+ `speaker ${userId} start`,
862
+ DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS,
863
+ );
864
+ const stream = entry.connection.receiver.subscribe(userId, {
865
+ end: {
866
+ behavior: voiceSdk.EndBehaviorType.Manual,
867
+ },
868
+ });
869
+ const generation = beginVoiceCapture(entry.capture, userId, stream);
870
+ let streamAborted = false;
871
+ stream.on("error", (err) => {
872
+ streamAborted = analyzeVoiceReceiveError(err).isAbortLike;
873
+ this.handleReceiveError(entry, err);
874
+ });
875
+
876
+ try {
877
+ if (realtime && realtimeIngress) {
878
+ const turn = realtime.beginSpeakerTurn(realtimeIngress, userId);
879
+ try {
880
+ await this.processRealtimeAudioCapture({ entry, stream, turn });
881
+ } finally {
882
+ turn.close();
883
+ }
884
+ return;
885
+ }
886
+ const pcm = await decodeOpusStream(stream, {
887
+ onVerbose: logVoiceVerbose,
888
+ onWarn: (message) => logger.warn(message),
889
+ });
890
+ if (pcm.length === 0) {
891
+ logVoiceVerbose(
892
+ `capture empty: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
893
+ );
894
+ return;
895
+ }
896
+ this.resetDecryptFailureState(entry);
897
+ const { path: wavPath, durationSeconds } = await writeVoiceWavFile(pcm);
898
+ const minimumDurationSeconds = streamAborted ? 0.2 : MIN_SEGMENT_SECONDS;
899
+ if (durationSeconds < minimumDurationSeconds) {
900
+ logVoiceVerbose(
901
+ `capture too short (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
902
+ );
903
+ return;
904
+ }
905
+ logVoiceVerbose(
906
+ `capture ready (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
907
+ );
908
+ this.enqueueProcessing(entry, async () => {
909
+ await this.processSegment({ entry, wavPath, userId, durationSeconds });
910
+ });
911
+ } finally {
912
+ finishVoiceCapture(entry.capture, userId, generation);
913
+ }
914
+ }
915
+
916
+ private async processRealtimeAudioCapture(params: {
917
+ entry: VoiceSessionEntry;
918
+ stream: import("node:stream").Readable;
919
+ turn: import("./session.js").VoiceRealtimeSpeakerTurn;
920
+ }): Promise<void> {
921
+ const { entry, stream, turn } = params;
922
+ let resetReceiveRecovery = false;
923
+ await decodeOpusStreamChunks(stream, {
924
+ onChunk: (pcm) => {
925
+ if (!resetReceiveRecovery && pcm.length > 0) {
926
+ resetReceiveRecovery = true;
927
+ this.resetDecryptFailureState(entry);
928
+ }
929
+ turn.sendInputAudio(pcm);
930
+ },
931
+ onVerbose: logVoiceVerbose,
932
+ onWarn: (message) => logger.warn(message),
933
+ });
934
+ }
935
+
936
+ private async resolveDiscordVoiceIngressContext(
937
+ entry: VoiceSessionEntry,
938
+ userId: string,
939
+ ): Promise<DiscordVoiceIngressContext | null> {
940
+ return await resolveDiscordVoiceIngressContext({
941
+ entry,
942
+ userId,
943
+ cfg: this.params.cfg,
944
+ discordConfig: this.params.discordConfig,
945
+ ownerAllowFrom: this.ownerAllowFrom,
946
+ fetchGuildName: async (guildId) => {
947
+ const guild = await this.params.client.fetchGuild(guildId).catch(() => null);
948
+ return guild && typeof guild.name === "string" && guild.name.trim()
949
+ ? guild.name
950
+ : undefined;
951
+ },
952
+ speakerContext: this.speakerContext,
953
+ });
954
+ }
955
+
956
+ private async runDiscordRealtimeAgentTurn(params: {
957
+ context: {
958
+ extraSystemPrompt?: string;
959
+ senderIsOwner: boolean;
960
+ speakerLabel: string;
961
+ };
962
+ entry: VoiceSessionEntry;
963
+ message: string;
964
+ toolsAllow?: string[];
965
+ userId: string;
966
+ }): Promise<string> {
967
+ const { context, entry, message, toolsAllow, userId } = params;
968
+ logger.info(
969
+ `discord voice: agent turn start guild=${entry.guildId} channel=${entry.channelId} voiceSession=${entry.voiceSessionKey} supervisorSession=${entry.route.sessionKey} agent=${entry.route.agentId} user=${userId} speaker=${context.speakerLabel} owner=${context.senderIsOwner} model=${this.params.discordConfig.voice?.model ?? "route-default"} message=${formatVoiceLogPreview(message)}`,
970
+ );
971
+ const turn = await runDiscordVoiceAgentTurn({
972
+ entry,
973
+ userId,
974
+ message,
975
+ cfg: this.params.cfg,
976
+ discordConfig: this.params.discordConfig,
977
+ runtime: this.params.runtime,
978
+ context,
979
+ toolsAllow,
980
+ ownerAllowFrom: this.ownerAllowFrom,
981
+ fetchGuildName: async (guildId) => {
982
+ const guild = await this.params.client.fetchGuild(guildId).catch(() => null);
983
+ return guild && typeof guild.name === "string" && guild.name.trim()
984
+ ? guild.name
985
+ : undefined;
986
+ },
987
+ speakerContext: this.speakerContext,
988
+ });
989
+ if (!turn) {
990
+ logVoiceVerbose(
991
+ `realtime agent unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
992
+ );
993
+ return "";
994
+ }
995
+ logger.info(
996
+ `discord voice: agent turn answer (${turn.text.length} chars) guild=${entry.guildId} channel=${entry.channelId} voiceSession=${entry.voiceSessionKey} supervisorSession=${entry.route.sessionKey} agent=${entry.route.agentId}: ${formatVoiceLogPreview(turn.text)}`,
997
+ );
998
+ return turn.text;
999
+ }
1000
+
1001
+ private async processSegment(params: {
1002
+ entry: VoiceSessionEntry;
1003
+ wavPath: string;
1004
+ userId: string;
1005
+ durationSeconds: number;
1006
+ }) {
1007
+ await processDiscordVoiceSegment({
1008
+ ...params,
1009
+ cfg: this.params.cfg,
1010
+ discordConfig: this.params.discordConfig,
1011
+ ownerAllowFrom: this.ownerAllowFrom,
1012
+ runtime: this.params.runtime,
1013
+ speakerContext: this.speakerContext,
1014
+ fetchGuildName: async (guildId) => {
1015
+ const guild = await this.params.client.fetchGuild(guildId).catch(() => null);
1016
+ return guild && typeof guild.name === "string" && guild.name.trim()
1017
+ ? guild.name
1018
+ : undefined;
1019
+ },
1020
+ enqueuePlayback: (entry, task) => {
1021
+ this.enqueuePlayback(entry, task);
1022
+ },
1023
+ });
1024
+ }
1025
+
1026
+ private handleReceiveError(entry: VoiceSessionEntry, err: unknown) {
1027
+ const analysis = analyzeVoiceReceiveError(err);
1028
+ if (analysis.isAbortLike && !analysis.countsAsDecryptFailure) {
1029
+ logVoiceVerbose(`receive stream ended: ${analysis.message}`);
1030
+ return;
1031
+ }
1032
+ logger.warn(`discord voice: receive error: ${analysis.message}`);
1033
+ if (analysis.shouldAttemptPassthrough) {
1034
+ this.enableDaveReceivePassthrough(
1035
+ entry,
1036
+ "receive decrypt error",
1037
+ DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS,
1038
+ );
1039
+ }
1040
+ if (!analysis.countsAsDecryptFailure) {
1041
+ return;
1042
+ }
1043
+ const decryptFailure = noteVoiceDecryptFailure(entry.receiveRecovery);
1044
+ if (decryptFailure.firstFailure) {
1045
+ logger.warn(
1046
+ "discord voice: DAVE decrypt failures detected; voice receive may be unstable (upstream: discordjs/discord.js#11419)",
1047
+ );
1048
+ }
1049
+ if (!decryptFailure.shouldRecover) {
1050
+ return;
1051
+ }
1052
+ void this.recoverFromDecryptFailures(entry)
1053
+ .catch((recoverErr) =>
1054
+ logger.warn(`discord voice: decrypt recovery failed: ${formatErrorMessage(recoverErr)}`),
1055
+ )
1056
+ .finally(() => {
1057
+ finishVoiceDecryptRecovery(entry.receiveRecovery);
1058
+ });
1059
+ }
1060
+
1061
+ private enableDaveReceivePassthrough(
1062
+ entry: Pick<VoiceSessionEntry, "guildId" | "channelId" | "connection">,
1063
+ reason: string,
1064
+ expirySeconds: number,
1065
+ ): boolean {
1066
+ const voiceSdk = loadDiscordVoiceSdk();
1067
+ return tryEnableDaveReceivePassthrough({
1068
+ target: {
1069
+ guildId: entry.guildId,
1070
+ channelId: entry.channelId,
1071
+ connection: entry.connection as {
1072
+ state: {
1073
+ status: unknown;
1074
+ networking?: {
1075
+ state?: {
1076
+ code?: unknown;
1077
+ dave?: {
1078
+ session?: {
1079
+ setPassthroughMode: (passthrough: boolean, expirySeconds: number) => void;
1080
+ };
1081
+ };
1082
+ };
1083
+ };
1084
+ };
1085
+ },
1086
+ },
1087
+ sdk: {
1088
+ VoiceConnectionStatus: {
1089
+ Ready: voiceSdk.VoiceConnectionStatus.Ready,
1090
+ },
1091
+ NetworkingStatusCode: {
1092
+ Ready: voiceSdk.NetworkingStatusCode.Ready,
1093
+ Resuming: voiceSdk.NetworkingStatusCode.Resuming,
1094
+ },
1095
+ },
1096
+ reason,
1097
+ expirySeconds,
1098
+ onVerbose: logVoiceVerbose,
1099
+ onWarn: (message) => logger.warn(message),
1100
+ });
1101
+ }
1102
+
1103
+ private resetDecryptFailureState(entry: VoiceSessionEntry) {
1104
+ resetVoiceReceiveRecoveryState(entry.receiveRecovery);
1105
+ }
1106
+
1107
+ private async recoverFromDecryptFailures(entry: VoiceSessionEntry) {
1108
+ const active = this.sessions.get(entry.guildId);
1109
+ if (!active || active.connection !== entry.connection) {
1110
+ return;
1111
+ }
1112
+ logger.warn(
1113
+ `discord voice: repeated decrypt failures; attempting rejoin for guild ${entry.guildId} channel ${entry.channelId}`,
1114
+ );
1115
+ const leaveResult = await this.leave({ guildId: entry.guildId });
1116
+ if (!leaveResult.ok) {
1117
+ logger.warn(`discord voice: decrypt recovery leave failed: ${leaveResult.message}`);
1118
+ return;
1119
+ }
1120
+ const result = await this.join({ guildId: entry.guildId, channelId: entry.channelId });
1121
+ if (!result.ok) {
1122
+ logger.warn(`discord voice: rejoin after decrypt failures failed: ${result.message}`);
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ export class DiscordVoiceReadyListener extends ReadyListener {
1128
+ constructor(private manager: DiscordVoiceManager) {
1129
+ super();
1130
+ }
1131
+
1132
+ async handle(_data: unknown, _client: Client): Promise<void> {
1133
+ startAutoJoin(this.manager);
1134
+ }
1135
+ }
1136
+
1137
+ export class DiscordVoiceResumedListener extends ResumedListener {
1138
+ constructor(private manager: DiscordVoiceManager) {
1139
+ super();
1140
+ }
1141
+
1142
+ async handle(_data: unknown, _client: Client): Promise<void> {
1143
+ startAutoJoin(this.manager);
1144
+ }
1145
+ }
1146
+
1147
+ export class DiscordVoiceStateUpdateListener extends VoiceStateUpdateListener {
1148
+ constructor(private manager: DiscordVoiceManager) {
1149
+ super();
1150
+ }
1151
+
1152
+ async handle(data: APIVoiceState, _client: Client): Promise<void> {
1153
+ await this.manager.handleVoiceStateUpdate(data);
1154
+ }
1155
+ }