@moxxy/plugin-channel-discord 0.27.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/dist/allow-list.d.ts +20 -0
  3. package/dist/allow-list.d.ts.map +1 -0
  4. package/dist/allow-list.js +12 -0
  5. package/dist/allow-list.js.map +1 -0
  6. package/dist/approval.d.ts +29 -0
  7. package/dist/approval.d.ts.map +1 -0
  8. package/dist/approval.js +65 -0
  9. package/dist/approval.js.map +1 -0
  10. package/dist/channel/allow-list-store.d.ts +22 -0
  11. package/dist/channel/allow-list-store.d.ts.map +1 -0
  12. package/dist/channel/allow-list-store.js +45 -0
  13. package/dist/channel/allow-list-store.js.map +1 -0
  14. package/dist/channel/approval-prompt.d.ts +16 -0
  15. package/dist/channel/approval-prompt.d.ts.map +1 -0
  16. package/dist/channel/approval-prompt.js +27 -0
  17. package/dist/channel/approval-prompt.js.map +1 -0
  18. package/dist/channel/components.d.ts +27 -0
  19. package/dist/channel/components.d.ts.map +1 -0
  20. package/dist/channel/components.js +34 -0
  21. package/dist/channel/components.js.map +1 -0
  22. package/dist/channel/discord-like.d.ts +31 -0
  23. package/dist/channel/discord-like.d.ts.map +1 -0
  24. package/dist/channel/discord-like.js +9 -0
  25. package/dist/channel/discord-like.js.map +1 -0
  26. package/dist/channel/interaction-handler.d.ts +67 -0
  27. package/dist/channel/interaction-handler.d.ts.map +1 -0
  28. package/dist/channel/interaction-handler.js +155 -0
  29. package/dist/channel/interaction-handler.js.map +1 -0
  30. package/dist/channel/message-handler.d.ts +60 -0
  31. package/dist/channel/message-handler.d.ts.map +1 -0
  32. package/dist/channel/message-handler.js +135 -0
  33. package/dist/channel/message-handler.js.map +1 -0
  34. package/dist/channel/pairing-handler.d.ts +58 -0
  35. package/dist/channel/pairing-handler.d.ts.map +1 -0
  36. package/dist/channel/pairing-handler.js +109 -0
  37. package/dist/channel/pairing-handler.js.map +1 -0
  38. package/dist/channel/permission-prompt.d.ts +19 -0
  39. package/dist/channel/permission-prompt.d.ts.map +1 -0
  40. package/dist/channel/permission-prompt.js +38 -0
  41. package/dist/channel/permission-prompt.js.map +1 -0
  42. package/dist/channel/slash-handler.d.ts +45 -0
  43. package/dist/channel/slash-handler.d.ts.map +1 -0
  44. package/dist/channel/slash-handler.js +107 -0
  45. package/dist/channel/slash-handler.js.map +1 -0
  46. package/dist/channel/turn-runner.d.ts +49 -0
  47. package/dist/channel/turn-runner.d.ts.map +1 -0
  48. package/dist/channel/turn-runner.js +116 -0
  49. package/dist/channel/turn-runner.js.map +1 -0
  50. package/dist/channel/typing-indicator.d.ts +12 -0
  51. package/dist/channel/typing-indicator.d.ts.map +1 -0
  52. package/dist/channel/typing-indicator.js +28 -0
  53. package/dist/channel/typing-indicator.js.map +1 -0
  54. package/dist/channel/voice-handler.d.ts +41 -0
  55. package/dist/channel/voice-handler.d.ts.map +1 -0
  56. package/dist/channel/voice-handler.js +105 -0
  57. package/dist/channel/voice-handler.js.map +1 -0
  58. package/dist/channel.d.ts +107 -0
  59. package/dist/channel.d.ts.map +1 -0
  60. package/dist/channel.js +464 -0
  61. package/dist/channel.js.map +1 -0
  62. package/dist/index.d.ts +29 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +179 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +64 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +85 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +16 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +109 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +83 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +98 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +30 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +54 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +34 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +204 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/schema.d.ts +123 -0
  87. package/dist/schema.d.ts.map +1 -0
  88. package/dist/schema.js +70 -0
  89. package/dist/schema.js.map +1 -0
  90. package/dist/setup-wizard.d.ts +13 -0
  91. package/dist/setup-wizard.d.ts.map +1 -0
  92. package/dist/setup-wizard.js +133 -0
  93. package/dist/setup-wizard.js.map +1 -0
  94. package/package.json +89 -0
  95. package/src/allow-list.test.ts +64 -0
  96. package/src/allow-list.ts +30 -0
  97. package/src/approval.ts +76 -0
  98. package/src/channel/allow-list-store.ts +52 -0
  99. package/src/channel/approval-prompt.ts +43 -0
  100. package/src/channel/components.ts +49 -0
  101. package/src/channel/discord-like.ts +34 -0
  102. package/src/channel/interaction-handler.test.ts +212 -0
  103. package/src/channel/interaction-handler.ts +237 -0
  104. package/src/channel/message-handler.test.ts +295 -0
  105. package/src/channel/message-handler.ts +195 -0
  106. package/src/channel/pairing-handler.ts +147 -0
  107. package/src/channel/permission-prompt.ts +54 -0
  108. package/src/channel/slash-handler.ts +136 -0
  109. package/src/channel/turn-runner.test.ts +177 -0
  110. package/src/channel/turn-runner.ts +139 -0
  111. package/src/channel/typing-indicator.ts +31 -0
  112. package/src/channel/voice-handler.test.ts +177 -0
  113. package/src/channel/voice-handler.ts +150 -0
  114. package/src/channel/voice-reply.test.ts +142 -0
  115. package/src/channel.ts +582 -0
  116. package/src/index.ts +247 -0
  117. package/src/keys.ts +100 -0
  118. package/src/pair-flow.ts +122 -0
  119. package/src/pairing.test.ts +99 -0
  120. package/src/pairing.ts +178 -0
  121. package/src/permission.ts +70 -0
  122. package/src/render.test.ts +82 -0
  123. package/src/render.ts +228 -0
  124. package/src/schema.ts +94 -0
  125. package/src/setup-wizard.ts +162 -0
  126. package/src/subcommands.test.ts +182 -0
