@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,136 @@
1
+ import type { ClientSession as Session } from '@moxxy/sdk';
2
+ import type { ChannelLogger } from './discord-like.js';
3
+
4
+ export interface SlashCallbacks {
5
+ /** Toggle yolo and return its new value (so we can echo the right message). */
6
+ toggleYolo(): boolean;
7
+ /** Handle `/voice [on|off|status]` — persist + apply, return the reply text. */
8
+ voice(arg: string): Promise<string>;
9
+ /** Apply a `session-action` result emitted from a registered command. */
10
+ performSessionAction(action: 'new' | 'clear' | 'exit', notice: string | undefined): Promise<string>;
11
+ }
12
+
13
+ /**
14
+ * Slash-command dispatcher shared by the plain-text path (`/info` typed as a
15
+ * message) and the application-command path (a real Discord slash command —
16
+ * the interaction handler routes both here). Returns the reply text to send.
17
+ *
18
+ * First tries the shared `session.commands` registry — the universal commands
19
+ * (/info, /clear, /new, /exit, /help) plus plugin-contributed ones — then
20
+ * falls through to Discord-local cases (/yolo, /tools, /skills). /cancel and
21
+ * /allow, /deny are handled by the message/interaction layer (they need channel
22
+ * state this dispatcher doesn't carry).
23
+ */
24
+ export async function runSlash(
25
+ name: string,
26
+ args: string,
27
+ session: Session,
28
+ cb: SlashCallbacks,
29
+ ): Promise<string> {
30
+ const registered = session.commands.get(name);
31
+ if (registered) {
32
+ try {
33
+ const result = await registered.handler({
34
+ channel: 'discord',
35
+ sessionId: session.id,
36
+ args,
37
+ session,
38
+ });
39
+ if (result.kind === 'text') return result.text;
40
+ if (result.kind === 'session-action') {
41
+ return cb.performSessionAction(result.action, result.notice);
42
+ }
43
+ if (result.kind === 'error') return `error: ${result.message}`;
44
+ return `✓ /${name}`;
45
+ } catch (err) {
46
+ return `command /${name} failed: ${err instanceof Error ? err.message : String(err)}`;
47
+ }
48
+ }
49
+
50
+ switch (name) {
51
+ case 'yolo': {
52
+ const enabled = cb.toggleYolo();
53
+ return enabled
54
+ ? '⚠ yolo mode ON — tool calls auto-approved for the rest of this session'
55
+ : 'yolo mode OFF — tool prompts will resume';
56
+ }
57
+ case 'voice':
58
+ return cb.voice(args);
59
+ case 'tools': {
60
+ const list = session.tools
61
+ .list()
62
+ .map((t) => `${t.name} — ${t.description}`)
63
+ .join('\n');
64
+ return list || '(no tools registered)';
65
+ }
66
+ case 'skills': {
67
+ const list = session.skills
68
+ .list()
69
+ .map((s) => `${s.frontmatter.name} — ${s.frontmatter.description}`)
70
+ .join('\n');
71
+ return list || '(no skills discovered)';
72
+ }
73
+ default:
74
+ return `unknown command: /${name} (try /help)`;
75
+ }
76
+ }
77
+
78
+ /** Discord application-command name constraint. */
79
+ const COMMAND_NAME_RE = /^[a-z0-9_-]{1,32}$/;
80
+
81
+ export interface AppCommandJson {
82
+ readonly name: string;
83
+ readonly description: string;
84
+ }
85
+
86
+ /**
87
+ * Build the application-command list to publish: the shared registry commands
88
+ * (parity with Telegram's `publishBotCommands`) plus the Discord-local ones.
89
+ * Names that don't fit Discord's `^[a-z0-9_-]{1,32}$` constraint are skipped
90
+ * (they remain reachable as plain-text `/name` messages); descriptions are
91
+ * clamped to Discord's 1..100 chars.
92
+ */
93
+ export function buildAppCommands(session: Session): AppCommandJson[] {
94
+ const LOCAL: AppCommandJson[] = [
95
+ { name: 'yolo', description: 'Toggle auto-approve mode' },
96
+ { name: 'voice', description: 'Toggle spoken voice replies' },
97
+ { name: 'tools', description: 'List the tools the active session can call' },
98
+ { name: 'skills', description: 'List the discovered skills' },
99
+ { name: 'cancel', description: 'Abort the current turn' },
100
+ { name: 'allow', description: 'Allow this guild channel to drive moxxy (paired user only)' },
101
+ { name: 'deny', description: 'Remove this guild channel from the allow-list' },
102
+ ];
103
+ const shared = session.commands
104
+ .listForChannel('discord')
105
+ .map((c) => ({ name: c.name, description: c.description }));
106
+ const seen = new Set(shared.map((c) => c.name));
107
+ return [...shared, ...LOCAL.filter((c) => !seen.has(c.name))]
108
+ .filter((c) => COMMAND_NAME_RE.test(c.name))
109
+ .sort((a, b) => a.name.localeCompare(b.name))
110
+ .map((c) => ({ name: c.name, description: (c.description || c.name).slice(0, 100) || c.name }));
111
+ }
112
+
113
+ /** The slice of a discord.js application-command manager we publish through. */
114
+ export interface AppCommandPublisher {
115
+ set(commands: ReadonlyArray<AppCommandJson>): Promise<unknown>;
116
+ }
117
+
118
+ /**
119
+ * Publish the commands to Discord so they appear in the client's "/" picker.
120
+ * Best-effort: a network failure here doesn't block channel startup — the
121
+ * commands still work as plain-text messages.
122
+ */
123
+ export async function publishAppCommands(
124
+ publisher: AppCommandPublisher | null,
125
+ session: Session | null,
126
+ logger?: ChannelLogger,
127
+ ): Promise<void> {
128
+ if (!publisher || !session) return;
129
+ try {
130
+ await publisher.set(buildAppCommands(session));
131
+ } catch (err) {
132
+ logger?.warn('discord commands.set failed', {
133
+ err: err instanceof Error ? err.message : String(err),
134
+ });
135
+ }
136
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asTurnId, type MoxxyEvent } from '@moxxy/sdk';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import type { SendableChannelLike, SentMessageLike } from './discord-like.js';
5
+ import { TypingIndicator } from './typing-indicator.js';
6
+ import {
7
+ clampEditFrameMs,
8
+ DEFAULT_EDIT_FRAME_MS,
9
+ MIN_EDIT_FRAME_MS,
10
+ runDiscordTurn,
11
+ } from './turn-runner.js';
12
+
13
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
14
+
15
+ describe('clampEditFrameMs (Discord edit rate limit ~5 edits/5s)', () => {
16
+ it('defaults when unset and floors configured values at 1200ms', () => {
17
+ expect(clampEditFrameMs(undefined)).toBe(DEFAULT_EDIT_FRAME_MS);
18
+ expect(DEFAULT_EDIT_FRAME_MS).toBeGreaterThanOrEqual(1_200);
19
+ expect(clampEditFrameMs(50)).toBe(MIN_EDIT_FRAME_MS);
20
+ expect(MIN_EDIT_FRAME_MS).toBeGreaterThanOrEqual(1_200);
21
+ expect(clampEditFrameMs(5_000)).toBe(5_000);
22
+ expect(clampEditFrameMs(Number.NaN)).toBe(DEFAULT_EDIT_FRAME_MS);
23
+ });
24
+ });
25
+
26
+ interface Recorded {
27
+ sends: string[];
28
+ edits: string[];
29
+ channel: SendableChannelLike;
30
+ }
31
+
32
+ function recordedChannel(): Recorded {
33
+ const sends: string[] = [];
34
+ const edits: string[] = [];
35
+ const channel: SendableChannelLike = {
36
+ send: async (payload) => {
37
+ sends.push(typeof payload === 'string' ? payload : payload.content);
38
+ const message: SentMessageLike = {
39
+ edit: async (content: string) => {
40
+ edits.push(content);
41
+ },
42
+ };
43
+ return message;
44
+ },
45
+ };
46
+ return { sends, edits, channel };
47
+ }
48
+
49
+ /**
50
+ * Fake session: `runTurn` pushes scripted events (stamped with the given
51
+ * turnId) through the log subscribers, mirroring how the real Session fans
52
+ * out; the async iterable then completes.
53
+ */
54
+ function fakeSession(
55
+ script: (emit: (e: Record<string, unknown>) => Promise<void>) => Promise<void>,
56
+ ): Session {
57
+ const listeners = new Set<(e: MoxxyEvent) => void>();
58
+ return {
59
+ log: {
60
+ subscribe(fn: (e: MoxxyEvent) => void) {
61
+ listeners.add(fn);
62
+ return () => listeners.delete(fn);
63
+ },
64
+ },
65
+ runTurn: (_prompt: string, opts: { turnId: string }) =>
66
+ (async function* () {
67
+ const emit = async (e: Record<string, unknown>): Promise<void> => {
68
+ const event = { ...e, turnId: opts.turnId } as unknown as MoxxyEvent;
69
+ for (const fn of listeners) fn(event);
70
+ };
71
+ await script(emit);
72
+ // The iterator yields nothing extra — rendering happens via the log.
73
+ yield undefined as never;
74
+ })(),
75
+ } as unknown as Session;
76
+ }
77
+
78
+ describe('runDiscordTurn — send-once-then-edit streaming', () => {
79
+ it('sends the first frame, edits it as chunks stream, edits the final in place', async () => {
80
+ const { sends, edits, channel } = recordedChannel();
81
+ const session = fakeSession(async (emit) => {
82
+ await emit({ type: 'assistant_chunk', delta: 'Hello' });
83
+ await sleep(30);
84
+ await emit({ type: 'assistant_chunk', delta: ', world' });
85
+ await sleep(30);
86
+ await emit({ type: 'assistant_message', content: 'Hello, world. Done.' });
87
+ });
88
+ await runDiscordTurn(
89
+ { session, channel, typing: new TypingIndicator(), editFrameMs: 10 },
90
+ { text: 'hi', controller: new AbortController(), turnId: asTurnId('turn-1') },
91
+ );
92
+ // Exactly one streamed message; later frames are edits of it.
93
+ expect(sends).toHaveLength(1);
94
+ expect(sends[0]!.startsWith('Hello')).toBe(true);
95
+ expect(edits.length).toBeGreaterThanOrEqual(1);
96
+ expect(edits[edits.length - 1]).toBe('Hello, world. Done.');
97
+ });
98
+
99
+ it('splits a long FINAL frame: edits the head, sends the tails as follow-ups', async () => {
100
+ const { sends, edits, channel } = recordedChannel();
101
+ const line = 'y'.repeat(100);
102
+ const long = Array.from({ length: 45 }, () => line).join('\n'); // ~4545 chars > 1900*2
103
+ const session = fakeSession(async (emit) => {
104
+ await emit({ type: 'assistant_chunk', delta: 'start' });
105
+ await sleep(30);
106
+ await emit({ type: 'assistant_message', content: long });
107
+ });
108
+ await runDiscordTurn(
109
+ { session, channel, typing: new TypingIndicator(), editFrameMs: 10 },
110
+ { text: 'hi', controller: new AbortController(), turnId: asTurnId('turn-2') },
111
+ );
112
+ // First send is the streamed head; final tails arrive as extra sends.
113
+ expect(sends.length).toBeGreaterThanOrEqual(3); // head + >=2 tails
114
+ const finalHead = edits[edits.length - 1]!;
115
+ expect(finalHead.length).toBeLessThanOrEqual(1_900);
116
+ // Reassembling head + tails recovers the whole final text.
117
+ const reassembled = [finalHead, ...sends.slice(1)].join('\n');
118
+ expect(reassembled).toBe(long);
119
+ });
120
+
121
+ it('posts a placeholder when the turn rendered nothing', async () => {
122
+ const { sends, channel } = recordedChannel();
123
+ const session = fakeSession(async () => undefined);
124
+ await runDiscordTurn(
125
+ { session, channel, typing: new TypingIndicator(), editFrameMs: 10 },
126
+ { text: 'hi', controller: new AbortController(), turnId: asTurnId('turn-3') },
127
+ );
128
+ expect(sends).toEqual(['*(no output)*']);
129
+ });
130
+
131
+ it('ignores events from OTHER turns (invariant #8: filter by turnId)', async () => {
132
+ const { sends, edits, channel } = recordedChannel();
133
+ const listeners = new Set<(e: MoxxyEvent) => void>();
134
+ const session = {
135
+ log: {
136
+ subscribe(fn: (e: MoxxyEvent) => void) {
137
+ listeners.add(fn);
138
+ return () => listeners.delete(fn);
139
+ },
140
+ },
141
+ runTurn: (_prompt: string, opts: { turnId: string }) =>
142
+ (async function* () {
143
+ const fan = (e: Record<string, unknown>): void => {
144
+ for (const fn of listeners) fn(e as unknown as MoxxyEvent);
145
+ };
146
+ // A concurrent foreign turn's chunk must NOT render here.
147
+ fan({ type: 'assistant_chunk', delta: 'FOREIGN', turnId: 'other-turn' });
148
+ fan({ type: 'assistant_message', content: 'mine', turnId: opts.turnId });
149
+ yield undefined as never;
150
+ })(),
151
+ } as unknown as Session;
152
+ await runDiscordTurn(
153
+ { session, channel, typing: new TypingIndicator(), editFrameMs: 10 },
154
+ { text: 'hi', controller: new AbortController(), turnId: asTurnId('turn-4') },
155
+ );
156
+ const all = [...sends, ...edits].join(' ');
157
+ expect(all).toContain('mine');
158
+ expect(all).not.toContain('FOREIGN');
159
+ });
160
+
161
+ it('surfaces a failed turn into the channel instead of dangling', async () => {
162
+ const { sends, channel } = recordedChannel();
163
+ const session = {
164
+ log: { subscribe: () => () => undefined },
165
+ runTurn: () =>
166
+ (async function* () {
167
+ yield undefined as never;
168
+ throw new Error('provider exploded');
169
+ })(),
170
+ } as unknown as Session;
171
+ await runDiscordTurn(
172
+ { session, channel, typing: new TypingIndicator(), editFrameMs: 10 },
173
+ { text: 'hi', controller: new AbortController(), turnId: asTurnId('turn-5') },
174
+ );
175
+ expect(sends.join(' ')).toMatch(/Turn failed: provider exploded/);
176
+ });
177
+ });
@@ -0,0 +1,139 @@
1
+ import type { newTurnId } from '@moxxy/core';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import { FramePump, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
4
+ import { DiscordTurnRenderer, splitForDiscord } from '../render.js';
5
+ import type { ChannelLogger, SendableChannelLike, SentMessageLike } from './discord-like.js';
6
+ import type { TypingIndicator } from './typing-indicator.js';
7
+
8
+ /**
9
+ * Discord's per-channel edit budget is ~5 edits / 5s; the streaming edit
10
+ * throttle must stay at or above 1200ms so a long turn never trips the rate
11
+ * limiter (which would delay-queue frames and lag the stream).
12
+ */
13
+ export const MIN_EDIT_FRAME_MS = 1_200;
14
+ export const DEFAULT_EDIT_FRAME_MS = 1_500;
15
+
16
+ /** Clamp a configured editFrameMs to the Discord-safe floor. */
17
+ export function clampEditFrameMs(requested: number | undefined): number {
18
+ if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_EDIT_FRAME_MS;
19
+ return Math.max(MIN_EDIT_FRAME_MS, requested);
20
+ }
21
+
22
+ export interface RunDiscordTurnDeps {
23
+ readonly session: Session;
24
+ readonly channel: SendableChannelLike;
25
+ readonly typing: TypingIndicator;
26
+ readonly editFrameMs: number;
27
+ readonly logger?: ChannelLogger;
28
+ /**
29
+ * Called once with the FINAL assistant text after it has been flushed to the
30
+ * channel (so the text reply always lands first). Backs the optional voice
31
+ * reply. Best-effort — its failure is logged and never breaks the text turn.
32
+ */
33
+ readonly onFinalReply?: (text: string) => Promise<void>;
34
+ }
35
+
36
+ export interface RunDiscordTurnOptions {
37
+ readonly text: string;
38
+ readonly model?: string | undefined;
39
+ readonly controller: AbortController;
40
+ /** Pre-minted turn id; the channel records it as an own-turn id. */
41
+ readonly turnId: ReturnType<typeof newTurnId>;
42
+ }
43
+
44
+ /**
45
+ * Drive a single Discord turn end-to-end: start typing, subscribe the frame
46
+ * pump to THIS turn's events (filtered by turnId — `session.log` fans out to
47
+ * every listener, so a concurrent turn on the same Session would otherwise
48
+ * stream into this channel, AGENTS.md invariant #8), run the turn, flush the
49
+ * final frame, unwind in `finally`.
50
+ *
51
+ * The streaming loop is `@moxxy/channel-kit`'s {@link FramePump} ("send once,
52
+ * then edit that message", throttled to `editFrameMs`) over a
53
+ * {@link DiscordTurnRenderer} snapshot. Delivery handles the 2000-char cap:
54
+ * mid-stream frames edit only the first split part; the FINAL frame sends the
55
+ * overflow tail parts as follow-up messages (`alwaysFlushFinal` so the sink's
56
+ * final-only work is never skipped).
57
+ */
58
+ export async function runDiscordTurn(
59
+ deps: RunDiscordTurnDeps,
60
+ opts: RunDiscordTurnOptions,
61
+ ): Promise<void> {
62
+ const { session, channel, typing, editFrameMs, logger, onFinalReply } = deps;
63
+ const { text, model, controller, turnId } = opts;
64
+
65
+ const renderer = new DiscordTurnRenderer();
66
+ const sendPart = async (part: string): Promise<SentMessageLike | null> => {
67
+ try {
68
+ return await channel.send(part);
69
+ } catch (err) {
70
+ logger?.warn('discord send failed', { err: String(err) });
71
+ return null;
72
+ }
73
+ };
74
+ const pump = new FramePump<SentMessageLike>({
75
+ editFrameMs,
76
+ frame: () => renderer.snapshot(),
77
+ // Guarantee at least one message even when the turn produced no text.
78
+ emptyFinalText: '*(no output)*',
79
+ // The sink does final-only work (split-overflow tails), so the final frame
80
+ // must reach it even when the text didn't change since the last edit.
81
+ alwaysFlushFinal: true,
82
+ sink: {
83
+ send: async (t, final) => {
84
+ const parts = splitForDiscord(t);
85
+ const sent = await sendPart(parts[0]!);
86
+ if (final) for (const tail of parts.slice(1)) await sendPart(tail);
87
+ return sent;
88
+ },
89
+ edit: async (message, t, final) => {
90
+ const parts = splitForDiscord(t);
91
+ try {
92
+ await message.edit(parts[0]!);
93
+ } catch (err) {
94
+ logger?.warn('discord edit failed', { err: String(err) });
95
+ }
96
+ if (final) for (const tail of parts.slice(1)) await sendPart(tail);
97
+ },
98
+ },
99
+ });
100
+
101
+ typing.start(channel);
102
+ const unsubscribe = subscribeTurn(session, turnId, (event) => {
103
+ if (renderer.accept(event)) pump.scheduleEdit();
104
+ });
105
+
106
+ try {
107
+ await driveTurn(session, {
108
+ turnId,
109
+ prompt: text,
110
+ ...(model ? { model } : {}),
111
+ signal: controller.signal,
112
+ });
113
+ await pump.flush(true);
114
+ // The text reply is now out. Speak the final assistant body if a voice
115
+ // reply is wired — isolated so a synth/transcode/transport failure can
116
+ // never break (or re-report) the already-delivered text turn.
117
+ if (onFinalReply) {
118
+ const finalText = renderer.finalText();
119
+ if (finalText) {
120
+ try {
121
+ await onFinalReply(finalText);
122
+ } catch (err) {
123
+ logger?.warn('discord voice reply hook failed', {
124
+ err: err instanceof Error ? err.message : String(err),
125
+ });
126
+ }
127
+ }
128
+ }
129
+ } catch (err) {
130
+ logger?.warn('discord turn failed', {
131
+ err: err instanceof Error ? err.message : String(err),
132
+ });
133
+ await sendPart(`Turn failed: ${err instanceof Error ? err.message : String(err)}`);
134
+ } finally {
135
+ typing.stop();
136
+ unsubscribe();
137
+ pump.dispose();
138
+ }
139
+ }
@@ -0,0 +1,31 @@
1
+ import type { SendableChannelLike } from './discord-like.js';
2
+
3
+ /** Discord's typing indicator expires ~10s after `sendTyping`; refresh under that. */
4
+ const REFRESH_MS = 8_000;
5
+
6
+ /**
7
+ * Keeps the "moxxy is typing…" indicator alive for the duration of a turn.
8
+ * Best-effort: transport errors are swallowed (a failed typing ping must never
9
+ * abort a turn).
10
+ */
11
+ export class TypingIndicator {
12
+ private timer: ReturnType<typeof setInterval> | null = null;
13
+
14
+ start(channel: SendableChannelLike): void {
15
+ this.stop();
16
+ if (!channel.sendTyping) return;
17
+ const ping = (): void => {
18
+ void channel.sendTyping?.()?.catch?.(() => undefined);
19
+ };
20
+ ping();
21
+ this.timer = setInterval(ping, REFRESH_MS);
22
+ this.timer.unref?.();
23
+ }
24
+
25
+ stop(): void {
26
+ if (this.timer) {
27
+ clearInterval(this.timer);
28
+ this.timer = null;
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import type { InboundMessage } from '../schema.js';
4
+ import { MAX_AUDIO_BYTES } from '../schema.js';
5
+ import type { InboundContext } from './message-handler.js';
6
+ import { handleVoiceMessage, pickAudioAttachment } from './voice-handler.js';
7
+
8
+ function audioMsg(overrides: Partial<InboundMessage['attachments'][number]> = {}): InboundMessage {
9
+ return {
10
+ id: '999999999999',
11
+ content: '',
12
+ channelId: '555555555555',
13
+ guildId: null,
14
+ authorId: '111111111111',
15
+ authorIsBot: false,
16
+ attachments: [
17
+ {
18
+ id: '888888888888',
19
+ url: 'https://cdn.example/voice.ogg',
20
+ contentType: 'audio/ogg',
21
+ size: 1_000,
22
+ name: 'voice-message.ogg',
23
+ ...overrides,
24
+ },
25
+ ],
26
+ };
27
+ }
28
+
29
+ function makeCtx(msg: InboundMessage): { ctx: InboundContext; replies: string[] } {
30
+ const replies: string[] = [];
31
+ return {
32
+ replies,
33
+ ctx: {
34
+ msg,
35
+ channel: { send: async () => ({ edit: async () => undefined }) },
36
+ reply: async (text: string) => {
37
+ replies.push(text);
38
+ },
39
+ },
40
+ };
41
+ }
42
+
43
+ function sessionWithTranscriber(transcribe: ((bytes: Uint8Array, o: { mimeType: string }) => Promise<{ text: string }>) | null): Session {
44
+ return {
45
+ transcribers: {
46
+ tryGetActive: () => (transcribe ? { transcribe } : null),
47
+ },
48
+ } as unknown as Session;
49
+ }
50
+
51
+ const okFetch = (bytes = 16) =>
52
+ vi.fn(async () => ({
53
+ ok: true,
54
+ headers: { get: () => String(bytes) },
55
+ arrayBuffer: async () => new ArrayBuffer(bytes),
56
+ }));
57
+
58
+ describe('pickAudioAttachment', () => {
59
+ it('picks the first audio/* attachment and ignores others', () => {
60
+ const msg = audioMsg();
61
+ expect(pickAudioAttachment(msg.attachments)?.contentType).toBe('audio/ogg');
62
+ expect(pickAudioAttachment([{ ...msg.attachments[0]!, contentType: 'image/png' }])).toBeNull();
63
+ expect(pickAudioAttachment([{ ...msg.attachments[0]!, contentType: null }])).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('handleVoiceMessage', () => {
68
+ it('returns false for messages without audio (text path proceeds)', async () => {
69
+ const { ctx } = makeCtx({ ...audioMsg(), attachments: [] });
70
+ const consumed = await handleVoiceMessage(
71
+ ctx,
72
+ { session: sessionWithTranscriber(null), busy: false },
73
+ {},
74
+ { runUserTurn: vi.fn() },
75
+ );
76
+ expect(consumed).toBe(false);
77
+ });
78
+
79
+ it('guides the user when no transcriber is configured', async () => {
80
+ const { ctx, replies } = makeCtx(audioMsg());
81
+ const runUserTurn = vi.fn();
82
+ const consumed = await handleVoiceMessage(
83
+ ctx,
84
+ { session: sessionWithTranscriber(null), busy: false },
85
+ {},
86
+ { runUserTurn },
87
+ );
88
+ expect(consumed).toBe(true);
89
+ expect(replies[0]).toMatch(/plugin-stt-whisper/);
90
+ expect(runUserTurn).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('rejects a declared-oversized attachment BEFORE downloading', async () => {
94
+ const fetchAudio = okFetch();
95
+ const { ctx, replies } = makeCtx(audioMsg({ size: MAX_AUDIO_BYTES + 1 }));
96
+ await handleVoiceMessage(
97
+ ctx,
98
+ { session: sessionWithTranscriber(async () => ({ text: 'x' })), busy: false },
99
+ {},
100
+ { runUserTurn: vi.fn(), fetchAudio },
101
+ );
102
+ expect(replies[0]).toMatch(/too large/);
103
+ expect(fetchAudio).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('rejects on a lying Content-Length header before buffering', async () => {
107
+ const fetchAudio = vi.fn(async () => ({
108
+ ok: true,
109
+ headers: { get: () => String(MAX_AUDIO_BYTES + 1) },
110
+ arrayBuffer: async () => new ArrayBuffer(8),
111
+ }));
112
+ const { ctx, replies } = makeCtx(audioMsg());
113
+ await handleVoiceMessage(
114
+ ctx,
115
+ { session: sessionWithTranscriber(async () => ({ text: 'x' })), busy: false },
116
+ {},
117
+ { runUserTurn: vi.fn(), fetchAudio },
118
+ );
119
+ expect(replies[0]).toMatch(/too large/);
120
+ });
121
+
122
+ it('rejects an oversized BUFFERED body (no content-length)', async () => {
123
+ const fetchAudio = vi.fn(async () => ({
124
+ ok: true,
125
+ headers: { get: () => null },
126
+ arrayBuffer: async () => new ArrayBuffer(MAX_AUDIO_BYTES + 1),
127
+ }));
128
+ const { ctx, replies } = makeCtx(audioMsg());
129
+ await handleVoiceMessage(
130
+ ctx,
131
+ { session: sessionWithTranscriber(async () => ({ text: 'x' })), busy: false },
132
+ {},
133
+ { runUserTurn: vi.fn(), fetchAudio },
134
+ );
135
+ expect(replies[0]).toMatch(/too large/);
136
+ });
137
+
138
+ it('refuses while busy', async () => {
139
+ const { ctx, replies } = makeCtx(audioMsg());
140
+ await handleVoiceMessage(
141
+ ctx,
142
+ { session: sessionWithTranscriber(async () => ({ text: 'x' })), busy: true },
143
+ {},
144
+ { runUserTurn: vi.fn() },
145
+ );
146
+ expect(replies[0]).toMatch(/still working/);
147
+ });
148
+
149
+ it('transcribes and runs a user turn with a "heard:" echo', async () => {
150
+ const transcribe = vi.fn(async () => ({ text: ' turn on the lights ' }));
151
+ const runUserTurn = vi.fn();
152
+ const { ctx, replies } = makeCtx(audioMsg());
153
+ const consumed = await handleVoiceMessage(
154
+ ctx,
155
+ { session: sessionWithTranscriber(transcribe), busy: false },
156
+ {},
157
+ { runUserTurn, fetchAudio: okFetch() },
158
+ );
159
+ expect(consumed).toBe(true);
160
+ expect(transcribe).toHaveBeenCalledWith(expect.any(Uint8Array), { mimeType: 'audio/ogg' });
161
+ expect(replies[0]).toBe('*heard:* turn on the lights');
162
+ expect(runUserTurn).toHaveBeenCalledWith(ctx, 'turn on the lights');
163
+ });
164
+
165
+ it('reports empty transcriptions instead of running an empty turn', async () => {
166
+ const runUserTurn = vi.fn();
167
+ const { ctx, replies } = makeCtx(audioMsg());
168
+ await handleVoiceMessage(
169
+ ctx,
170
+ { session: sessionWithTranscriber(async () => ({ text: ' ' })), busy: false },
171
+ {},
172
+ { runUserTurn, fetchAudio: okFetch() },
173
+ );
174
+ expect(replies[0]).toMatch(/empty text/);
175
+ expect(runUserTurn).not.toHaveBeenCalled();
176
+ });
177
+ });