@moxxy/plugin-telegram 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/approval.d.ts +37 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +75 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +20 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +34 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/callback-handler.d.ts +32 -0
- package/dist/channel/callback-handler.d.ts.map +1 -0
- package/dist/channel/callback-handler.js +212 -0
- package/dist/channel/callback-handler.js.map +1 -0
- package/dist/channel/frame-pump.d.ts +58 -0
- package/dist/channel/frame-pump.d.ts.map +1 -0
- package/dist/channel/frame-pump.js +159 -0
- package/dist/channel/frame-pump.js.map +1 -0
- package/dist/channel/html.d.ts +24 -0
- package/dist/channel/html.d.ts.map +1 -0
- package/dist/channel/html.js +62 -0
- package/dist/channel/html.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +66 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +147 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +21 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +36 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +47 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +214 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/text-handler.d.ts +43 -0
- package/dist/channel/text-handler.d.ts.map +1 -0
- package/dist/channel/text-handler.js +116 -0
- package/dist/channel/text-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +40 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +67 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +14 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +29 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +42 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +138 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +118 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +478 -0
- package/dist/channel.js.map +1 -0
- package/dist/channel.test-d.d.ts +2 -0
- package/dist/channel.test-d.d.ts.map +1 -0
- package/dist/channel.test-d.js +5 -0
- package/dist/channel.test-d.js.map +1 -0
- package/dist/format.d.ts +38 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +237 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +42 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +119 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +90 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +97 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +49 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +68 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/dist/setup-wizard.d.ts +21 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +140 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +91 -0
- package/src/approval.test.ts +103 -0
- package/src/approval.ts +86 -0
- package/src/channel/approval-prompt.ts +46 -0
- package/src/channel/callback-handler.test.ts +188 -0
- package/src/channel/callback-handler.ts +256 -0
- package/src/channel/frame-pump.ts +176 -0
- package/src/channel/html.test.ts +40 -0
- package/src/channel/html.ts +60 -0
- package/src/channel/pairing-handler.ts +181 -0
- package/src/channel/permission-prompt.ts +51 -0
- package/src/channel/slash-handler.ts +259 -0
- package/src/channel/text-handler.test.ts +222 -0
- package/src/channel/text-handler.ts +174 -0
- package/src/channel/turn-runner.ts +102 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +328 -0
- package/src/channel/voice-handler.ts +198 -0
- package/src/channel/voice-reply.test.ts +223 -0
- package/src/channel.test-d.ts +7 -0
- package/src/channel.ts +564 -0
- package/src/format.test.ts +159 -0
- package/src/format.ts +257 -0
- package/src/index.ts +347 -0
- package/src/keys.ts +52 -0
- package/src/pair-flow.ts +131 -0
- package/src/pairing.test.ts +113 -0
- package/src/pairing.ts +184 -0
- package/src/permission.test.ts +56 -0
- package/src/permission.ts +61 -0
- package/src/render.test.ts +350 -0
- package/src/render.ts +522 -0
- package/src/setup-wizard.ts +178 -0
- package/src/subcommands.test.ts +153 -0
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
* Pairing state machine — one mechanism, host-issued QR code.
|
|
15
|
+
*
|
|
16
|
+
* A control surface (the desktop "Channels" panel, or `moxxy channels telegram
|
|
17
|
+
* pair` in a terminal) opens a window with `beginHostIssuedPairing`: the 6-digit
|
|
18
|
+
* code is generated UP FRONT so the surface can embed it in a
|
|
19
|
+
* `t.me/<bot>?start=<code>` deep link and render that as a QR. The user scans /
|
|
20
|
+
* opens the link and taps START — the bot receives `/start <code>` — or simply
|
|
21
|
+
* sends the 6 digits as a message. `submitChatCode` matches the presented code
|
|
22
|
+
* and authorizes that chat.
|
|
23
|
+
*
|
|
24
|
+
* Why this single direction (it replaced an older bot-issues-a-code /
|
|
25
|
+
* paste-in-the-terminal flow): it works identically whether the surface is a GUI
|
|
26
|
+
* with no terminal (the desktop) or a terminal (the `pair` command renders the
|
|
27
|
+
* QR inline) — one affordance everywhere, zero manual code entry — while keeping
|
|
28
|
+
* the "prove you can see the host's screen" security property (the code only
|
|
29
|
+
* ever lived on the surface).
|
|
30
|
+
*
|
|
31
|
+
* The transitions live in `@moxxy/channel-kit` (host-code pairing, generic over
|
|
32
|
+
* the peer id — shared with future channels); this module keeps the
|
|
33
|
+
* Telegram-shaped state/decision surface and maps the kit's semantic action
|
|
34
|
+
* kinds to Telegram's user-facing messages.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type PairingPhase =
|
|
38
|
+
| 'idle'
|
|
39
|
+
// Host generated a code and is waiting for a chat to present it (via a
|
|
40
|
+
// `?start=<code>` deep link or a plain message). See `beginHostIssuedPairing`.
|
|
41
|
+
| 'awaiting-host-code'
|
|
42
|
+
| 'paired'
|
|
43
|
+
| 'expired';
|
|
44
|
+
|
|
45
|
+
export interface PairingState {
|
|
46
|
+
phase: PairingPhase;
|
|
47
|
+
code: string | null;
|
|
48
|
+
pendingChatId: number | null;
|
|
49
|
+
expiresAt: number | null;
|
|
50
|
+
authorizedChatId: number | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PairingDecision {
|
|
54
|
+
readonly state: PairingState;
|
|
55
|
+
readonly action:
|
|
56
|
+
| { kind: 'reject'; message: string }
|
|
57
|
+
| { kind: 'paired'; chatId: number }
|
|
58
|
+
| { kind: 'still-paired'; chatId: number }
|
|
59
|
+
| { kind: 'mismatch'; message: string }
|
|
60
|
+
| { kind: 'expired'; message: string }
|
|
61
|
+
| { kind: 'not-pending'; message: string };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MSG_FOREIGN_CHAT = 'This bot is paired with a different chat. Access denied.';
|
|
65
|
+
const MSG_WINDOW_OPEN_HINT =
|
|
66
|
+
'Scan the QR (or open the link) shown in moxxy, or send the 6-digit code it shows, to finish pairing.';
|
|
67
|
+
const MSG_NO_WINDOW =
|
|
68
|
+
'No pairing window is open. Start pairing from the moxxy desktop Channels panel, or run `moxxy channels telegram pair`.';
|
|
69
|
+
const MSG_NOT_PENDING = 'No pairing window is open.';
|
|
70
|
+
const MSG_EXPIRED = 'Pairing window expired. Start the channel again to retry.';
|
|
71
|
+
const MSG_MISMATCH = "Code didn't match. Check the digits shown in moxxy and try again.";
|
|
72
|
+
|
|
73
|
+
function toKit(state: PairingState): HostCodeState<number> {
|
|
74
|
+
return {
|
|
75
|
+
phase: state.phase,
|
|
76
|
+
code: state.code,
|
|
77
|
+
expiresAt: state.expiresAt,
|
|
78
|
+
authorizedPeer: state.authorizedChatId,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fromKit(state: HostCodeState<number>): PairingState {
|
|
83
|
+
return {
|
|
84
|
+
phase: state.phase,
|
|
85
|
+
code: state.code,
|
|
86
|
+
pendingChatId: null,
|
|
87
|
+
expiresAt: state.expiresAt,
|
|
88
|
+
authorizedChatId: state.authorizedPeer,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createPairingState(opts: { authorizedChatId?: number | null } = {}): PairingState {
|
|
93
|
+
return fromKit(
|
|
94
|
+
createHostCodeState<number>({
|
|
95
|
+
// Preserve the historical truthy check (a 0 chat id is not a real chat).
|
|
96
|
+
authorizedPeer: opts.authorizedChatId ? opts.authorizedChatId : null,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Open a host-issued pairing window. The code is generated immediately so the
|
|
103
|
+
* surface can embed it in the deep link / QR it shows the user.
|
|
104
|
+
*
|
|
105
|
+
* No TTL by default: the window's lifetime is the channel process's lifetime
|
|
106
|
+
* while unpaired (the surface tears it down by stopping the channel), and the
|
|
107
|
+
* security boundary is "could see the host's screen", not a clock. A caller may
|
|
108
|
+
* still pass a `ttlMs` to bound it.
|
|
109
|
+
*/
|
|
110
|
+
export function beginHostIssuedPairing(
|
|
111
|
+
state: PairingState,
|
|
112
|
+
code: string = randomCode(6),
|
|
113
|
+
now: number = Date.now(),
|
|
114
|
+
ttlMs: number | null = null,
|
|
115
|
+
): { state: PairingState; code: string } {
|
|
116
|
+
const opened = openHostCodeWindow(toKit(state), { code, now, ttlMs });
|
|
117
|
+
return { state: fromKit(opened.state), code: opened.code };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Map a kit action to the Telegram-worded decision, reusing `state` when the
|
|
121
|
+
* machine didn't transition. */
|
|
122
|
+
function mapAction(
|
|
123
|
+
state: PairingState,
|
|
124
|
+
kitState: HostCodeState<number>,
|
|
125
|
+
action: HostCodeAction<number>,
|
|
126
|
+
): PairingDecision {
|
|
127
|
+
switch (action.kind) {
|
|
128
|
+
case 'paired':
|
|
129
|
+
return { state: fromKit(kitState), action: { kind: 'paired', chatId: action.peer } };
|
|
130
|
+
case 'still-paired':
|
|
131
|
+
return { state, action: { kind: 'still-paired', chatId: action.peer } };
|
|
132
|
+
case 'rejected-foreign-peer':
|
|
133
|
+
return { state, action: { kind: 'reject', message: MSG_FOREIGN_CHAT } };
|
|
134
|
+
case 'window-open-hint':
|
|
135
|
+
return { state, action: { kind: 'reject', message: MSG_WINDOW_OPEN_HINT } };
|
|
136
|
+
case 'no-window':
|
|
137
|
+
return { state, action: { kind: 'reject', message: MSG_NO_WINDOW } };
|
|
138
|
+
case 'not-pending':
|
|
139
|
+
return { state, action: { kind: 'not-pending', message: MSG_NOT_PENDING } };
|
|
140
|
+
case 'expired':
|
|
141
|
+
return { state: fromKit(kitState), action: { kind: 'expired', message: MSG_EXPIRED } };
|
|
142
|
+
case 'mismatch':
|
|
143
|
+
return { state, action: { kind: 'mismatch', message: MSG_MISMATCH } };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Called when the bot receives a BARE `/start` (no code payload) — i.e. the user
|
|
149
|
+
* opened the chat manually rather than via the pairing deep link. The code path
|
|
150
|
+
* for a `/start <code>` payload (and plain-message codes) is `submitChatCode`.
|
|
151
|
+
*
|
|
152
|
+
* Behavior depends on phase:
|
|
153
|
+
* - paired (same chat) → still-paired (already authorized; greet)
|
|
154
|
+
* - paired (other chat) → reject (the bot is owned by someone else)
|
|
155
|
+
* - awaiting-host-code → reject with a nudge to use the QR / send the code
|
|
156
|
+
* - idle / expired → reject with a nudge to start pairing
|
|
157
|
+
*/
|
|
158
|
+
export function handleStart(state: PairingState, chatId: number): PairingDecision {
|
|
159
|
+
const decision = greetPeer(toKit(state), chatId);
|
|
160
|
+
return mapAction(state, decision.state, decision.action);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Called when a chat PRESENTS a host-issued code — either as the payload of a
|
|
165
|
+
* `/start <code>` deep link or as a plain 6-digit message. On match the chat is
|
|
166
|
+
* authorized.
|
|
167
|
+
*/
|
|
168
|
+
export function submitChatCode(
|
|
169
|
+
state: PairingState,
|
|
170
|
+
chatId: number,
|
|
171
|
+
rawCode: string,
|
|
172
|
+
now: number = Date.now(),
|
|
173
|
+
): PairingDecision {
|
|
174
|
+
const decision = submitPeerCode(toKit(state), chatId, rawCode, now);
|
|
175
|
+
return mapAction(state, decision.state, decision.action);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function isAuthorized(state: PairingState, chatId: number): boolean {
|
|
179
|
+
return isPeerAuthorized(toKit(state), chatId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function clearPairing(state: PairingState): PairingState {
|
|
183
|
+
return fromKit(clearHostCodePairing(toKit(state)));
|
|
184
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { asToolCallId } from '@moxxy/sdk';
|
|
3
|
+
import { TelegramPermissionResolver } from './permission.js';
|
|
4
|
+
|
|
5
|
+
const call = (name = 'Read') => ({ callId: asToolCallId('c1'), name, input: {} });
|
|
6
|
+
const ctx = { sessionId: 's', toolDescription: '' };
|
|
7
|
+
|
|
8
|
+
describe('TelegramPermissionResolver', () => {
|
|
9
|
+
it('denies when no decider is attached', async () => {
|
|
10
|
+
const r = new TelegramPermissionResolver();
|
|
11
|
+
expect((await r.check(call(), ctx)).mode).toBe('deny');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('routes to the decider, awaits resolvePending', async () => {
|
|
15
|
+
const r = new TelegramPermissionResolver();
|
|
16
|
+
r.setDecider(async () => {});
|
|
17
|
+
const promise = r.check(call(), ctx);
|
|
18
|
+
const resolved = r.resolvePending('c1', { mode: 'allow' });
|
|
19
|
+
expect(resolved).toBe(true);
|
|
20
|
+
expect((await promise).mode).toBe('allow');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('caches allow_session and skips the decider on subsequent calls', async () => {
|
|
24
|
+
const r = new TelegramPermissionResolver();
|
|
25
|
+
const decider = vi.fn(async () => {});
|
|
26
|
+
r.setDecider(decider);
|
|
27
|
+
const p1 = r.check(call('Bash'), ctx);
|
|
28
|
+
r.resolvePending('c1', { mode: 'allow_session' });
|
|
29
|
+
expect((await p1).mode).toBe('allow_session');
|
|
30
|
+
|
|
31
|
+
// Second call to Bash should not hit the decider
|
|
32
|
+
const p2 = r.check(call('Bash'), ctx);
|
|
33
|
+
expect((await p2).mode).toBe('allow_session');
|
|
34
|
+
expect(decider).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('abortAll resolves pending checks as deny', async () => {
|
|
38
|
+
const r = new TelegramPermissionResolver();
|
|
39
|
+
r.setDecider(async () => {});
|
|
40
|
+
const promise = r.check(call(), ctx);
|
|
41
|
+
r.abortAll('shutdown');
|
|
42
|
+
const decision = await promise;
|
|
43
|
+
expect(decision.mode).toBe('deny');
|
|
44
|
+
expect(decision.reason).toContain('shutdown');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('decider throw → deny with the error message', async () => {
|
|
48
|
+
const r = new TelegramPermissionResolver();
|
|
49
|
+
r.setDecider(async () => {
|
|
50
|
+
throw new Error('boom');
|
|
51
|
+
});
|
|
52
|
+
const decision = await r.check(call(), ctx);
|
|
53
|
+
expect(decision.mode).toBe('deny');
|
|
54
|
+
expect(decision.reason).toContain('boom');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { PendingToolCall, PermissionContext, PermissionDecision, PermissionResolver } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
export interface PendingPermission {
|
|
4
|
+
readonly callId: string;
|
|
5
|
+
readonly call: PendingToolCall;
|
|
6
|
+
readonly ctx: PermissionContext;
|
|
7
|
+
readonly resolve: (decision: PermissionDecision) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Permission resolver that defers each tool call to an external decider (the
|
|
12
|
+
* Telegram bot, which renders an inline keyboard and waits for a callback).
|
|
13
|
+
* The Telegram channel registers a callback handler that invokes
|
|
14
|
+
* `resolvePending(callId, decision)` when the user clicks a button.
|
|
15
|
+
*/
|
|
16
|
+
export class TelegramPermissionResolver implements PermissionResolver {
|
|
17
|
+
readonly name = 'telegram';
|
|
18
|
+
private readonly pending = new Map<string, PendingPermission>();
|
|
19
|
+
private readonly sessionAllows = new Set<string>();
|
|
20
|
+
private deciderFn:
|
|
21
|
+
| ((call: PendingToolCall, ctx: PermissionContext) => Promise<void>)
|
|
22
|
+
| null = null;
|
|
23
|
+
|
|
24
|
+
setDecider(fn: (call: PendingToolCall, ctx: PermissionContext) => Promise<void>): void {
|
|
25
|
+
this.deciderFn = fn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async check(call: PendingToolCall, ctx: PermissionContext): Promise<PermissionDecision> {
|
|
29
|
+
if (this.sessionAllows.has(call.name)) {
|
|
30
|
+
return { mode: 'allow_session', reason: 'allow_session previously granted' };
|
|
31
|
+
}
|
|
32
|
+
if (!this.deciderFn) {
|
|
33
|
+
return { mode: 'deny', reason: 'no decider attached (bot not running)' };
|
|
34
|
+
}
|
|
35
|
+
const decision = await new Promise<PermissionDecision>((resolve) => {
|
|
36
|
+
this.pending.set(call.callId, { callId: call.callId, call, ctx, resolve });
|
|
37
|
+
this.deciderFn!(call, ctx).catch((err) => {
|
|
38
|
+
this.pending.delete(call.callId);
|
|
39
|
+
resolve({ mode: 'deny', reason: err instanceof Error ? err.message : String(err) });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
if (decision.mode === 'allow_session') this.sessionAllows.add(call.name);
|
|
43
|
+
return decision;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Called by the bot when a permission callback button is clicked. */
|
|
47
|
+
resolvePending(callId: string, decision: PermissionDecision): boolean {
|
|
48
|
+
const pending = this.pending.get(callId);
|
|
49
|
+
if (!pending) return false;
|
|
50
|
+
this.pending.delete(callId);
|
|
51
|
+
pending.resolve(decision);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
abortAll(reason = 'channel closed'): void {
|
|
56
|
+
for (const pending of this.pending.values()) {
|
|
57
|
+
pending.resolve({ mode: 'deny', reason });
|
|
58
|
+
}
|
|
59
|
+
this.pending.clear();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { asPluginId, asSessionId, asToolCallId, asTurnId, type MoxxyEvent } from '@moxxy/sdk';
|
|
3
|
+
import { splitForTelegram, TurnRenderer } from './render.js';
|
|
4
|
+
import { composeFrame } from './channel/html.js';
|
|
5
|
+
import type { FileDiffDisplay } from '@moxxy/sdk';
|
|
6
|
+
|
|
7
|
+
const sid = asSessionId('s');
|
|
8
|
+
const tid = asTurnId('t');
|
|
9
|
+
const c1 = asToolCallId('c1');
|
|
10
|
+
const baseEvent = (e: Partial<MoxxyEvent> & Pick<MoxxyEvent, 'type'>, seq = 0): MoxxyEvent =>
|
|
11
|
+
({
|
|
12
|
+
sessionId: sid,
|
|
13
|
+
turnId: tid,
|
|
14
|
+
source: 'system',
|
|
15
|
+
id: `e${seq}` as never,
|
|
16
|
+
seq,
|
|
17
|
+
ts: 0,
|
|
18
|
+
...e,
|
|
19
|
+
}) as MoxxyEvent;
|
|
20
|
+
|
|
21
|
+
describe('TurnRenderer', () => {
|
|
22
|
+
it('accumulates assistant chunks into the body', () => {
|
|
23
|
+
const r = new TurnRenderer();
|
|
24
|
+
r.accept(baseEvent({ type: 'assistant_chunk', delta: 'hello ', source: 'model' }, 0));
|
|
25
|
+
const second = r.accept(baseEvent({ type: 'assistant_chunk', delta: 'world', source: 'model' }, 1));
|
|
26
|
+
expect(second.body).toBe('hello world');
|
|
27
|
+
expect(second.activityHtml).toBe('');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('replaces chunks with the final assistant_message on stop', () => {
|
|
31
|
+
const r = new TurnRenderer();
|
|
32
|
+
r.accept(baseEvent({ type: 'assistant_chunk', delta: 'partial', source: 'model' }, 0));
|
|
33
|
+
const final = r.accept(
|
|
34
|
+
baseEvent({ type: 'assistant_message', content: 'final', stopReason: 'end_turn', source: 'model' }, 1),
|
|
35
|
+
);
|
|
36
|
+
expect(final.body).toBe('final');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('emits tool entries with status badges on tool_call_requested + tool_result', () => {
|
|
40
|
+
const r = new TurnRenderer();
|
|
41
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: c1, name: 'Read', input: { path: '/a' }, source: 'model' }, 0));
|
|
42
|
+
const after = r.accept(baseEvent({ type: 'tool_result', callId: c1, ok: true, output: 'ok', source: 'tool' }, 1));
|
|
43
|
+
expect(after.activityHtml).toContain('<code>Read</code>');
|
|
44
|
+
expect(after.activityHtml).toContain('path=');
|
|
45
|
+
expect(after.activityHtml).toContain('✓');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('marks failed tool calls with the error badge', () => {
|
|
49
|
+
const r = new TurnRenderer();
|
|
50
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: c1, name: 'Read', input: {}, source: 'model' }, 0));
|
|
51
|
+
const after = r.accept(
|
|
52
|
+
baseEvent({
|
|
53
|
+
type: 'tool_result',
|
|
54
|
+
callId: c1,
|
|
55
|
+
ok: false,
|
|
56
|
+
error: { kind: 'threw', message: 'boom' },
|
|
57
|
+
source: 'tool',
|
|
58
|
+
}, 1),
|
|
59
|
+
);
|
|
60
|
+
expect(after.activityHtml).toContain('✗');
|
|
61
|
+
expect(after.activityHtml).toContain('threw');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders a file-diff tool_result as a diff fence + summary in the composed frame', () => {
|
|
65
|
+
const r = new TurnRenderer();
|
|
66
|
+
const display: FileDiffDisplay = {
|
|
67
|
+
kind: 'file-diff',
|
|
68
|
+
path: 'src/app.ts',
|
|
69
|
+
mode: 'update',
|
|
70
|
+
added: 2,
|
|
71
|
+
removed: 1,
|
|
72
|
+
hunks: [
|
|
73
|
+
{
|
|
74
|
+
oldStart: 10,
|
|
75
|
+
oldLines: 2,
|
|
76
|
+
newStart: 10,
|
|
77
|
+
newLines: 3,
|
|
78
|
+
lines: [
|
|
79
|
+
{ kind: 'context', text: 'const a = 1;', oldNo: 10, newNo: 10 },
|
|
80
|
+
{ kind: 'del', text: 'const b = 2;', oldNo: 11 },
|
|
81
|
+
{ kind: 'add', text: 'const b = 3;', newNo: 11 },
|
|
82
|
+
{ kind: 'add', text: 'const c = 4;', newNo: 12 },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: c1, name: 'Edit', input: { file_path: 'src/app.ts' }, source: 'model' }, 0));
|
|
88
|
+
const frame = r.accept(
|
|
89
|
+
baseEvent({ type: 'tool_result', callId: c1, ok: true, output: { forModel: 'edited', display }, source: 'tool' }, 1),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Frame carries the pre-formatted diff HTML.
|
|
93
|
+
expect(frame.diffHtml).toContain('<pre><code class="language-diff">');
|
|
94
|
+
expect(frame.diffHtml).toContain('@@ -10,2 +10,3 @@');
|
|
95
|
+
expect(frame.diffHtml).toContain('-const b = 2;');
|
|
96
|
+
expect(frame.diffHtml).toContain('+const b = 3;');
|
|
97
|
+
expect(frame.diffHtml).toContain('<b>Update src/app.ts</b>');
|
|
98
|
+
expect(frame.diffHtml).toContain('Added 2 lines, removed 1 line');
|
|
99
|
+
|
|
100
|
+
// Composed frame includes the diff block after the (empty) body.
|
|
101
|
+
const composed = composeFrame(frame);
|
|
102
|
+
expect(composed).toContain('language-diff');
|
|
103
|
+
expect(composed).toContain('@@ -10,2 +10,3 @@');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('shows only the summary line for a truncated file-diff with no hunks', () => {
|
|
107
|
+
const r = new TurnRenderer();
|
|
108
|
+
const display: FileDiffDisplay = {
|
|
109
|
+
kind: 'file-diff',
|
|
110
|
+
path: 'big.json',
|
|
111
|
+
mode: 'create',
|
|
112
|
+
added: 5000,
|
|
113
|
+
removed: 0,
|
|
114
|
+
hunks: [],
|
|
115
|
+
truncated: true,
|
|
116
|
+
};
|
|
117
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: c1, name: 'Write', input: { file_path: 'big.json' }, source: 'model' }, 0));
|
|
118
|
+
const frame = r.accept(
|
|
119
|
+
baseEvent({ type: 'tool_result', callId: c1, ok: true, output: { forModel: 'wrote', display }, source: 'tool' }, 1),
|
|
120
|
+
);
|
|
121
|
+
expect(frame.diffHtml).toContain('<b>Create big.json</b>');
|
|
122
|
+
expect(frame.diffHtml).toContain('Added 5000 lines');
|
|
123
|
+
expect(frame.diffHtml).toContain('(diff truncated)');
|
|
124
|
+
// No empty code block when there are no hunks.
|
|
125
|
+
expect(frame.diffHtml).not.toContain('<pre>');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('escapes HTML special chars inside the diff fence body', () => {
|
|
129
|
+
const r = new TurnRenderer();
|
|
130
|
+
const display: FileDiffDisplay = {
|
|
131
|
+
kind: 'file-diff',
|
|
132
|
+
path: 'x.tsx',
|
|
133
|
+
mode: 'update',
|
|
134
|
+
added: 1,
|
|
135
|
+
removed: 0,
|
|
136
|
+
hunks: [
|
|
137
|
+
{
|
|
138
|
+
oldStart: 1,
|
|
139
|
+
oldLines: 0,
|
|
140
|
+
newStart: 1,
|
|
141
|
+
newLines: 1,
|
|
142
|
+
lines: [{ kind: 'add', text: 'const x = <Foo a={b && c} />;', newNo: 1 }],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: c1, name: 'Edit', input: {}, source: 'model' }, 0));
|
|
147
|
+
const frame = r.accept(
|
|
148
|
+
baseEvent({ type: 'tool_result', callId: c1, ok: true, output: { forModel: 'ok', display }, source: 'tool' }, 1),
|
|
149
|
+
);
|
|
150
|
+
expect(frame.diffHtml).toContain('+const x = <Foo a={b && c} />;');
|
|
151
|
+
expect(frame.diffHtml).not.toContain('<Foo');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('appends an error footer when an error event arrives', () => {
|
|
155
|
+
const r = new TurnRenderer();
|
|
156
|
+
const out = r.accept(baseEvent({ type: 'error', kind: 'fatal', message: 'boom', source: 'system' }, 0));
|
|
157
|
+
expect(out.errorHtml).toContain('fatal');
|
|
158
|
+
expect(out.errorHtml).toContain('boom');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('reports hasUpdate=false when the same event yields the same frame', () => {
|
|
162
|
+
const r = new TurnRenderer();
|
|
163
|
+
r.accept(baseEvent({ type: 'assistant_chunk', delta: 'x', source: 'model' }, 0));
|
|
164
|
+
const second = r.accept(baseEvent({ type: 'plugin_event', pluginId: asPluginId('p'), subtype: 'noop', payload: null, source: 'plugin' }, 1));
|
|
165
|
+
expect(second.hasUpdate).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Push `n` tool calls (request + ok result) into a renderer.
|
|
169
|
+
const withTools = (n: number): TurnRenderer => {
|
|
170
|
+
const r = new TurnRenderer();
|
|
171
|
+
for (let i = 0; i < n; i++) {
|
|
172
|
+
const id = asToolCallId(`c${i}`);
|
|
173
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: id, name: `Tool${i}`, input: {}, source: 'model' }, i * 2));
|
|
174
|
+
r.accept(baseEvent({ type: 'tool_result', callId: id, ok: true, output: 'ok', source: 'tool' }, i * 2 + 1));
|
|
175
|
+
}
|
|
176
|
+
return r;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
it('folds a long activity trace into an expandable box with a summary on the final frame', () => {
|
|
180
|
+
const r = withTools(4);
|
|
181
|
+
const snap = r.snapshot({ collapse: true });
|
|
182
|
+
expect(snap.activityHtml).toContain('<blockquote expandable>');
|
|
183
|
+
expect(snap.activityHtml).toContain('🔧 <b>4 steps</b>');
|
|
184
|
+
// The tool lines are still in the (collapsed) box.
|
|
185
|
+
expect(snap.activityHtml).toContain('<code>Tool3</code>');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('keeps the activity open (no expandable) while streaming', () => {
|
|
189
|
+
const r = withTools(5);
|
|
190
|
+
const snap = r.snapshot(); // default collapse=false
|
|
191
|
+
expect(snap.activityHtml).toContain('<blockquote>');
|
|
192
|
+
expect(snap.activityHtml).not.toContain('expandable');
|
|
193
|
+
expect(snap.activityHtml).not.toContain('🔧');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('keeps a short activity trace inline even on the final frame', () => {
|
|
197
|
+
const r = withTools(2); // below the collapse threshold
|
|
198
|
+
const snap = r.snapshot({ collapse: true });
|
|
199
|
+
expect(snap.activityHtml).toContain('<blockquote>');
|
|
200
|
+
expect(snap.activityHtml).not.toContain('expandable');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('uses singular "step" for a single-tool collapsed trace', () => {
|
|
204
|
+
// One tool + a skill banner + 2 notices = 4 lines, enough to collapse,
|
|
205
|
+
// but only ONE tool call → "1 step".
|
|
206
|
+
const r = new TurnRenderer();
|
|
207
|
+
r.accept(baseEvent({ type: 'skill_invoked', name: 'demo', source: 'system' }, 0));
|
|
208
|
+
const id = asToolCallId('only');
|
|
209
|
+
r.accept(baseEvent({ type: 'tool_call_requested', callId: id, name: 'Read', input: {}, source: 'model' }, 1));
|
|
210
|
+
r.accept(baseEvent({ type: 'tool_result', callId: id, ok: true, output: 'ok', source: 'tool' }, 2));
|
|
211
|
+
r.accept(baseEvent({ type: 'skill_created', name: 's1', source: 'system' }, 3));
|
|
212
|
+
r.accept(baseEvent({ type: 'skill_created', name: 's2', source: 'system' }, 4));
|
|
213
|
+
const snap = r.snapshot({ collapse: true });
|
|
214
|
+
expect(snap.activityHtml).toContain('<blockquote expandable>');
|
|
215
|
+
expect(snap.activityHtml).toContain('🔧 <b>1 step</b>');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('splitForTelegram', () => {
|
|
220
|
+
it('returns one chunk when under the limit', () => {
|
|
221
|
+
expect(splitForTelegram('hello')).toEqual(['hello']);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('splits at newline preference when over the limit', () => {
|
|
225
|
+
const text = 'a'.repeat(2000) + '\n' + 'b'.repeat(2000) + '\n' + 'c'.repeat(2000);
|
|
226
|
+
const parts = splitForTelegram(text, 2500);
|
|
227
|
+
expect(parts.length).toBeGreaterThan(1);
|
|
228
|
+
for (const part of parts) expect(part.length).toBeLessThanOrEqual(2500);
|
|
229
|
+
expect(parts.join('')).toBe(text);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// A minimal balanced-tag check: every opened tag (non-self-closing) is closed
|
|
233
|
+
// and no `<` / `&` is left dangling without its `>` / `;` terminator.
|
|
234
|
+
const isBalancedHtml = (html: string): boolean => {
|
|
235
|
+
const stack: string[] = [];
|
|
236
|
+
const re = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)[^>]*?>/g;
|
|
237
|
+
let m: RegExpExecArray | null;
|
|
238
|
+
let lastIndex = 0;
|
|
239
|
+
while ((m = re.exec(html))) {
|
|
240
|
+
// No bare '<' between the previous tag and this one.
|
|
241
|
+
if (html.slice(lastIndex, m.index).includes('<')) return false;
|
|
242
|
+
lastIndex = re.lastIndex;
|
|
243
|
+
const closing = m[1] === '/';
|
|
244
|
+
const name = m[2]!.toLowerCase();
|
|
245
|
+
if (m[0].endsWith('/>')) continue;
|
|
246
|
+
if (closing) {
|
|
247
|
+
if (stack.pop() !== name) return false;
|
|
248
|
+
} else {
|
|
249
|
+
stack.push(name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (html.slice(lastIndex).includes('<')) return false;
|
|
253
|
+
if (stack.length !== 0) return false;
|
|
254
|
+
// No truncated entity: every '&'-prefixed entity-looking run is `;`-terminated.
|
|
255
|
+
for (const em of html.matchAll(/&[a-zA-Z0-9#]+/g)) {
|
|
256
|
+
const after = html[em.index! + em[0].length];
|
|
257
|
+
if (after !== ';') return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
it('never cuts inside an HTML tag (each part is balanced HTML)', () => {
|
|
263
|
+
// Force the limit to land right where a tag straddles the boundary. Sweep
|
|
264
|
+
// limits comfortably larger than any single tag (a 4-char cap can't hold
|
|
265
|
+
// even `<b>`; the real Telegram cap is 4000).
|
|
266
|
+
const html = '<b>' + 'x'.repeat(50) + '</b>' + '<i>' + 'y'.repeat(50) + '</i>';
|
|
267
|
+
for (let limit = 4; limit < html.length; limit++) {
|
|
268
|
+
const parts = splitForTelegram(html, limit);
|
|
269
|
+
for (const part of parts) {
|
|
270
|
+
expect(isBalancedHtml(part)).toBe(true);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('closes and reopens a <pre><code> diff fence across the cut (no broken fence)', () => {
|
|
276
|
+
const body = 'd'.repeat(120);
|
|
277
|
+
const html =
|
|
278
|
+
'before\n\n<pre><code class="language-diff">' + body + '</code></pre>\n\nafter';
|
|
279
|
+
const parts = splitForTelegram(html, 60);
|
|
280
|
+
expect(parts.length).toBeGreaterThan(1);
|
|
281
|
+
for (const part of parts) {
|
|
282
|
+
expect(isBalancedHtml(part)).toBe(true);
|
|
283
|
+
}
|
|
284
|
+
// The fence content survives once we strip the reopened/closed wrapper tags.
|
|
285
|
+
const recombined = parts.join('').replace(/<\/?(pre|code)[^>]*>/g, '');
|
|
286
|
+
expect(recombined).toContain(body);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('never cuts inside an & entity', () => {
|
|
290
|
+
const html = 'a'.repeat(30) + '&' + 'b'.repeat(30);
|
|
291
|
+
for (let limit = 10; limit < html.length; limit++) {
|
|
292
|
+
const parts = splitForTelegram(html, limit);
|
|
293
|
+
for (const part of parts) expect(isBalancedHtml(part)).toBe(true);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('stays linear and correct on a large tag-dense diff fence', () => {
|
|
298
|
+
// Worst case for the old O(n^2) prefix re-scan: a long fenced block with no
|
|
299
|
+
// newline-free run. The single-pass boundary index must keep this fast and
|
|
300
|
+
// still produce balanced, reconstructable parts.
|
|
301
|
+
const body = ('x'.repeat(40) + '\n').repeat(2000); // ~82KB, tag-dense via fence
|
|
302
|
+
const html = '<pre><code class="language-diff">' + body + '</code></pre>';
|
|
303
|
+
const start = Date.now();
|
|
304
|
+
const parts = splitForTelegram(html, 4000);
|
|
305
|
+
const elapsed = Date.now() - start;
|
|
306
|
+
expect(parts.length).toBeGreaterThan(1);
|
|
307
|
+
for (const part of parts) {
|
|
308
|
+
expect(part.length).toBeLessThanOrEqual(4000 + 64); // soft cap + reopen/close margin
|
|
309
|
+
expect(isBalancedHtml(part)).toBe(true);
|
|
310
|
+
}
|
|
311
|
+
// The fence body survives once the reopened/closed wrapper tags are stripped.
|
|
312
|
+
const recombined = parts.join('').replace(/<\/?(pre|code)[^>]*>/g, '');
|
|
313
|
+
expect(recombined).toContain('x'.repeat(40));
|
|
314
|
+
// Generous bound — the old quadratic path blew well past this on 82KB.
|
|
315
|
+
expect(elapsed).toBeLessThan(2000);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('reopens a hyphenated <tg-spoiler> across a cut (never emits </tg>)', () => {
|
|
319
|
+
const body = 'b'.repeat(60);
|
|
320
|
+
const html = 'a'.repeat(40) + '<tg-spoiler>' + body + '</tg-spoiler>' + 'c'.repeat(40);
|
|
321
|
+
let cut = false;
|
|
322
|
+
for (let limit = 30; limit < html.length; limit++) {
|
|
323
|
+
const parts = splitForTelegram(html, limit);
|
|
324
|
+
if (parts.length > 1) cut = true;
|
|
325
|
+
for (const part of parts) {
|
|
326
|
+
expect(isBalancedHtml(part)).toBe(true);
|
|
327
|
+
// The buggy path closed the spoiler with the truncated `</tg>`.
|
|
328
|
+
expect(part).not.toContain('</tg>');
|
|
329
|
+
}
|
|
330
|
+
// The spoiler body survives once the wrapper tags are stripped.
|
|
331
|
+
const recombined = parts.join('').replace(/<\/?tg-spoiler>/g, '');
|
|
332
|
+
expect(recombined).toContain(body);
|
|
333
|
+
}
|
|
334
|
+
expect(cut).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('closes and reopens a <blockquote expandable> across the cut', () => {
|
|
338
|
+
// No newlines inside, body far over the limit → the box itself must split.
|
|
339
|
+
const html = '<blockquote expandable>' + 'q'.repeat(300) + '</blockquote>';
|
|
340
|
+
const parts = splitForTelegram(html, 100);
|
|
341
|
+
expect(parts.length).toBeGreaterThan(1);
|
|
342
|
+
for (const part of parts) expect(isBalancedHtml(part)).toBe(true);
|
|
343
|
+
// Every continuation head reopens the box WITH its `expandable` attribute.
|
|
344
|
+
for (const tail of parts.slice(1)) {
|
|
345
|
+
expect(tail.startsWith('<blockquote expandable>')).toBe(true);
|
|
346
|
+
}
|
|
347
|
+
const recombined = parts.join('').replace(/<\/?blockquote[^>]*>/g, '');
|
|
348
|
+
expect(recombined).toContain('q'.repeat(300));
|
|
349
|
+
});
|
|
350
|
+
});
|