@@ -0,0 +1,45 @@
1
+ import type { ClientSession as Session } from '@moxxy/sdk';
2
+ import type { ChannelLogger } from './discord-like.js';
3
+ export interface SlashCallbacks {
4
+ /** Toggle yolo and return its new value (so we can echo the right message). */
5
+ toggleYolo(): boolean;
6
+ /** Handle `/voice [on|off|status]` — persist + apply, return the reply text. */
7
+ voice(arg: string): Promise<string>;
8
+ /** Apply a `session-action` result emitted from a registered command. */
9
+ performSessionAction(action: 'new' | 'clear' | 'exit', notice: string | undefined): Promise<string>;
10
+ }
11
+ /**
12
+ * Slash-command dispatcher shared by the plain-text path (`/info` typed as a
13
+ * message) and the application-command path (a real Discord slash command —
14
+ * the interaction handler routes both here). Returns the reply text to send.
15
+ *
16
+ * First tries the shared `session.commands` registry — the universal commands
17
+ * (/info, /clear, /new, /exit, /help) plus plugin-contributed ones — then
18
+ * falls through to Discord-local cases (/yolo, /tools, /skills). /cancel and
19
+ * /allow, /deny are handled by the message/interaction layer (they need channel
20
+ * state this dispatcher doesn't carry).
21
+ */
22
+ export declare function runSlash(name: string, args: string, session: Session, cb: SlashCallbacks): Promise<string>;
23
+ export interface AppCommandJson {
24
+ readonly name: string;
25
+ readonly description: string;
26
+ }
27
+ /**
28
+ * Build the application-command list to publish: the shared registry commands
29
+ * (parity with Telegram's `publishBotCommands`) plus the Discord-local ones.
30
+ * Names that don't fit Discord's `^[a-z0-9_-]{1,32}$` constraint are skipped
31
+ * (they remain reachable as plain-text `/name` messages); descriptions are
32
+ * clamped to Discord's 1..100 chars.
33
+ */
34
+ export declare function buildAppCommands(session: Session): AppCommandJson[];
35
+ /** The slice of a discord.js application-command manager we publish through. */
36
+ export interface AppCommandPublisher {
37
+ set(commands: ReadonlyArray<AppCommandJson>): Promise<unknown>;
38
+ }
39
+ /**
40
+ * Publish the commands to Discord so they appear in the client's "/" picker.
41
+ * Best-effort: a network failure here doesn't block channel startup — the
42
+ * commands still work as plain-text messages.
43
+ */
44
+ export declare function publishAppCommands(publisher: AppCommandPublisher | null, session: Session | null, logger?: ChannelLogger): Promise<void>;
45
+ //# sourceMappingURL=slash-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slash-handler.d.ts","sourceRoot":"","sources":["../../src/channel/slash-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,IAAI,OAAO,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,UAAU,IAAI,OAAO,CAAC;IACtB,gFAAgF;IAChF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpC,yEAAyE;IACzE,oBAAoB,CAAC,MAAM,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACrG;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,EAChB,EAAE,EAAE,cAAc,GACjB,OAAO,CAAC,MAAM,CAAC,CA+CjB;AAKD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,cAAc,EAAE,CAkBnE;AAED,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,QAAQ,EAAE,aAAa,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAChE;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,mBAAmB,GAAG,IAAI,EACrC,OAAO,EAAE,OAAO,GAAG,IAAI,EACvB,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,IAAI,CAAC,CASf"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Slash-command dispatcher shared by the plain-text path (`/info` typed as a
3
+ * message) and the application-command path (a real Discord slash command —
4
+ * the interaction handler routes both here). Returns the reply text to send.
5
+ *
6
+ * First tries the shared `session.commands` registry — the universal commands
7
+ * (/info, /clear, /new, /exit, /help) plus plugin-contributed ones — then
8
+ * falls through to Discord-local cases (/yolo, /tools, /skills). /cancel and
9
+ * /allow, /deny are handled by the message/interaction layer (they need channel
10
+ * state this dispatcher doesn't carry).
11
+ */
12
+ export async function runSlash(name, args, session, cb) {
13
+ const registered = session.commands.get(name);
14
+ if (registered) {
15
+ try {
16
+ const result = await registered.handler({
17
+ channel: 'discord',
18
+ sessionId: session.id,
19
+ args,
20
+ session,
21
+ });
22
+ if (result.kind === 'text')
23
+ return result.text;
24
+ if (result.kind === 'session-action') {
25
+ return cb.performSessionAction(result.action, result.notice);
26
+ }
27
+ if (result.kind === 'error')
28
+ return `error: ${result.message}`;
29
+ return `✓ /${name}`;
30
+ }
31
+ catch (err) {
32
+ return `command /${name} failed: ${err instanceof Error ? err.message : String(err)}`;
33
+ }
34
+ }
35
+ switch (name) {
36
+ case 'yolo': {
37
+ const enabled = cb.toggleYolo();
38
+ return enabled
39
+ ? '⚠ yolo mode ON — tool calls auto-approved for the rest of this session'
40
+ : 'yolo mode OFF — tool prompts will resume';
41
+ }
42
+ case 'voice':
43
+ return cb.voice(args);
44
+ case 'tools': {
45
+ const list = session.tools
46
+ .list()
47
+ .map((t) => `${t.name} — ${t.description}`)
48
+ .join('\n');
49
+ return list || '(no tools registered)';
50
+ }
51
+ case 'skills': {
52
+ const list = session.skills
53
+ .list()
54
+ .map((s) => `${s.frontmatter.name} — ${s.frontmatter.description}`)
55
+ .join('\n');
56
+ return list || '(no skills discovered)';
57
+ }
58
+ default:
59
+ return `unknown command: /${name} (try /help)`;
60
+ }
61
+ }
62
+ /** Discord application-command name constraint. */
63
+ const COMMAND_NAME_RE = /^[a-z0-9_-]{1,32}$/;
64
+ /**
65
+ * Build the application-command list to publish: the shared registry commands
66
+ * (parity with Telegram's `publishBotCommands`) plus the Discord-local ones.
67
+ * Names that don't fit Discord's `^[a-z0-9_-]{1,32}$` constraint are skipped
68
+ * (they remain reachable as plain-text `/name` messages); descriptions are
69
+ * clamped to Discord's 1..100 chars.
70
+ */
71
+ export function buildAppCommands(session) {
72
+ const LOCAL = [
73
+ { name: 'yolo', description: 'Toggle auto-approve mode' },
74
+ { name: 'voice', description: 'Toggle spoken voice replies' },
75
+ { name: 'tools', description: 'List the tools the active session can call' },
76
+ { name: 'skills', description: 'List the discovered skills' },
77
+ { name: 'cancel', description: 'Abort the current turn' },
78
+ { name: 'allow', description: 'Allow this guild channel to drive moxxy (paired user only)' },
79
+ { name: 'deny', description: 'Remove this guild channel from the allow-list' },
80
+ ];
81
+ const shared = session.commands
82
+ .listForChannel('discord')
83
+ .map((c) => ({ name: c.name, description: c.description }));
84
+ const seen = new Set(shared.map((c) => c.name));
85
+ return [...shared, ...LOCAL.filter((c) => !seen.has(c.name))]
86
+ .filter((c) => COMMAND_NAME_RE.test(c.name))
87
+ .sort((a, b) => a.name.localeCompare(b.name))
88
+ .map((c) => ({ name: c.name, description: (c.description || c.name).slice(0, 100) || c.name }));
89
+ }
90
+ /**
91
+ * Publish the commands to Discord so they appear in the client's "/" picker.
92
+ * Best-effort: a network failure here doesn't block channel startup — the
93
+ * commands still work as plain-text messages.
94
+ */
95
+ export async function publishAppCommands(publisher, session, logger) {
96
+ if (!publisher || !session)
97
+ return;
98
+ try {
99
+ await publisher.set(buildAppCommands(session));
100
+ }
101
+ catch (err) {
102
+ logger?.warn('discord commands.set failed', {
103
+ err: err instanceof Error ? err.message : String(err),
104
+ });
105
+ }
106
+ }
107
+ //# sourceMappingURL=slash-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slash-handler.js","sourceRoot":"","sources":["../../src/channel/slash-handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ import type { newTurnId } from '@moxxy/core';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import type { ChannelLogger, SendableChannelLike } from './discord-like.js';
4
+ import type { TypingIndicator } from './typing-indicator.js';
5
+ /**
6
+ * Discord's per-channel edit budget is ~5 edits / 5s; the streaming edit
7
+ * throttle must stay at or above 1200ms so a long turn never trips the rate
8
+ * limiter (which would delay-queue frames and lag the stream).
9
+ */
10
+ export declare const MIN_EDIT_FRAME_MS = 1200;
11
+ export declare const DEFAULT_EDIT_FRAME_MS = 1500;
12
+ /** Clamp a configured editFrameMs to the Discord-safe floor. */
13
+ export declare function clampEditFrameMs(requested: number | undefined): number;
14
+ export interface RunDiscordTurnDeps {
15
+ readonly session: Session;
16
+ readonly channel: SendableChannelLike;
17
+ readonly typing: TypingIndicator;
18
+ readonly editFrameMs: number;
19
+ readonly logger?: ChannelLogger;
20
+ /**
21
+ * Called once with the FINAL assistant text after it has been flushed to the
22
+ * channel (so the text reply always lands first). Backs the optional voice
23
+ * reply. Best-effort — its failure is logged and never breaks the text turn.
24
+ */
25
+ readonly onFinalReply?: (text: string) => Promise<void>;
26
+ }
27
+ export interface RunDiscordTurnOptions {
28
+ readonly text: string;
29
+ readonly model?: string | undefined;
30
+ readonly controller: AbortController;
31
+ /** Pre-minted turn id; the channel records it as an own-turn id. */
32
+ readonly turnId: ReturnType<typeof newTurnId>;
33
+ }
34
+ /**
35
+ * Drive a single Discord turn end-to-end: start typing, subscribe the frame
36
+ * pump to THIS turn's events (filtered by turnId — `session.log` fans out to
37
+ * every listener, so a concurrent turn on the same Session would otherwise
38
+ * stream into this channel, AGENTS.md invariant #8), run the turn, flush the
39
+ * final frame, unwind in `finally`.
40
+ *
41
+ * The streaming loop is `@moxxy/channel-kit`'s {@link FramePump} ("send once,
42
+ * then edit that message", throttled to `editFrameMs`) over a
43
+ * {@link DiscordTurnRenderer} snapshot. Delivery handles the 2000-char cap:
44
+ * mid-stream frames edit only the first split part; the FINAL frame sends the
45
+ * overflow tail parts as follow-up messages (`alwaysFlushFinal` so the sink's
46
+ * final-only work is never skipped).
47
+ */
48
+ export declare function runDiscordTurn(deps: RunDiscordTurnDeps, opts: RunDiscordTurnOptions): Promise<void>;
49
+ //# sourceMappingURL=turn-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-runner.d.ts","sourceRoot":"","sources":["../../src/channel/turn-runner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,aAAa,IAAI,OAAO,EAAE,MAAM,YAAY,CAAC;AAG3D,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAmB,EAAmB,MAAM,mBAAmB,CAAC;AAC7F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,OAAQ,CAAC;AACvC,eAAO,MAAM,qBAAqB,OAAQ,CAAC;AAE3C,gEAAgE;AAChE,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAGtE;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC;IACrC,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;CAC/C;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,kBAAkB,EACxB,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,IAAI,CAAC,CA8Ef"}
@@ -0,0 +1,116 @@
1
+ import { FramePump, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
2
+ import { DiscordTurnRenderer, splitForDiscord } from '../render.js';
3
+ /**
4
+ * Discord's per-channel edit budget is ~5 edits / 5s; the streaming edit
5
+ * throttle must stay at or above 1200ms so a long turn never trips the rate
6
+ * limiter (which would delay-queue frames and lag the stream).
7
+ */
8
+ export const MIN_EDIT_FRAME_MS = 1_200;
9
+ export const DEFAULT_EDIT_FRAME_MS = 1_500;
10
+ /** Clamp a configured editFrameMs to the Discord-safe floor. */
11
+ export function clampEditFrameMs(requested) {
12
+ if (typeof requested !== 'number' || !Number.isFinite(requested))
13
+ return DEFAULT_EDIT_FRAME_MS;
14
+ return Math.max(MIN_EDIT_FRAME_MS, requested);
15
+ }
16
+ /**
17
+ * Drive a single Discord turn end-to-end: start typing, subscribe the frame
18
+ * pump to THIS turn's events (filtered by turnId — `session.log` fans out to
19
+ * every listener, so a concurrent turn on the same Session would otherwise
20
+ * stream into this channel, AGENTS.md invariant #8), run the turn, flush the
21
+ * final frame, unwind in `finally`.
22
+ *
23
+ * The streaming loop is `@moxxy/channel-kit`'s {@link FramePump} ("send once,
24
+ * then edit that message", throttled to `editFrameMs`) over a
25
+ * {@link DiscordTurnRenderer} snapshot. Delivery handles the 2000-char cap:
26
+ * mid-stream frames edit only the first split part; the FINAL frame sends the
27
+ * overflow tail parts as follow-up messages (`alwaysFlushFinal` so the sink's
28
+ * final-only work is never skipped).
29
+ */
30
+ export async function runDiscordTurn(deps, opts) {
31
+ const { session, channel, typing, editFrameMs, logger, onFinalReply } = deps;
32
+ const { text, model, controller, turnId } = opts;
33
+ const renderer = new DiscordTurnRenderer();
34
+ const sendPart = async (part) => {
35
+ try {
36
+ return await channel.send(part);
37
+ }
38
+ catch (err) {
39
+ logger?.warn('discord send failed', { err: String(err) });
40
+ return null;
41
+ }
42
+ };
43
+ const pump = new FramePump({
44
+ editFrameMs,
45
+ frame: () => renderer.snapshot(),
46
+ // Guarantee at least one message even when the turn produced no text.
47
+ emptyFinalText: '*(no output)*',
48
+ // The sink does final-only work (split-overflow tails), so the final frame
49
+ // must reach it even when the text didn't change since the last edit.
50
+ alwaysFlushFinal: true,
51
+ sink: {
52
+ send: async (t, final) => {
53
+ const parts = splitForDiscord(t);
54
+ const sent = await sendPart(parts[0]);
55
+ if (final)
56
+ for (const tail of parts.slice(1))
57
+ await sendPart(tail);
58
+ return sent;
59
+ },
60
+ edit: async (message, t, final) => {
61
+ const parts = splitForDiscord(t);
62
+ try {
63
+ await message.edit(parts[0]);
64
+ }
65
+ catch (err) {
66
+ logger?.warn('discord edit failed', { err: String(err) });
67
+ }
68
+ if (final)
69
+ for (const tail of parts.slice(1))
70
+ await sendPart(tail);
71
+ },
72
+ },
73
+ });
74
+ typing.start(channel);
75
+ const unsubscribe = subscribeTurn(session, turnId, (event) => {
76
+ if (renderer.accept(event))
77
+ pump.scheduleEdit();
78
+ });
79
+ try {
80
+ await driveTurn(session, {
81
+ turnId,
82
+ prompt: text,
83
+ ...(model ? { model } : {}),
84
+ signal: controller.signal,
85
+ });
86
+ await pump.flush(true);
87
+ // The text reply is now out. Speak the final assistant body if a voice
88
+ // reply is wired — isolated so a synth/transcode/transport failure can
89
+ // never break (or re-report) the already-delivered text turn.
90
+ if (onFinalReply) {
91
+ const finalText = renderer.finalText();
92
+ if (finalText) {
93
+ try {
94
+ await onFinalReply(finalText);
95
+ }
96
+ catch (err) {
97
+ logger?.warn('discord voice reply hook failed', {
98
+ err: err instanceof Error ? err.message : String(err),
99
+ });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ catch (err) {
105
+ logger?.warn('discord turn failed', {
106
+ err: err instanceof Error ? err.message : String(err),
107
+ });
108
+ await sendPart(`Turn failed: ${err instanceof Error ? err.message : String(err)}`);
109
+ }
110
+ finally {
111
+ typing.stop();
112
+ unsubscribe();
113
+ pump.dispose();
114
+ }
115
+ }
116
+ //# sourceMappingURL=turn-runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-runner.js","sourceRoot":"","sources":["../../src/channel/turn-runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import type { SendableChannelLike } from './discord-like.js';
2
+ /**
3
+ * Keeps the "moxxy is typing…" indicator alive for the duration of a turn.
4
+ * Best-effort: transport errors are swallowed (a failed typing ping must never
5
+ * abort a turn).
6
+ */
7
+ export declare class TypingIndicator {
8
+ private timer;
9
+ start(channel: SendableChannelLike): void;
10
+ stop(): void;
11
+ }
12
+ //# sourceMappingURL=typing-indicator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typing-indicator.d.ts","sourceRoot":"","sources":["../../src/channel/typing-indicator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAK7D;;;;GAIG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,KAAK,CAA+C;IAE5D,KAAK,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI;IAWzC,IAAI,IAAI,IAAI;CAMb"}
@@ -0,0 +1,28 @@
1
+ /** Discord's typing indicator expires ~10s after `sendTyping`; refresh under that. */
2
+ const REFRESH_MS = 8_000;
3
+ /**
4
+ * Keeps the "moxxy is typing…" indicator alive for the duration of a turn.
5
+ * Best-effort: transport errors are swallowed (a failed typing ping must never
6
+ * abort a turn).
7
+ */
8
+ export class TypingIndicator {
9
+ timer = null;
10
+ start(channel) {
11
+ this.stop();
12
+ if (!channel.sendTyping)
13
+ return;
14
+ const ping = () => {
15
+ void channel.sendTyping?.()?.catch?.(() => undefined);
16
+ };
17
+ ping();
18
+ this.timer = setInterval(ping, REFRESH_MS);
19
+ this.timer.unref?.();
20
+ }
21
+ stop() {
22
+ if (this.timer) {
23
+ clearInterval(this.timer);
24
+ this.timer = null;
25
+ }
26
+ }
27
+ }
28
+ //# sourceMappingURL=typing-indicator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typing-indicator.js","sourceRoot":"","sources":["../../src/channel/typing-indicator.ts"],"names":[],"mappings":"AAEA,sFAAsF;AACtF,MAAM,UAAU,GAAG,KAAK,CAAC;AAEzB;;;;GAIG;AACH,MAAM,OAAO,eAAe;IAClB,KAAK,GAA0C,IAAI,CAAC;IAE5D,KAAK,CAAC,OAA4B;QAChC,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,IAAI,CAAC,OAAO,CAAC,UAAU;YAAE,OAAO;QAChC,MAAM,IAAI,GAAG,GAAS,EAAE;YACtB,KAAK,OAAO,CAAC,UAAU,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACxD,CAAC,CAAC;QACF,IAAI,EAAE,CAAC;QACP,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IACvB,CAAC;IAED,IAAI;QACF,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACf,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC1B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QACpB,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,41 @@
1
+ import type { ClientSession as Session } from '@moxxy/sdk';
2
+ import { type InboundAttachment } from '../schema.js';
3
+ import type { ChannelLogger } from './discord-like.js';
4
+ import type { InboundContext } from './message-handler.js';
5
+ export interface VoiceHandlerState {
6
+ readonly session: Session | null;
7
+ readonly busy: boolean;
8
+ }
9
+ export interface VoiceHandlerDeps {
10
+ readonly logger?: ChannelLogger;
11
+ }
12
+ export interface VoiceHandlerCallbacks {
13
+ readonly runUserTurn: (ctx: InboundContext, text: string) => Promise<void>;
14
+ /** Override the network fetch for tests. Defaults to global `fetch`. */
15
+ readonly fetchAudio?: (url: string, init?: {
16
+ signal?: AbortSignal;
17
+ }) => Promise<{
18
+ ok: boolean;
19
+ status?: number;
20
+ statusText?: string;
21
+ headers?: {
22
+ get(name: string): string | null;
23
+ };
24
+ arrayBuffer(): Promise<ArrayBuffer>;
25
+ }>;
26
+ }
27
+ /** The first audio attachment on the message (Discord voice messages arrive as
28
+ * an `audio/ogg` attachment; uploaded audio files carry their own mime). */
29
+ export declare function pickAudioAttachment(attachments: ReadonlyArray<InboundAttachment>): InboundAttachment | null;
30
+ /**
31
+ * Handle a Discord voice message / uploaded audio file. Called AFTER the
32
+ * pairing + allow-list gate (the message handler owns authorization). Returns
33
+ * true when the message carried audio and was consumed here (successfully or
34
+ * not), false when there was no audio and the text path should proceed.
35
+ *
36
+ * Mirrors the Telegram voice handler: size caps (declared size, then
37
+ * Content-Length, then the buffered body), bounded download, transcriber gate
38
+ * with install guidance, a "heard:" echo, then a normal user turn.
39
+ */
40
+ export declare function handleVoiceMessage(ctx: InboundContext, state: VoiceHandlerState, deps: VoiceHandlerDeps, cb: VoiceHandlerCallbacks): Promise<boolean>;
41
+ //# sourceMappingURL=voice-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice-handler.d.ts","sourceRoot":"","sources":["../../src/channel/voice-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,IAAI,OAAO,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAK3D,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;CACjC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,WAAW,EAAE,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,wEAAwE;IACxE,QAAQ,CAAC,UAAU,CAAC,EAAE,CACpB,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAC5B,OAAO,CAAC;QACX,EAAE,EAAE,OAAO,CAAC;QACZ,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,OAAO,CAAC,EAAE;YAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;SAAE,CAAC;QAC/C,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;KACrC,CAAC,CAAC;CACJ;AAED;6EAC6E;AAC7E,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,aAAa,CAAC,iBAAiB,CAAC,GAC5C,iBAAiB,GAAG,IAAI,CAE1B;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,cAAc,EACnB,KAAK,EAAE,iBAAiB,EACxB,IAAI,EAAE,gBAAgB,EACtB,EAAE,EAAE,qBAAqB,GACxB,OAAO,CAAC,OAAO,CAAC,CA8FlB"}
@@ -0,0 +1,105 @@
1
+ import { MAX_AUDIO_BYTES } from '../schema.js';
2
+ /** Abort the attachment download if Discord's CDN hasn't responded in this window. */
3
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
4
+ /** The first audio attachment on the message (Discord voice messages arrive as
5
+ * an `audio/ogg` attachment; uploaded audio files carry their own mime). */
6
+ export function pickAudioAttachment(attachments) {
7
+ return attachments.find((a) => (a.contentType ?? '').startsWith('audio/')) ?? null;
8
+ }
9
+ /**
10
+ * Handle a Discord voice message / uploaded audio file. Called AFTER the
11
+ * pairing + allow-list gate (the message handler owns authorization). Returns
12
+ * true when the message carried audio and was consumed here (successfully or
13
+ * not), false when there was no audio and the text path should proceed.
14
+ *
15
+ * Mirrors the Telegram voice handler: size caps (declared size, then
16
+ * Content-Length, then the buffered body), bounded download, transcriber gate
17
+ * with install guidance, a "heard:" echo, then a normal user turn.
18
+ */
19
+ export async function handleVoiceMessage(ctx, state, deps, cb) {
20
+ const audio = pickAudioAttachment(ctx.msg.attachments);
21
+ if (!audio)
22
+ return false;
23
+ if (state.busy) {
24
+ await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
25
+ return true;
26
+ }
27
+ if (!state.session) {
28
+ await ctx.reply('Session is not ready yet.');
29
+ return true;
30
+ }
31
+ const transcriber = state.session.transcribers.tryGetActive();
32
+ if (!transcriber) {
33
+ await ctx.reply('Heard a voice message, but no speech-to-text backend is configured. Install @moxxy/plugin-stt-whisper and run `moxxy login openai` (or set OPENAI_API_KEY) to enable voice input.');
34
+ return true;
35
+ }
36
+ // Reject oversized uploads up-front using the size Discord reports, before
37
+ // we spend a download or any memory on it.
38
+ if (audio.size > MAX_AUDIO_BYTES) {
39
+ await ctx.reply(`That audio is too large (${Math.round(audio.size / (1024 * 1024))}MB). The limit is ${MAX_AUDIO_BYTES / (1024 * 1024)}MB.`);
40
+ return true;
41
+ }
42
+ const fetcher = cb.fetchAudio ?? ((u, init) => fetch(u, init));
43
+ const ac = new AbortController();
44
+ const timer = setTimeout(() => ac.abort(), DOWNLOAD_TIMEOUT_MS);
45
+ let bytes;
46
+ try {
47
+ const response = await fetcher(audio.url, { signal: ac.signal });
48
+ if (!response.ok) {
49
+ deps.logger?.warn('discord voice download failed', {
50
+ ...(response.status !== undefined ? { status: response.status } : {}),
51
+ ...(response.statusText ? { statusText: response.statusText } : {}),
52
+ });
53
+ await ctx.reply('Failed to download the voice message from Discord.');
54
+ return true;
55
+ }
56
+ // Trust the Content-Length header (when present) to bail before buffering
57
+ // a body that lies about its size on the attachment object.
58
+ const declaredLen = Number(response.headers?.get('content-length') ?? '');
59
+ if (Number.isFinite(declaredLen) && declaredLen > MAX_AUDIO_BYTES) {
60
+ await ctx.reply('That audio is too large to transcribe.');
61
+ return true;
62
+ }
63
+ const buf = await response.arrayBuffer();
64
+ if (buf.byteLength > MAX_AUDIO_BYTES) {
65
+ await ctx.reply('That audio is too large to transcribe.');
66
+ return true;
67
+ }
68
+ bytes = new Uint8Array(buf);
69
+ }
70
+ catch (err) {
71
+ deps.logger?.warn('discord voice fetch failed', {
72
+ err: err instanceof Error ? err.message : String(err),
73
+ });
74
+ await ctx.reply(ac.signal.aborted
75
+ ? 'Downloading the voice message timed out.'
76
+ : 'Failed to download the voice message from Discord.');
77
+ return true;
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ }
82
+ const mimeType = audio.contentType ?? 'audio/ogg';
83
+ let transcript;
84
+ try {
85
+ const result = await transcriber.transcribe(bytes, { mimeType });
86
+ transcript = result.text.trim();
87
+ }
88
+ catch (err) {
89
+ deps.logger?.warn('discord voice transcription failed', {
90
+ err: err instanceof Error ? err.message : String(err),
91
+ });
92
+ await ctx.reply(`Transcription failed: ${err instanceof Error ? err.message : String(err)}`);
93
+ return true;
94
+ }
95
+ if (!transcript) {
96
+ await ctx.reply('Could not transcribe the voice message (got empty text).');
97
+ return true;
98
+ }
99
+ // Echo what we heard so the user can spot misrecognitions before the agent
100
+ // acts on it. Italics keep it visually distinct from a normal reply.
101
+ await ctx.reply(`*heard:* ${transcript}`);
102
+ await cb.runUserTurn(ctx, transcript);
103
+ return true;
104
+ }
105
+ //# sourceMappingURL=voice-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice-handler.js","sourceRoot":"","sources":["../../src/channel/voice-handler.ts"],"names":[],"mappings":"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"}