@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/pairing.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { randomCode } from '@moxxy/plugin-vault';
|
|
2
|
+
import {
|
|
3
|
+
clearHostCodePairing,
|
|
4
|
+
createHostCodeState,
|
|
5
|
+
greetPeer,
|
|
6
|
+
isPeerAuthorized,
|
|
7
|
+
openHostCodeWindow,
|
|
8
|
+
submitPeerCode,
|
|
9
|
+
type HostCodeAction,
|
|
10
|
+
type HostCodeState,
|
|
11
|
+
} from '@moxxy/channel-kit';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Discord pairing — the kit's host-code machine, driven over DM in the
|
|
15
|
+
* "bot replies with a code, operator pastes it in the terminal" direction.
|
|
16
|
+
*
|
|
17
|
+
* Flow (the wizard/`moxxy discord pair` arms it):
|
|
18
|
+
* 1. The channel starts with a pairing window ARMED (no code yet).
|
|
19
|
+
* 2. An unauthorized user DMs the bot → we mint a one-time code bound to
|
|
20
|
+
* THAT user ({@link mintCodeForPeer} = `openHostCodeWindow` with the
|
|
21
|
+
* DM-ing user recorded as the pending peer) and DM the code back.
|
|
22
|
+
* 3. The operator pastes the code into the terminal wizard →
|
|
23
|
+
* {@link confirmPendingCode} (`submitPeerCode` against the pending peer).
|
|
24
|
+
* On match the DM-ing user becomes the authorized principal.
|
|
25
|
+
*
|
|
26
|
+
* Security property: the code only ever lives in the candidate's DM, so
|
|
27
|
+
* pasting it in the terminal proves the operator controls (or trusts) that
|
|
28
|
+
* Discord account. A different user DM-ing while armed re-mints the window
|
|
29
|
+
* for themselves — which STALES the earlier code, so the operator's paste of
|
|
30
|
+
* the legitimate code then mismatches and pairing simply retries; an attacker
|
|
31
|
+
* can never be authorized unless the operator pastes the ATTACKER's code
|
|
32
|
+
* (which only exists in the attacker's DM).
|
|
33
|
+
*
|
|
34
|
+
* The transitions live in `@moxxy/channel-kit` (generic over the peer id —
|
|
35
|
+
* a Discord user id string here); this module keeps the Discord-shaped state
|
|
36
|
+
* (armed flag + pending peer) and maps the kit's semantic action kinds to
|
|
37
|
+
* Discord-worded messages.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export type DiscordPairingPhase = 'idle' | 'armed' | 'awaiting-host-code' | 'paired' | 'expired';
|
|
41
|
+
|
|
42
|
+
export interface DiscordPairingState {
|
|
43
|
+
/** Kit machine state; peer = Discord user id. */
|
|
44
|
+
readonly kit: HostCodeState<string>;
|
|
45
|
+
/** A pairing window is armed (codes will be minted for DM-ing users). */
|
|
46
|
+
readonly armed: boolean;
|
|
47
|
+
/** The user the CURRENT code was minted for (null when none outstanding). */
|
|
48
|
+
readonly pendingUserId: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DiscordPairingDecision {
|
|
52
|
+
readonly state: DiscordPairingState;
|
|
53
|
+
readonly action:
|
|
54
|
+
| { kind: 'code-minted'; userId: string; code: string }
|
|
55
|
+
| { kind: 'paired'; userId: string }
|
|
56
|
+
| { kind: 'still-paired'; userId: string }
|
|
57
|
+
| { kind: 'reject'; message: string }
|
|
58
|
+
| { kind: 'mismatch'; message: string }
|
|
59
|
+
| { kind: 'not-pending'; message: string }
|
|
60
|
+
| { kind: 'expired'; message: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MSG_FOREIGN_USER = 'This bot is paired with a different Discord account. Access denied.';
|
|
64
|
+
const MSG_NOT_ARMED =
|
|
65
|
+
'No pairing window is open. Run `moxxy discord pair` in the moxxy terminal first, then DM me again.';
|
|
66
|
+
const MSG_NOT_PENDING = 'No pairing code is outstanding. DM the bot first — it replies with a code.';
|
|
67
|
+
const MSG_MISMATCH =
|
|
68
|
+
"That code didn't match. Check the code in the bot's most recent DM reply and try again.";
|
|
69
|
+
const MSG_EXPIRED = 'The pairing code expired. DM the bot again for a fresh one.';
|
|
70
|
+
|
|
71
|
+
export function createDiscordPairingState(
|
|
72
|
+
opts: { authorizedUserId?: string | null } = {},
|
|
73
|
+
): DiscordPairingState {
|
|
74
|
+
return {
|
|
75
|
+
kit: createHostCodeState<string>({ authorizedPeer: opts.authorizedUserId ?? null }),
|
|
76
|
+
armed: false,
|
|
77
|
+
pendingUserId: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function pairingPhase(state: DiscordPairingState): DiscordPairingPhase {
|
|
82
|
+
if (state.kit.phase === 'paired') return 'paired';
|
|
83
|
+
if (state.kit.phase === 'awaiting-host-code') return 'awaiting-host-code';
|
|
84
|
+
if (state.kit.phase === 'expired') return 'expired';
|
|
85
|
+
return state.armed ? 'armed' : 'idle';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Arm the pairing window: DM-ing users will now be issued one-time codes. */
|
|
89
|
+
export function armPairing(state: DiscordPairingState): DiscordPairingState {
|
|
90
|
+
return { ...state, armed: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isUserAuthorized(state: DiscordPairingState, userId: string): boolean {
|
|
94
|
+
return isPeerAuthorized(state.kit, userId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function clearDiscordPairing(state: DiscordPairingState): DiscordPairingState {
|
|
98
|
+
return {
|
|
99
|
+
kit: clearHostCodePairing(state.kit),
|
|
100
|
+
armed: state.armed,
|
|
101
|
+
pendingUserId: null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* An unauthorized user DMed the bot while a window may be armed. Mints a
|
|
107
|
+
* one-time code bound to that user (re-arming replaces any earlier pending
|
|
108
|
+
* code — see the module doc for why that fails safe) or rejects with the
|
|
109
|
+
* appropriate wording.
|
|
110
|
+
*/
|
|
111
|
+
export function mintCodeForPeer(
|
|
112
|
+
state: DiscordPairingState,
|
|
113
|
+
userId: string,
|
|
114
|
+
code: string = randomCode(6),
|
|
115
|
+
): DiscordPairingDecision {
|
|
116
|
+
const greeted = greetPeer(state.kit, userId);
|
|
117
|
+
if (greeted.action.kind === 'still-paired') {
|
|
118
|
+
return { state, action: { kind: 'still-paired', userId } };
|
|
119
|
+
}
|
|
120
|
+
if (greeted.action.kind === 'rejected-foreign-peer') {
|
|
121
|
+
return { state, action: { kind: 'reject', message: MSG_FOREIGN_USER } };
|
|
122
|
+
}
|
|
123
|
+
if (!state.armed) {
|
|
124
|
+
return { state, action: { kind: 'reject', message: MSG_NOT_ARMED } };
|
|
125
|
+
}
|
|
126
|
+
const opened = openHostCodeWindow(state.kit, { code });
|
|
127
|
+
return {
|
|
128
|
+
state: { kit: opened.state, armed: true, pendingUserId: userId },
|
|
129
|
+
action: { kind: 'code-minted', userId, code: opened.code },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Map a kit action to the Discord-worded decision, reusing `state` when the
|
|
134
|
+
* machine didn't transition. */
|
|
135
|
+
function mapConfirmAction(
|
|
136
|
+
state: DiscordPairingState,
|
|
137
|
+
kitState: HostCodeState<string>,
|
|
138
|
+
action: HostCodeAction<string>,
|
|
139
|
+
): DiscordPairingDecision {
|
|
140
|
+
switch (action.kind) {
|
|
141
|
+
case 'paired':
|
|
142
|
+
return {
|
|
143
|
+
state: { kit: kitState, armed: false, pendingUserId: null },
|
|
144
|
+
action: { kind: 'paired', userId: action.peer },
|
|
145
|
+
};
|
|
146
|
+
case 'still-paired':
|
|
147
|
+
return { state, action: { kind: 'still-paired', userId: action.peer } };
|
|
148
|
+
case 'rejected-foreign-peer':
|
|
149
|
+
return { state, action: { kind: 'reject', message: MSG_FOREIGN_USER } };
|
|
150
|
+
case 'window-open-hint':
|
|
151
|
+
case 'no-window':
|
|
152
|
+
case 'not-pending':
|
|
153
|
+
return { state, action: { kind: 'not-pending', message: MSG_NOT_PENDING } };
|
|
154
|
+
case 'expired':
|
|
155
|
+
return {
|
|
156
|
+
state: { kit: kitState, armed: state.armed, pendingUserId: null },
|
|
157
|
+
action: { kind: 'expired', message: MSG_EXPIRED },
|
|
158
|
+
};
|
|
159
|
+
case 'mismatch':
|
|
160
|
+
return { state, action: { kind: 'mismatch', message: MSG_MISMATCH } };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The operator pasted a code into the terminal wizard. Compared against the
|
|
166
|
+
* code minted for the CURRENT pending user; on match that user is authorized.
|
|
167
|
+
*/
|
|
168
|
+
export function confirmPendingCode(
|
|
169
|
+
state: DiscordPairingState,
|
|
170
|
+
rawCode: string,
|
|
171
|
+
now: number = Date.now(),
|
|
172
|
+
): DiscordPairingDecision {
|
|
173
|
+
if (state.pendingUserId == null) {
|
|
174
|
+
return { state, action: { kind: 'not-pending', message: MSG_NOT_PENDING } };
|
|
175
|
+
}
|
|
176
|
+
const decision = submitPeerCode(state.kit, state.pendingUserId, rawCode, now);
|
|
177
|
+
return mapConfirmAction(state, decision.state, decision.action);
|
|
178
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PendingToolCall,
|
|
3
|
+
PermissionContext,
|
|
4
|
+
PermissionDecision,
|
|
5
|
+
PermissionResolver,
|
|
6
|
+
} from '@moxxy/sdk';
|
|
7
|
+
|
|
8
|
+
export interface PendingPermission {
|
|
9
|
+
readonly callId: string;
|
|
10
|
+
readonly call: PendingToolCall;
|
|
11
|
+
readonly ctx: PermissionContext;
|
|
12
|
+
readonly resolve: (decision: PermissionDecision) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Permission resolver that defers each tool call to an external decider (the
|
|
17
|
+
* Discord bot, which renders a button row and waits for a click). The channel
|
|
18
|
+
* registers an interaction handler that invokes `resolvePending(callId,
|
|
19
|
+
* decision)` when the paired user clicks a button. Mirrors the Telegram
|
|
20
|
+
* resolver's shape (pending map + setDecider + abortAll) — the button
|
|
21
|
+
* click arrives on a different code path than the check, so the shared
|
|
22
|
+
* `createDeferredPermissionResolver` (whose prompt must RETURN the decision)
|
|
23
|
+
* doesn't fit; the abortAll contract is preserved here instead.
|
|
24
|
+
*/
|
|
25
|
+
export class DiscordPermissionResolver implements PermissionResolver {
|
|
26
|
+
readonly name = 'discord';
|
|
27
|
+
private readonly pending = new Map<string, PendingPermission>();
|
|
28
|
+
private readonly sessionAllows = new Set<string>();
|
|
29
|
+
private deciderFn: ((call: PendingToolCall, ctx: PermissionContext) => Promise<void>) | null =
|
|
30
|
+
null;
|
|
31
|
+
|
|
32
|
+
setDecider(fn: (call: PendingToolCall, ctx: PermissionContext) => Promise<void>): void {
|
|
33
|
+
this.deciderFn = fn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async check(call: PendingToolCall, ctx: PermissionContext): Promise<PermissionDecision> {
|
|
37
|
+
if (this.sessionAllows.has(call.name)) {
|
|
38
|
+
return { mode: 'allow_session', reason: 'allow_session previously granted' };
|
|
39
|
+
}
|
|
40
|
+
if (!this.deciderFn) {
|
|
41
|
+
return { mode: 'deny', reason: 'no decider attached (bot not running)' };
|
|
42
|
+
}
|
|
43
|
+
const decision = await new Promise<PermissionDecision>((resolve) => {
|
|
44
|
+
this.pending.set(call.callId, { callId: call.callId, call, ctx, resolve });
|
|
45
|
+
this.deciderFn!(call, ctx).catch((err) => {
|
|
46
|
+
this.pending.delete(call.callId);
|
|
47
|
+
resolve({ mode: 'deny', reason: err instanceof Error ? err.message : String(err) });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
if (decision.mode === 'allow_session') this.sessionAllows.add(call.name);
|
|
51
|
+
return decision;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Called by the interaction handler when a permission button is clicked. */
|
|
55
|
+
resolvePending(callId: string, decision: PermissionDecision): boolean {
|
|
56
|
+
const pending = this.pending.get(callId);
|
|
57
|
+
if (!pending) return false;
|
|
58
|
+
this.pending.delete(callId);
|
|
59
|
+
pending.resolve(decision);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Deny every in-flight prompt — MUST run on channel stop so callers don't hang. */
|
|
64
|
+
abortAll(reason = 'channel closed'): void {
|
|
65
|
+
for (const pending of this.pending.values()) {
|
|
66
|
+
pending.resolve({ mode: 'deny', reason });
|
|
67
|
+
}
|
|
68
|
+
this.pending.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
3
|
+
import { DiscordTurnRenderer, splitForDiscord } from './render.js';
|
|
4
|
+
|
|
5
|
+
const ev = (partial: Record<string, unknown>): MoxxyEvent => partial as unknown as MoxxyEvent;
|
|
6
|
+
|
|
7
|
+
describe('DiscordTurnRenderer', () => {
|
|
8
|
+
it('accumulates chunks, prefers the final assistant message, reports updates', () => {
|
|
9
|
+
const r = new DiscordTurnRenderer();
|
|
10
|
+
expect(r.accept(ev({ type: 'assistant_chunk', delta: 'Hel' }))).toBe(true);
|
|
11
|
+
expect(r.accept(ev({ type: 'assistant_chunk', delta: 'lo' }))).toBe(true);
|
|
12
|
+
expect(r.snapshot()).toBe('Hello');
|
|
13
|
+
expect(r.accept(ev({ type: 'assistant_message', content: 'Hello, world.' }))).toBe(true);
|
|
14
|
+
expect(r.snapshot()).toBe('Hello, world.');
|
|
15
|
+
// Unknown events don't change the frame.
|
|
16
|
+
expect(r.accept(ev({ type: 'user_prompt', content: 'x' }))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders tool activity as a quote block with statuses', () => {
|
|
20
|
+
const r = new DiscordTurnRenderer();
|
|
21
|
+
r.accept(ev({ type: 'tool_call_requested', callId: 'c1', name: 'read_file', input: { path: '/tmp/x' } }));
|
|
22
|
+
expect(r.snapshot()).toContain('> - `read_file` (path=/tmp/x) — running…');
|
|
23
|
+
r.accept(ev({ type: 'tool_result', callId: 'c1', ok: true, output: 'ok' }));
|
|
24
|
+
expect(r.snapshot()).toMatch(/`read_file`.*— done /);
|
|
25
|
+
r.accept(ev({ type: 'tool_call_requested', callId: 'c2', name: 'bash', input: {} }));
|
|
26
|
+
r.accept(ev({ type: 'tool_call_denied', callId: 'c2', reason: 'denied by user' }));
|
|
27
|
+
expect(r.snapshot()).toContain('denied: denied by user');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('surfaces error events', () => {
|
|
31
|
+
const r = new DiscordTurnRenderer();
|
|
32
|
+
r.accept(ev({ type: 'error', kind: 'provider', message: 'boom' }));
|
|
33
|
+
expect(r.snapshot()).toContain('❗ **provider**: boom');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('reset clears everything', () => {
|
|
37
|
+
const r = new DiscordTurnRenderer();
|
|
38
|
+
r.accept(ev({ type: 'assistant_chunk', delta: 'x' }));
|
|
39
|
+
r.reset();
|
|
40
|
+
expect(r.snapshot()).toBe('');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('splitForDiscord (2000-char cap)', () => {
|
|
45
|
+
it('returns single part under the limit and [] for empty', () => {
|
|
46
|
+
expect(splitForDiscord('hi')).toEqual(['hi']);
|
|
47
|
+
expect(splitForDiscord('')).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('splits on newline boundaries and keeps every part under the cap', () => {
|
|
51
|
+
const line = 'a'.repeat(80);
|
|
52
|
+
const text = Array.from({ length: 60 }, () => line).join('\n'); // ~4860 chars
|
|
53
|
+
const parts = splitForDiscord(text, 500);
|
|
54
|
+
expect(parts.length).toBeGreaterThan(1);
|
|
55
|
+
for (const p of parts) expect(p.length).toBeLessThanOrEqual(500);
|
|
56
|
+
// No content lost: rejoining with newlines reproduces the original lines.
|
|
57
|
+
expect(parts.join('\n')).toBe(text);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('closes and reopens a code fence across a cut (with its info string)', () => {
|
|
61
|
+
const fenceBody = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n');
|
|
62
|
+
const text = 'intro\n```diff\n' + fenceBody + '\n```\noutro';
|
|
63
|
+
const parts = splitForDiscord(text, 120);
|
|
64
|
+
expect(parts.length).toBeGreaterThan(1);
|
|
65
|
+
for (const p of parts) expect(p.length).toBeLessThanOrEqual(120);
|
|
66
|
+
// Every part is fence-balanced (an even number of ``` markers).
|
|
67
|
+
for (const p of parts) {
|
|
68
|
+
const fences = p.match(/```/g) ?? [];
|
|
69
|
+
expect(fences.length % 2).toBe(0);
|
|
70
|
+
}
|
|
71
|
+
// A middle part reopens with the original info string.
|
|
72
|
+
const middle = parts.slice(1, -1);
|
|
73
|
+
expect(middle.some((p) => p.startsWith('```diff\n'))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles oversized single lines (no newline under the budget)', () => {
|
|
77
|
+
const text = 'x'.repeat(5000);
|
|
78
|
+
const parts = splitForDiscord(text, 1000);
|
|
79
|
+
expect(parts.join('')).toBe(text);
|
|
80
|
+
for (const p of parts) expect(p.length).toBeLessThanOrEqual(1000);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discord messages cap at 2000 chars; leave margin for the fence-reopen text a
|
|
5
|
+
* split may prepend to a tail part.
|
|
6
|
+
*/
|
|
7
|
+
export const DISCORD_MESSAGE_LIMIT = 1_900;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Accumulates one turn's events into a single Discord-markdown string (the
|
|
11
|
+
* frame the pump sends/edits). Discord renders standard markdown natively
|
|
12
|
+
* (bold, `code`, ``` fences, > quotes), so — unlike Telegram — no HTML
|
|
13
|
+
* conversion pass is needed; the assistant body passes through as-is with a
|
|
14
|
+
* compact tool-activity quote block above it.
|
|
15
|
+
*
|
|
16
|
+
* Structure of a frame:
|
|
17
|
+
*
|
|
18
|
+
* > - `tool_name` (args) — done 0.4s
|
|
19
|
+
* > - `another_tool` (args) — running…
|
|
20
|
+
*
|
|
21
|
+
* <assistant body>
|
|
22
|
+
*
|
|
23
|
+
* <error if any>
|
|
24
|
+
*/
|
|
25
|
+
type ToolStatus =
|
|
26
|
+
| { kind: 'pending'; startedAt: number }
|
|
27
|
+
| { kind: 'ok'; ms: number }
|
|
28
|
+
| { kind: 'err'; ms: number; message: string }
|
|
29
|
+
| { kind: 'denied'; reason: string };
|
|
30
|
+
|
|
31
|
+
interface ToolEntry {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly inputPreview: string;
|
|
34
|
+
status: ToolStatus;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class DiscordTurnRenderer {
|
|
38
|
+
private chunks: string[] = [];
|
|
39
|
+
private finalAssistant: string | null = null;
|
|
40
|
+
private readonly tools = new Map<string, ToolEntry>();
|
|
41
|
+
private notices: string[] = [];
|
|
42
|
+
private errorLine: string | null = null;
|
|
43
|
+
private lastFrame = '';
|
|
44
|
+
|
|
45
|
+
/** Returns true when the event changed the frame (schedule an edit). */
|
|
46
|
+
accept(event: MoxxyEvent): boolean {
|
|
47
|
+
switch (event.type) {
|
|
48
|
+
case 'assistant_chunk':
|
|
49
|
+
this.chunks.push(event.delta);
|
|
50
|
+
break;
|
|
51
|
+
case 'assistant_message':
|
|
52
|
+
this.finalAssistant = event.content;
|
|
53
|
+
this.chunks = [];
|
|
54
|
+
break;
|
|
55
|
+
case 'tool_call_requested':
|
|
56
|
+
this.tools.set(String(event.callId), {
|
|
57
|
+
name: event.name,
|
|
58
|
+
inputPreview: previewArgs(event.input),
|
|
59
|
+
status: { kind: 'pending', startedAt: Date.now() },
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
case 'tool_call_denied': {
|
|
63
|
+
const entry = this.tools.get(String(event.callId));
|
|
64
|
+
if (entry) entry.status = { kind: 'denied', reason: event.reason };
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'tool_result': {
|
|
68
|
+
const entry = this.tools.get(String(event.callId));
|
|
69
|
+
if (!entry) break;
|
|
70
|
+
const started = entry.status.kind === 'pending' ? entry.status.startedAt : Date.now();
|
|
71
|
+
const ms = Date.now() - started;
|
|
72
|
+
entry.status = event.ok
|
|
73
|
+
? { kind: 'ok', ms }
|
|
74
|
+
: {
|
|
75
|
+
kind: 'err',
|
|
76
|
+
ms,
|
|
77
|
+
message: `${event.error?.kind ?? 'error'}: ${event.error?.message ?? ''}`,
|
|
78
|
+
};
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'skill_invoked':
|
|
82
|
+
this.notices.push(`💡 skill: **${event.name}**`);
|
|
83
|
+
break;
|
|
84
|
+
case 'error':
|
|
85
|
+
this.errorLine = `❗ **${event.kind}**: ${event.message}`;
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const frame = this.snapshot();
|
|
91
|
+
const changed = frame !== this.lastFrame;
|
|
92
|
+
this.lastFrame = frame;
|
|
93
|
+
return changed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
snapshot(): string {
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
const activity = this.renderActivity();
|
|
99
|
+
if (activity) parts.push(activity);
|
|
100
|
+
const body = this.finalText();
|
|
101
|
+
if (body) parts.push(body);
|
|
102
|
+
if (this.errorLine) parts.push(this.errorLine);
|
|
103
|
+
return parts.join('\n\n').trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The assistant body alone (no activity/error block) — the text a voice
|
|
107
|
+
* reply speaks. Empty for a tool-only turn. */
|
|
108
|
+
finalText(): string {
|
|
109
|
+
return (this.finalAssistant ?? this.chunks.join('')).trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
reset(): void {
|
|
113
|
+
this.chunks = [];
|
|
114
|
+
this.finalAssistant = null;
|
|
115
|
+
this.tools.clear();
|
|
116
|
+
this.notices = [];
|
|
117
|
+
this.errorLine = null;
|
|
118
|
+
this.lastFrame = '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private renderActivity(): string | null {
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
const tools = [...this.tools.values()];
|
|
124
|
+
const visible = tools.slice(-10);
|
|
125
|
+
const hidden = tools.length - visible.length;
|
|
126
|
+
if (hidden > 0) lines.push(`… ${hidden} earlier tool call${hidden === 1 ? '' : 's'} hidden`);
|
|
127
|
+
for (const t of visible) {
|
|
128
|
+
const args = t.inputPreview ? ` (${t.inputPreview})` : '';
|
|
129
|
+
lines.push(`- \`${t.name}\`${args} — ${statusBadge(t.status)}`);
|
|
130
|
+
}
|
|
131
|
+
for (const n of this.notices.slice(-5)) lines.push(n);
|
|
132
|
+
if (lines.length === 0) return null;
|
|
133
|
+
return lines.map((l) => `> ${l}`).join('\n');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function statusBadge(status: ToolStatus): string {
|
|
138
|
+
switch (status.kind) {
|
|
139
|
+
case 'pending':
|
|
140
|
+
return 'running…';
|
|
141
|
+
case 'ok':
|
|
142
|
+
return `done ${fmtElapsed(status.ms)}`;
|
|
143
|
+
case 'err':
|
|
144
|
+
return `error: ${status.message.slice(0, 80)}`;
|
|
145
|
+
case 'denied':
|
|
146
|
+
return `denied: ${status.reason.slice(0, 80)}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function fmtElapsed(ms: number): string {
|
|
151
|
+
if (ms < 1000) return `${ms}ms`;
|
|
152
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
153
|
+
return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function truncJson(value: unknown, max = 60): string {
|
|
157
|
+
const s = typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
|
|
158
|
+
if (s.length <= max) return s;
|
|
159
|
+
return s.slice(0, max - 1) + '…';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function previewArgs(input: unknown): string {
|
|
163
|
+
if (input == null || (typeof input === 'object' && Object.keys(input as object).length === 0)) {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
if (typeof input === 'string') return truncJson(input);
|
|
167
|
+
try {
|
|
168
|
+
const obj = input as Record<string, unknown>;
|
|
169
|
+
const keys = Object.keys(obj).slice(0, 3);
|
|
170
|
+
const pairs = keys.map((k) => `${k}=${truncJson(obj[k], 20)}`);
|
|
171
|
+
const tail = Object.keys(obj).length > keys.length ? ', …' : '';
|
|
172
|
+
return pairs.join(', ') + tail;
|
|
173
|
+
} catch {
|
|
174
|
+
return truncJson(input);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Split Discord markdown into <=`limit`-char parts that each render sanely on
|
|
180
|
+
* their own (mirrors `splitForTelegram`'s shape, adapted from HTML tags to the
|
|
181
|
+
* one markdown construct that breaks when cut: a ``` code fence). Cuts prefer
|
|
182
|
+
* a newline at/under the budget; if the cut lands inside an open fence, the
|
|
183
|
+
* fence is closed at the head part's end and reopened (with its original
|
|
184
|
+
* info-string, e.g. ```diff) at the tail's start, so every part is
|
|
185
|
+
* independently valid. Parts are separate Discord messages, so the newline a
|
|
186
|
+
* cut lands on is consumed (not carried into the tail).
|
|
187
|
+
*/
|
|
188
|
+
export function splitForDiscord(text: string, limit: number = DISCORD_MESSAGE_LIMIT): string[] {
|
|
189
|
+
if (text.length <= limit) return text ? [text] : [];
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
/** The fence opener carried over from the previous part (reopened at its head). */
|
|
192
|
+
let carryFence: string | null = null;
|
|
193
|
+
let remaining = text;
|
|
194
|
+
while (remaining.length > limit) {
|
|
195
|
+
const prefix = carryFence ? carryFence + '\n' : '';
|
|
196
|
+
// The reopened fence + a possible closing fence eat into the budget.
|
|
197
|
+
const budget = Math.max(1, limit - prefix.length - '\n```'.length);
|
|
198
|
+
let idx = Math.min(budget, remaining.length);
|
|
199
|
+
const nl = remaining.lastIndexOf('\n', idx);
|
|
200
|
+
if (nl > 0) idx = nl;
|
|
201
|
+
const openFence = openFenceAt(remaining, idx, carryFence);
|
|
202
|
+
const head = prefix + remaining.slice(0, idx) + (openFence ? '\n```' : '');
|
|
203
|
+
out.push(head);
|
|
204
|
+
carryFence = openFence;
|
|
205
|
+
remaining = remaining.slice(idx);
|
|
206
|
+
// Drop the leading newline the cut left behind (it was consumed by the cut
|
|
207
|
+
// point, and the reopened fence supplies its own).
|
|
208
|
+
if (remaining.startsWith('\n')) remaining = remaining.slice(1);
|
|
209
|
+
}
|
|
210
|
+
if (remaining) out.push((carryFence ? carryFence + '\n' : '') + remaining);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Is a ``` fence open at `idx`? Returns the fence's opening line (e.g.
|
|
216
|
+
* "```diff") when open, else null. `seed` is the fence carried into this part
|
|
217
|
+
* from the previous split (the text below it starts inside that fence).
|
|
218
|
+
*/
|
|
219
|
+
function openFenceAt(text: string, idx: number, seed: string | null): string | null {
|
|
220
|
+
let open: string | null = seed;
|
|
221
|
+
const scan = text.slice(0, idx);
|
|
222
|
+
const fenceRe = /(?:^|\n)(```[^\n]*)/g;
|
|
223
|
+
let m: RegExpExecArray | null;
|
|
224
|
+
while ((m = fenceRe.exec(scan)) !== null) {
|
|
225
|
+
open = open === null ? m[1]! : null;
|
|
226
|
+
}
|
|
227
|
+
return open;
|
|
228
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound-payload validation (AGENTS.md invariant: zod-validate every inbound
|
|
3
|
+
* frame/body before it touches the session). discord.js hands us rich class
|
|
4
|
+
* instances; we extract ONLY the primitive fields the channel consumes into a
|
|
5
|
+
* plain record and validate + size-cap that, so a malformed/hostile payload is
|
|
6
|
+
* dropped at the boundary instead of flowing into handlers.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from '@moxxy/sdk';
|
|
9
|
+
import { snowflakeSchema } from './keys.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hard cap on inbound message text we accept. Discord caps user messages at
|
|
13
|
+
* 4000 chars (Nitro); anything past this is not a legitimate prompt, so we
|
|
14
|
+
* drop it rather than truncate (a silently truncated prompt is worse than a
|
|
15
|
+
* clear rejection).
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_CONTENT_CHARS = 8_000;
|
|
18
|
+
|
|
19
|
+
/** Hard cap on an inbound audio attachment we will buffer for transcription. */
|
|
20
|
+
export const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
const attachmentSchema = z.object({
|
|
23
|
+
id: snowflakeSchema,
|
|
24
|
+
url: z.string().url().max(2_048),
|
|
25
|
+
contentType: z.string().max(256).nullable(),
|
|
26
|
+
size: z.number().int().nonnegative(),
|
|
27
|
+
name: z.string().max(1_024).nullable(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const inboundMessageSchema = z.object({
|
|
31
|
+
id: snowflakeSchema,
|
|
32
|
+
/** Raw message text (may be empty for attachment-only messages). */
|
|
33
|
+
content: z.string().max(MAX_CONTENT_CHARS),
|
|
34
|
+
channelId: snowflakeSchema,
|
|
35
|
+
/** Null for DMs. */
|
|
36
|
+
guildId: snowflakeSchema.nullable(),
|
|
37
|
+
authorId: snowflakeSchema,
|
|
38
|
+
authorIsBot: z.boolean(),
|
|
39
|
+
attachments: z.array(attachmentSchema).max(16),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export type InboundMessage = z.infer<typeof inboundMessageSchema>;
|
|
43
|
+
export type InboundAttachment = z.infer<typeof attachmentSchema>;
|
|
44
|
+
|
|
45
|
+
/** The structural slice of a discord.js Message we extract fields from. */
|
|
46
|
+
export interface RawMessageLike {
|
|
47
|
+
readonly id?: unknown;
|
|
48
|
+
readonly content?: unknown;
|
|
49
|
+
readonly channelId?: unknown;
|
|
50
|
+
readonly guildId?: unknown;
|
|
51
|
+
readonly author?: { readonly id?: unknown; readonly bot?: unknown } | null;
|
|
52
|
+
readonly attachments?: {
|
|
53
|
+
values?(): IterableIterator<{
|
|
54
|
+
readonly id?: unknown;
|
|
55
|
+
readonly url?: unknown;
|
|
56
|
+
readonly contentType?: unknown;
|
|
57
|
+
readonly size?: unknown;
|
|
58
|
+
readonly name?: unknown;
|
|
59
|
+
}>;
|
|
60
|
+
} | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract + validate the fields we consume from a discord.js Message. Returns
|
|
65
|
+
* null when the payload doesn't validate (wrong shapes, oversized content,
|
|
66
|
+
* missing ids) — the caller drops it with a warning.
|
|
67
|
+
*/
|
|
68
|
+
export function extractInboundMessage(msg: RawMessageLike): InboundMessage | null {
|
|
69
|
+
const attachments: unknown[] = [];
|
|
70
|
+
try {
|
|
71
|
+
for (const a of msg.attachments?.values?.() ?? []) {
|
|
72
|
+
attachments.push({
|
|
73
|
+
id: a?.id,
|
|
74
|
+
url: a?.url,
|
|
75
|
+
contentType: a?.contentType ?? null,
|
|
76
|
+
size: a?.size,
|
|
77
|
+
name: a?.name ?? null,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const candidate = {
|
|
84
|
+
id: msg.id,
|
|
85
|
+
content: msg.content,
|
|
86
|
+
channelId: msg.channelId,
|
|
87
|
+
guildId: msg.guildId ?? null,
|
|
88
|
+
authorId: msg.author?.id,
|
|
89
|
+
authorIsBot: msg.author?.bot === true,
|
|
90
|
+
attachments,
|
|
91
|
+
};
|
|
92
|
+
const parsed = inboundMessageSchema.safeParse(candidate);
|
|
93
|
+
return parsed.success ? parsed.data : null;
|
|
94
|
+
}
|