@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.
- package/LICENSE +21 -0
- package/dist/allow-list.d.ts +20 -0
- package/dist/allow-list.d.ts.map +1 -0
- package/dist/allow-list.js +12 -0
- package/dist/allow-list.js.map +1 -0
- package/dist/approval.d.ts +29 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +65 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/allow-list-store.d.ts +22 -0
- package/dist/channel/allow-list-store.d.ts.map +1 -0
- package/dist/channel/allow-list-store.js +45 -0
- package/dist/channel/allow-list-store.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +16 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +27 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/components.d.ts +27 -0
- package/dist/channel/components.d.ts.map +1 -0
- package/dist/channel/components.js +34 -0
- package/dist/channel/components.js.map +1 -0
- package/dist/channel/discord-like.d.ts +31 -0
- package/dist/channel/discord-like.d.ts.map +1 -0
- package/dist/channel/discord-like.js +9 -0
- package/dist/channel/discord-like.js.map +1 -0
- package/dist/channel/interaction-handler.d.ts +67 -0
- package/dist/channel/interaction-handler.d.ts.map +1 -0
- package/dist/channel/interaction-handler.js +155 -0
- package/dist/channel/interaction-handler.js.map +1 -0
- package/dist/channel/message-handler.d.ts +60 -0
- package/dist/channel/message-handler.d.ts.map +1 -0
- package/dist/channel/message-handler.js +135 -0
- package/dist/channel/message-handler.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +58 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +109 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +19 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +38 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +45 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +107 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +49 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +116 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +12 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +28 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +41 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +105 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +107 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +464 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +64 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +85 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +16 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +109 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +83 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +98 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +30 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +54 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +34 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +204 -0
- package/dist/render.js.map +1 -0
- package/dist/schema.d.ts +123 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +70 -0
- package/dist/schema.js.map +1 -0
- package/dist/setup-wizard.d.ts +13 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +133 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +89 -0
- package/src/allow-list.test.ts +64 -0
- package/src/allow-list.ts +30 -0
- package/src/approval.ts +76 -0
- package/src/channel/allow-list-store.ts +52 -0
- package/src/channel/approval-prompt.ts +43 -0
- package/src/channel/components.ts +49 -0
- package/src/channel/discord-like.ts +34 -0
- package/src/channel/interaction-handler.test.ts +212 -0
- package/src/channel/interaction-handler.ts +237 -0
- package/src/channel/message-handler.test.ts +295 -0
- package/src/channel/message-handler.ts +195 -0
- package/src/channel/pairing-handler.ts +147 -0
- package/src/channel/permission-prompt.ts +54 -0
- package/src/channel/slash-handler.ts +136 -0
- package/src/channel/turn-runner.test.ts +177 -0
- package/src/channel/turn-runner.ts +139 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +177 -0
- package/src/channel/voice-handler.ts +150 -0
- package/src/channel/voice-reply.test.ts +142 -0
- package/src/channel.ts +582 -0
- package/src/index.ts +247 -0
- package/src/keys.ts +100 -0
- package/src/pair-flow.ts +122 -0
- package/src/pairing.test.ts +99 -0
- package/src/pairing.ts +178 -0
- package/src/permission.ts +70 -0
- package/src/render.test.ts +82 -0
- package/src/render.ts +228 -0
- package/src/schema.ts +94 -0
- package/src/setup-wizard.ts +162 -0
- package/src/subcommands.test.ts +182 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
AttachmentBuilder,
|
|
4
|
+
Client,
|
|
5
|
+
Events,
|
|
6
|
+
GatewayIntentBits,
|
|
7
|
+
Partials,
|
|
8
|
+
type Interaction,
|
|
9
|
+
type Message,
|
|
10
|
+
} from 'discord.js';
|
|
11
|
+
import { newTurnId } from '@moxxy/core';
|
|
12
|
+
import { TurnCoordinator, deliverVoiceReply, resolveVoiceToggle } from '@moxxy/channel-kit';
|
|
13
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
14
|
+
import type {
|
|
15
|
+
ApprovalRequest,
|
|
16
|
+
Channel,
|
|
17
|
+
ChannelHandle,
|
|
18
|
+
ChannelStartOptsBase,
|
|
19
|
+
MoxxyEvent,
|
|
20
|
+
PendingToolCall,
|
|
21
|
+
PermissionContext,
|
|
22
|
+
} from '@moxxy/sdk';
|
|
23
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
24
|
+
import { DiscordPermissionResolver } from './permission.js';
|
|
25
|
+
import { DiscordApprovalResolver } from './approval.js';
|
|
26
|
+
import {
|
|
27
|
+
resolveBotToken,
|
|
28
|
+
DISCORD_TOKEN_KEY,
|
|
29
|
+
loadVoiceReplies,
|
|
30
|
+
saveVoiceReplies,
|
|
31
|
+
} from './keys.js';
|
|
32
|
+
import { extractInboundMessage } from './schema.js';
|
|
33
|
+
import { splitForDiscord } from './render.js';
|
|
34
|
+
import { AllowListStore } from './channel/allow-list-store.js';
|
|
35
|
+
import type { ChannelLogger, SendableChannelLike } from './channel/discord-like.js';
|
|
36
|
+
import {
|
|
37
|
+
handleInboundMessage,
|
|
38
|
+
performSessionAction,
|
|
39
|
+
type AwaitingApprovalText,
|
|
40
|
+
type InboundContext,
|
|
41
|
+
} from './channel/message-handler.js';
|
|
42
|
+
import { handleInteraction, type InteractionLike } from './channel/interaction-handler.js';
|
|
43
|
+
import { PairingHandler, type PairingConfirmResult } from './channel/pairing-handler.js';
|
|
44
|
+
import { askForPermission } from './channel/permission-prompt.js';
|
|
45
|
+
import { askForApproval } from './channel/approval-prompt.js';
|
|
46
|
+
import { publishAppCommands } from './channel/slash-handler.js';
|
|
47
|
+
import { clampEditFrameMs, runDiscordTurn } from './channel/turn-runner.js';
|
|
48
|
+
import { handleVoiceMessage } from './channel/voice-handler.js';
|
|
49
|
+
import { TypingIndicator } from './channel/typing-indicator.js';
|
|
50
|
+
|
|
51
|
+
/** Cap on waiting for the gateway READY event (bot identity → invite link).
|
|
52
|
+
* Identity resolution must never wedge `start()`; on timeout we proceed
|
|
53
|
+
* without the invite URL and the gateway surfaces real token errors itself. */
|
|
54
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
55
|
+
|
|
56
|
+
/** Minimal permission bits for the invite link: View Channels + Send Messages
|
|
57
|
+
* + Read Message History. */
|
|
58
|
+
const INVITE_PERMISSIONS = 1024 + 2048 + 65536;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Install guidance shown when enabling `/voice` with no active synthesizer —
|
|
62
|
+
* mirrors the voice-handler's transcriber guidance wording, pointing at the TTS
|
|
63
|
+
* plugin instead of the STT one.
|
|
64
|
+
*/
|
|
65
|
+
const VOICE_NO_SYNTH_HINT =
|
|
66
|
+
'No text-to-speech backend is configured yet, so replies stay text-only. Install one with `moxxy plugins install tts-openai` and run `moxxy login openai` (or set OPENAI_API_KEY) to enable spoken replies.';
|
|
67
|
+
|
|
68
|
+
export type { PairingConfirmResult } from './channel/pairing-handler.js';
|
|
69
|
+
|
|
70
|
+
export interface DiscordStartOpts extends ChannelStartOptsBase {
|
|
71
|
+
readonly session: Session;
|
|
72
|
+
/**
|
|
73
|
+
* If true (and no account is paired yet), arm the DM pairing window on
|
|
74
|
+
* startup: an unauthorized user who DMs the bot is issued a one-time code,
|
|
75
|
+
* and the terminal `moxxy discord pair` flow confirms the pasted code. Set
|
|
76
|
+
* by the `pair` subcommand.
|
|
77
|
+
*/
|
|
78
|
+
readonly pair?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* The channel is running on its own dedicated runner under a GUI control
|
|
81
|
+
* surface (the desktop Channels panel). Arms the same pairing window as
|
|
82
|
+
* `pair` for the unpaired case so DM-ing users at least receive codes —
|
|
83
|
+
* completing the paste still needs the terminal `moxxy discord pair` flow.
|
|
84
|
+
*/
|
|
85
|
+
readonly dedicated?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DiscordChannelOptions {
|
|
89
|
+
readonly vault: VaultStore;
|
|
90
|
+
readonly token?: string;
|
|
91
|
+
readonly logger?: ChannelLogger;
|
|
92
|
+
/** Streaming edit throttle; clamped to ≥1200ms (Discord's ~5 edits/5s per
|
|
93
|
+
* channel rate limit). */
|
|
94
|
+
readonly editFrameMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class DiscordChannel implements Channel<DiscordStartOpts> {
|
|
98
|
+
readonly name = 'discord';
|
|
99
|
+
readonly permissionResolver: DiscordPermissionResolver;
|
|
100
|
+
readonly approvalResolver: DiscordApprovalResolver;
|
|
101
|
+
private readonly opts: DiscordChannelOptions;
|
|
102
|
+
private readonly editFrameMs: number;
|
|
103
|
+
private client: Client | null = null;
|
|
104
|
+
private botUserId: string | null = null;
|
|
105
|
+
// The bot's OAuth2 invite URL (resolved from the application id after the
|
|
106
|
+
// gateway READY), published as this channel's `requestUrl` connect value so
|
|
107
|
+
// control surfaces can render an "invite the bot" step. Null until resolved.
|
|
108
|
+
private inviteUrl: string | null = null;
|
|
109
|
+
private readonly connectListeners = new Set<() => void>();
|
|
110
|
+
// Channel the CURRENT turn runs in (target for permission/approval prompts).
|
|
111
|
+
private currentChannel: SendableChannelLike | null = null;
|
|
112
|
+
// Last channel we served — the target for mirroring foreign turns.
|
|
113
|
+
private lastChannel: SendableChannelLike | null = null;
|
|
114
|
+
private logUnsub: (() => void) | null = null;
|
|
115
|
+
private session: Session | null = null;
|
|
116
|
+
private model: string | undefined;
|
|
117
|
+
private yolo = false;
|
|
118
|
+
// When true, the final assistant reply of each turn is also synthesized (via
|
|
119
|
+
// the session's active Synthesizer) and sent as an audio attachment.
|
|
120
|
+
// Persisted per paired account in the vault (`discord_voice_replies`),
|
|
121
|
+
// toggled with `/voice`.
|
|
122
|
+
private voiceReplies = false;
|
|
123
|
+
// Single-flight turn state: `busy` guard, per-turn AbortController (so
|
|
124
|
+
// /cancel aborts only the current turn), and the bounded own-turn-id set
|
|
125
|
+
// that mirrorForeignTurn filters on (AGENTS.md invariant #8).
|
|
126
|
+
private readonly turns = new TurnCoordinator();
|
|
127
|
+
private awaitingApprovalText: AwaitingApprovalText | null = null;
|
|
128
|
+
private handle: ChannelHandle | null = null;
|
|
129
|
+
private readonly typing = new TypingIndicator();
|
|
130
|
+
private readonly pairing: PairingHandler;
|
|
131
|
+
private readonly allowList: AllowListStore;
|
|
132
|
+
// Rate-limit the "dropped invalid payload" warning so a hostile sender
|
|
133
|
+
// can't turn the log into a firehose.
|
|
134
|
+
private lastInvalidWarnAt = 0;
|
|
135
|
+
|
|
136
|
+
constructor(opts: DiscordChannelOptions) {
|
|
137
|
+
this.opts = opts;
|
|
138
|
+
this.editFrameMs = clampEditFrameMs(opts.editFrameMs);
|
|
139
|
+
this.permissionResolver = new DiscordPermissionResolver();
|
|
140
|
+
this.approvalResolver = new DiscordApprovalResolver();
|
|
141
|
+
this.pairing = new PairingHandler({
|
|
142
|
+
vault: opts.vault,
|
|
143
|
+
...(opts.logger ? { logger: opts.logger } : {}),
|
|
144
|
+
});
|
|
145
|
+
this.allowList = new AllowListStore(opts.vault);
|
|
146
|
+
// A completed pairing flips this channel's connect-state to "connected".
|
|
147
|
+
this.pairing.onPaired((userId) => {
|
|
148
|
+
this.notifyConnectChange();
|
|
149
|
+
this.greetPairedUser(userId);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** This channel's connect value (see {@link Channel.requestUrl}): the bot's
|
|
154
|
+
* OAuth2 invite URL, surfaced by control surfaces as an "invite the bot"
|
|
155
|
+
* step. */
|
|
156
|
+
get requestUrl(): string | null {
|
|
157
|
+
return this.inviteUrl;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether an account is paired (see {@link Channel.connected}). */
|
|
161
|
+
get connected(): boolean {
|
|
162
|
+
return this.pairing.phase() === 'paired';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pairingPhase(): ReturnType<PairingHandler['phase']> {
|
|
166
|
+
return this.pairing.phase();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
unpair(): void {
|
|
170
|
+
this.pairing.unpair();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Subscribe to "an account just paired" (fires once per completed pairing).
|
|
174
|
+
* Returns an unsubscribe function. */
|
|
175
|
+
onPaired(listener: (userId: string) => void): () => void {
|
|
176
|
+
return this.pairing.onPaired(listener);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The terminal pair flow submits the operator-pasted code here. */
|
|
180
|
+
confirmPairingCode(rawCode: string): Promise<PairingConfirmResult> {
|
|
181
|
+
return this.pairing.confirmCode(rawCode);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async start(startOpts: DiscordStartOpts): Promise<ChannelHandle> {
|
|
185
|
+
if (this.handle) return this.handle;
|
|
186
|
+
this.session = startOpts.session;
|
|
187
|
+
this.model = startOpts.model;
|
|
188
|
+
|
|
189
|
+
// Precedence mirrors the other channels (and this channel's isAvailable
|
|
190
|
+
// gate): explicit option, then MOXXY_DISCORD_TOKEN, then the vault.
|
|
191
|
+
const token = this.opts.token ?? (await resolveBotToken(this.opts.vault));
|
|
192
|
+
if (!token) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Discord bot token not found. Store one via vault_set('${DISCORD_TOKEN_KEY}', ...) or set MOXXY_DISCORD_TOKEN.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
await this.pairing.loadAuthorized();
|
|
198
|
+
await this.allowList.load();
|
|
199
|
+
this.voiceReplies = await loadVoiceReplies(this.opts.vault);
|
|
200
|
+
|
|
201
|
+
// Arm the DM pairing window when a pairing surface asked for it (`pair`)
|
|
202
|
+
// OR when running GUI-supervised on a dedicated runner and nothing is
|
|
203
|
+
// paired yet. A headless start with neither signal errors with a hint.
|
|
204
|
+
const dedicated = startOpts.dedicated === true || process.env.MOXXY_DEDICATED_RUNNER === '1';
|
|
205
|
+
if (this.pairing.phase() !== 'paired') {
|
|
206
|
+
if (startOpts.pair || dedicated) {
|
|
207
|
+
this.pairing.arm();
|
|
208
|
+
this.opts.logger?.info?.('discord pairing window armed');
|
|
209
|
+
} else {
|
|
210
|
+
throw new Error(
|
|
211
|
+
'No Discord account is paired yet. Run `moxxy discord pair`, DM the bot, and paste the code it replies with.',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// MessageContent is a PRIVILEGED intent — the setup wizard tells the user
|
|
217
|
+
// to enable it in the Developer Portal; without it every guild message
|
|
218
|
+
// arrives with empty content. Partials.Channel is required for DMs (they
|
|
219
|
+
// are not cached when the first event for them arrives).
|
|
220
|
+
const client = new Client({
|
|
221
|
+
intents: [
|
|
222
|
+
GatewayIntentBits.Guilds,
|
|
223
|
+
GatewayIntentBits.GuildMessages,
|
|
224
|
+
GatewayIntentBits.DirectMessages,
|
|
225
|
+
GatewayIntentBits.MessageContent,
|
|
226
|
+
],
|
|
227
|
+
partials: [Partials.Channel],
|
|
228
|
+
});
|
|
229
|
+
this.client = client;
|
|
230
|
+
this.permissionResolver.setDecider((call, ctx) => this.askForPermission(call, ctx));
|
|
231
|
+
this.approvalResolver.setDecider((id, request) => this.askForApproval(id, request));
|
|
232
|
+
// Register the approval resolver on the session so loop strategies
|
|
233
|
+
// (plan-execute) surface their validation dialog on this channel;
|
|
234
|
+
// stop() tears it down so headless paths never see a stale handler.
|
|
235
|
+
this.session.setApprovalResolver(this.approvalResolver);
|
|
236
|
+
|
|
237
|
+
// Mirror-to-both: when the session runs a turn this channel did NOT
|
|
238
|
+
// initiate (e.g. a co-attached web surface), post the assistant's prose
|
|
239
|
+
// into the last channel we served. Our OWN turns render via the pump.
|
|
240
|
+
this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
|
|
241
|
+
|
|
242
|
+
// discord.js does not await event handlers, but we still fire-and-track so
|
|
243
|
+
// rejections are logged (they would otherwise be unhandled rejections).
|
|
244
|
+
client.on(Events.MessageCreate, (message) =>
|
|
245
|
+
this.dispatchInBackground(this.handleMessage(message), 'message'),
|
|
246
|
+
);
|
|
247
|
+
client.on(Events.InteractionCreate, (interaction) =>
|
|
248
|
+
this.dispatchInBackground(this.handleInteractionEvent(interaction), 'interaction'),
|
|
249
|
+
);
|
|
250
|
+
client.on(Events.Error, (err) => {
|
|
251
|
+
this.opts.logger?.warn('discord client error', { err: String(err) });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Resolve the bot's identity (application id → invite URL, own user id)
|
|
255
|
+
// from the READY event, BOUNDED so a slow gateway can't wedge start() —
|
|
256
|
+
// on timeout we proceed without the invite link (mirrors Telegram's getMe
|
|
257
|
+
// timeout). `login` itself rejects fast on an invalid token.
|
|
258
|
+
const ready = new Promise<void>((resolve) => {
|
|
259
|
+
client.once(Events.ClientReady, () => resolve());
|
|
260
|
+
});
|
|
261
|
+
await client.login(token);
|
|
262
|
+
const readyTimer = new Promise<never>((_, reject) => {
|
|
263
|
+
const t = setTimeout(
|
|
264
|
+
() => reject(new Error(`gateway READY timed out after ${READY_TIMEOUT_MS}ms`)),
|
|
265
|
+
READY_TIMEOUT_MS,
|
|
266
|
+
);
|
|
267
|
+
t.unref?.();
|
|
268
|
+
});
|
|
269
|
+
try {
|
|
270
|
+
await Promise.race([ready, readyTimer]);
|
|
271
|
+
this.botUserId = client.user?.id ?? null;
|
|
272
|
+
const appId = client.application?.id ?? null;
|
|
273
|
+
if (appId) {
|
|
274
|
+
this.inviteUrl = `https://discord.com/oauth2/authorize?client_id=${appId}&scope=bot%20applications.commands&permissions=${INVITE_PERMISSIONS}`;
|
|
275
|
+
}
|
|
276
|
+
// Surface the shared registry commands in Discord's "/" picker. Best
|
|
277
|
+
// effort — a failure never blocks startup (text commands still work).
|
|
278
|
+
const commands = client.application?.commands;
|
|
279
|
+
void publishAppCommands(
|
|
280
|
+
commands ? { set: (defs) => commands.set([...defs]) } : null,
|
|
281
|
+
this.session,
|
|
282
|
+
this.opts.logger,
|
|
283
|
+
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
this.opts.logger?.warn('discord: could not resolve bot identity (READY timeout)', {
|
|
286
|
+
error: err instanceof Error ? err.message : String(err),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.opts.logger?.info?.('discord channel starting', {
|
|
291
|
+
paired: this.pairing.phase() === 'paired',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// The gateway has no "runs until stopped" promise; resolve on stop().
|
|
295
|
+
let resolveRunning: () => void = () => undefined;
|
|
296
|
+
const running = new Promise<void>((resolve) => {
|
|
297
|
+
resolveRunning = resolve;
|
|
298
|
+
});
|
|
299
|
+
this.handle = {
|
|
300
|
+
running,
|
|
301
|
+
onConnectChange: (listener) => {
|
|
302
|
+
this.connectListeners.add(listener);
|
|
303
|
+
return () => this.connectListeners.delete(listener);
|
|
304
|
+
},
|
|
305
|
+
stop: async (reason = 'shutdown') => {
|
|
306
|
+
// Abort the in-flight turn FIRST so the model loop stops generating /
|
|
307
|
+
// executing the moment the operator asks to shut down; then reject
|
|
308
|
+
// pending prompts so no caller hangs (audit: TuiChannel.stop hang).
|
|
309
|
+
this.turns.abort(reason);
|
|
310
|
+
this.permissionResolver.abortAll(reason);
|
|
311
|
+
this.approvalResolver.abortAll(reason);
|
|
312
|
+
this.logUnsub?.();
|
|
313
|
+
this.logUnsub = null;
|
|
314
|
+
if (this.session) this.session.setApprovalResolver(null);
|
|
315
|
+
this.typing.stop();
|
|
316
|
+
if (this.client) {
|
|
317
|
+
await this.client.destroy().catch(() => undefined);
|
|
318
|
+
this.client = null;
|
|
319
|
+
}
|
|
320
|
+
resolveRunning();
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
return this.handle;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private notifyConnectChange(): void {
|
|
327
|
+
for (const listener of this.connectListeners) {
|
|
328
|
+
try {
|
|
329
|
+
listener();
|
|
330
|
+
} catch (err) {
|
|
331
|
+
this.opts.logger?.warn('discord connect-change listener threw', { err: String(err) });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** DM a confirmation to the freshly paired account. Best-effort. */
|
|
337
|
+
private greetPairedUser(userId: string): void {
|
|
338
|
+
const client = this.client;
|
|
339
|
+
if (!client) return;
|
|
340
|
+
void client.users
|
|
341
|
+
.fetch(userId)
|
|
342
|
+
.then((user) => user.send('✅ Paired with moxxy. Send a prompt to begin.'))
|
|
343
|
+
.catch((err) => {
|
|
344
|
+
this.opts.logger?.warn('discord pairing greeting failed', { err: String(err) });
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Run a handler promise detached from the event dispatch. Errors are logged
|
|
350
|
+
* here — discord.js does not surface handler rejections anywhere useful.
|
|
351
|
+
*/
|
|
352
|
+
private dispatchInBackground(work: Promise<void>, kind: string): void {
|
|
353
|
+
void work.catch((err) => {
|
|
354
|
+
this.opts.logger?.warn('discord handler failed', { kind, err: String(err) });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async handleMessage(raw: Message): Promise<void> {
|
|
359
|
+
// Boundary validation (invariant A8): extract ONLY the fields we consume
|
|
360
|
+
// and zod-validate them; drop invalid/oversized payloads with a
|
|
361
|
+
// rate-limited warning.
|
|
362
|
+
const msg = extractInboundMessage(raw);
|
|
363
|
+
if (!msg) {
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
if (now - this.lastInvalidWarnAt > 10_000) {
|
|
366
|
+
this.lastInvalidWarnAt = now;
|
|
367
|
+
this.opts.logger?.warn('discord: dropped invalid inbound message payload');
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const channel = raw.channel as unknown as { send?: unknown };
|
|
372
|
+
if (typeof channel.send !== 'function') return;
|
|
373
|
+
const ctx: InboundContext = {
|
|
374
|
+
msg,
|
|
375
|
+
channel: raw.channel as unknown as SendableChannelLike,
|
|
376
|
+
reply: (text) => raw.reply(text),
|
|
377
|
+
};
|
|
378
|
+
const deps = {
|
|
379
|
+
pairing: this.pairing,
|
|
380
|
+
allowList: this.allowList,
|
|
381
|
+
approvalResolver: this.approvalResolver,
|
|
382
|
+
permissionResolver: this.permissionResolver,
|
|
383
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
384
|
+
};
|
|
385
|
+
await handleInboundMessage(
|
|
386
|
+
ctx,
|
|
387
|
+
{
|
|
388
|
+
session: this.session,
|
|
389
|
+
busy: this.turns.busy,
|
|
390
|
+
turnController: this.turns.controller,
|
|
391
|
+
awaitingApprovalText: this.awaitingApprovalText,
|
|
392
|
+
handle: this.handle,
|
|
393
|
+
botUserId: this.botUserId,
|
|
394
|
+
},
|
|
395
|
+
deps,
|
|
396
|
+
{
|
|
397
|
+
setAwaitingApprovalText: (state) => {
|
|
398
|
+
this.awaitingApprovalText = state;
|
|
399
|
+
},
|
|
400
|
+
toggleYolo: () => {
|
|
401
|
+
this.yolo = !this.yolo;
|
|
402
|
+
return this.yolo;
|
|
403
|
+
},
|
|
404
|
+
setYolo: (value) => {
|
|
405
|
+
this.yolo = value;
|
|
406
|
+
},
|
|
407
|
+
voice: (arg) => this.voiceCommand(arg),
|
|
408
|
+
runUserTurn: (c, text) => this.runUserTurn(c, text),
|
|
409
|
+
runVoiceMessage: (c) =>
|
|
410
|
+
handleVoiceMessage(
|
|
411
|
+
c,
|
|
412
|
+
{ session: this.session, busy: this.turns.busy },
|
|
413
|
+
this.opts.logger ? { logger: this.opts.logger } : {},
|
|
414
|
+
{ runUserTurn: (cc, text) => this.runUserTurn(cc, text) },
|
|
415
|
+
),
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async handleInteractionEvent(interaction: Interaction): Promise<void> {
|
|
421
|
+
await handleInteraction(
|
|
422
|
+
interaction as unknown as InteractionLike,
|
|
423
|
+
{ session: this.session, turnController: this.turns.controller },
|
|
424
|
+
{
|
|
425
|
+
pairing: this.pairing,
|
|
426
|
+
allowList: this.allowList,
|
|
427
|
+
permissionResolver: this.permissionResolver,
|
|
428
|
+
approvalResolver: this.approvalResolver,
|
|
429
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
setAwaitingApprovalText: (state) => {
|
|
433
|
+
this.awaitingApprovalText = state;
|
|
434
|
+
},
|
|
435
|
+
toggleYolo: () => {
|
|
436
|
+
this.yolo = !this.yolo;
|
|
437
|
+
return this.yolo;
|
|
438
|
+
},
|
|
439
|
+
voice: (arg) => this.voiceCommand(arg),
|
|
440
|
+
performSessionAction: (action, notice) =>
|
|
441
|
+
performSessionAction(
|
|
442
|
+
action,
|
|
443
|
+
notice,
|
|
444
|
+
{
|
|
445
|
+
session: this.session,
|
|
446
|
+
turnController: this.turns.controller,
|
|
447
|
+
handle: this.handle,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
approvalResolver: this.approvalResolver,
|
|
451
|
+
permissionResolver: this.permissionResolver,
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
setAwaitingApprovalText: (state) => {
|
|
455
|
+
this.awaitingApprovalText = state;
|
|
456
|
+
},
|
|
457
|
+
setYolo: (value) => {
|
|
458
|
+
this.yolo = value;
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
),
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async runUserTurn(ctx: InboundContext, text: string): Promise<void> {
|
|
467
|
+
if (!this.session) throw new Error('DiscordChannel.start() must be called first');
|
|
468
|
+
// Atomic single-flight guard: `begin` claims the slot synchronously so a
|
|
469
|
+
// concurrently dispatched second turn can't slip past the busy check. The
|
|
470
|
+
// turnId is minted here so the coordinator records it as an own-turn id
|
|
471
|
+
// (mirrorForeignTurn filters on those).
|
|
472
|
+
const lease = this.turns.begin(newTurnId());
|
|
473
|
+
if (!lease) {
|
|
474
|
+
await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
this.currentChannel = ctx.channel;
|
|
478
|
+
this.lastChannel = ctx.channel;
|
|
479
|
+
try {
|
|
480
|
+
await runDiscordTurn(
|
|
481
|
+
{
|
|
482
|
+
session: this.session,
|
|
483
|
+
channel: ctx.channel,
|
|
484
|
+
typing: this.typing,
|
|
485
|
+
editFrameMs: this.editFrameMs,
|
|
486
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
487
|
+
onFinalReply: (finalText) => this.sendVoiceReply(ctx.channel, finalText),
|
|
488
|
+
},
|
|
489
|
+
{ text, model: this.model, controller: lease.controller, turnId: lease.turnId },
|
|
490
|
+
);
|
|
491
|
+
} finally {
|
|
492
|
+
lease.end();
|
|
493
|
+
this.currentChannel = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Handle `/voice [on|off|status]`: persist + apply the preference, return the
|
|
498
|
+
* reply text. Shared by the plain-text and application-command paths. */
|
|
499
|
+
private async voiceCommand(arg: string): Promise<string> {
|
|
500
|
+
const result = resolveVoiceToggle({
|
|
501
|
+
arg,
|
|
502
|
+
enabled: this.voiceReplies,
|
|
503
|
+
hasSynthesizer: this.session?.synthesizers.tryGetActive() != null,
|
|
504
|
+
delivery: 'an audio file',
|
|
505
|
+
noSynthesizerHint: VOICE_NO_SYNTH_HINT,
|
|
506
|
+
});
|
|
507
|
+
if (result.persist) await this.setVoiceReplies(result.enabled);
|
|
508
|
+
return result.reply;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async setVoiceReplies(on: boolean): Promise<void> {
|
|
512
|
+
this.voiceReplies = on;
|
|
513
|
+
try {
|
|
514
|
+
await saveVoiceReplies(this.opts.vault, on);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
this.opts.logger?.warn('discord voice-replies persist failed', { err: String(err) });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Attach the final assistant reply as synthesized audio, when enabled.
|
|
522
|
+
* Best-effort and fully isolated (never throws): synthesize via the session's
|
|
523
|
+
* active Synthesizer, transcode to OGG/Opus (or send the original format when
|
|
524
|
+
* ffmpeg is unavailable), and post as a file. The text reply already went out.
|
|
525
|
+
*
|
|
526
|
+
* NB: a plain audio attachment, not a true Discord voice-message bubble
|
|
527
|
+
* (MessageFlags.IsVoiceMessage + waveform) — deferred to a follow-up.
|
|
528
|
+
*/
|
|
529
|
+
private async sendVoiceReply(channel: SendableChannelLike, text: string): Promise<void> {
|
|
530
|
+
if (!this.voiceReplies || !this.session) return;
|
|
531
|
+
const outcome = await deliverVoiceReply(this.session, text, {
|
|
532
|
+
send: async (audio, meta) => {
|
|
533
|
+
await channel.send({
|
|
534
|
+
files: [new AttachmentBuilder(Buffer.from(audio), { name: meta.filename })],
|
|
535
|
+
});
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
if (outcome.status === 'failed') {
|
|
539
|
+
this.opts.logger?.warn('discord voice reply failed', {
|
|
540
|
+
reason: outcome.reason,
|
|
541
|
+
...(outcome.error ? { err: outcome.error } : {}),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Post the assistant's prose for a turn this channel did not initiate. The
|
|
548
|
+
* coordinator skips turns THIS channel started, by turnId (invariant #8),
|
|
549
|
+
* and yields the trimmed prose; we only need a served channel to post into.
|
|
550
|
+
*/
|
|
551
|
+
private mirrorForeignTurn(event: MoxxyEvent): void {
|
|
552
|
+
const text = this.turns.mirrorText(event);
|
|
553
|
+
if (text == null) return;
|
|
554
|
+
const target = this.lastChannel;
|
|
555
|
+
if (!target) return;
|
|
556
|
+
void (async () => {
|
|
557
|
+
for (const part of splitForDiscord(text)) {
|
|
558
|
+
await target.send(part);
|
|
559
|
+
}
|
|
560
|
+
})().catch((err) => {
|
|
561
|
+
this.opts.logger?.warn('discord mirror failed', { err: String(err) });
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private askForPermission(call: PendingToolCall, ctx: PermissionContext): Promise<void> {
|
|
566
|
+
return askForPermission(call, ctx, {
|
|
567
|
+
channel: this.currentChannel,
|
|
568
|
+
session: this.session,
|
|
569
|
+
resolver: this.permissionResolver,
|
|
570
|
+
yolo: this.yolo,
|
|
571
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private askForApproval(id: string, request: ApprovalRequest): Promise<void> {
|
|
576
|
+
return askForApproval(id, request, {
|
|
577
|
+
channel: this.currentChannel,
|
|
578
|
+
resolver: this.approvalResolver,
|
|
579
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|