@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Context } from 'grammy';
|
|
3
|
+
import type { ChannelHandle } from '@moxxy/sdk';
|
|
4
|
+
import { Session } from '@moxxy/core';
|
|
5
|
+
import {
|
|
6
|
+
handleTextMessage,
|
|
7
|
+
type TextHandlerCallbacks,
|
|
8
|
+
type TextHandlerDeps,
|
|
9
|
+
type TextHandlerState,
|
|
10
|
+
} from './text-handler.js';
|
|
11
|
+
import type { AwaitingApprovalText } from './callback-handler.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Worst-case regression coverage for the text-handler — the primary inbound
|
|
15
|
+
* path. Since the fire-and-track dispatch change (grammy poll loop no longer
|
|
16
|
+
* parked on a turn), the load-bearing invariants this handler enforces are:
|
|
17
|
+
*
|
|
18
|
+
* - an unpaired chat's text is refused (authorization gate),
|
|
19
|
+
* - `/cancel` aborts the live per-turn controller EVEN while busy (the whole
|
|
20
|
+
* point of the deadlock fix — without it /cancel sat undelivered),
|
|
21
|
+
* - a plain prompt is refused while a turn is in flight (single-flight guard),
|
|
22
|
+
* - awaiting-approval-text is captured BEFORE the busy guard (so the user can
|
|
23
|
+
* answer a plan-execute "Redraft" prompt while the strategy is still
|
|
24
|
+
* technically mid-turn, pending on us),
|
|
25
|
+
* - a stale approval-text submission degrades to a clear reply, not a throw.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const makeSession = (): Session => new Session({ cwd: '/tmp', silent: true });
|
|
29
|
+
|
|
30
|
+
const fakeCtx = (over: { chatId?: number; text?: string } = {}) => {
|
|
31
|
+
const replies: string[] = [];
|
|
32
|
+
const reply = vi.fn(async (text: string) => {
|
|
33
|
+
replies.push(text);
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
ctx: {
|
|
37
|
+
chat: { id: over.chatId ?? 42 },
|
|
38
|
+
message: { text: over.text ?? 'hello' },
|
|
39
|
+
reply,
|
|
40
|
+
} as unknown as Context,
|
|
41
|
+
replies,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface Harness {
|
|
46
|
+
state: TextHandlerState;
|
|
47
|
+
deps: TextHandlerDeps;
|
|
48
|
+
cb: TextHandlerCallbacks;
|
|
49
|
+
runUserTurn: ReturnType<typeof vi.fn>;
|
|
50
|
+
resolvePendingWithText: ReturnType<typeof vi.fn>;
|
|
51
|
+
setAwaitingApprovalText: ReturnType<typeof vi.fn>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const makeHarness = (over: Partial<TextHandlerState> = {}): Harness => {
|
|
55
|
+
const runUserTurn = vi.fn(async () => {});
|
|
56
|
+
const resolvePendingWithText = vi.fn(() => true);
|
|
57
|
+
const setAwaitingApprovalText = vi.fn();
|
|
58
|
+
const state: TextHandlerState = {
|
|
59
|
+
session: makeSession(),
|
|
60
|
+
model: undefined,
|
|
61
|
+
activeModelOverride: null,
|
|
62
|
+
yolo: false,
|
|
63
|
+
busy: false,
|
|
64
|
+
turnController: null,
|
|
65
|
+
awaitingApprovalText: null,
|
|
66
|
+
handle: null,
|
|
67
|
+
...over,
|
|
68
|
+
};
|
|
69
|
+
const deps: TextHandlerDeps = {
|
|
70
|
+
pairing: { isAuthorized: () => true } as never,
|
|
71
|
+
approvalResolver: {
|
|
72
|
+
resolvePendingWithText,
|
|
73
|
+
abortAll: vi.fn(),
|
|
74
|
+
} as never,
|
|
75
|
+
permissionResolver: { abortAll: vi.fn() } as never,
|
|
76
|
+
framePump: { resetRenderer: vi.fn() } as never,
|
|
77
|
+
};
|
|
78
|
+
const tryHostPair = vi.fn(async () => false);
|
|
79
|
+
const cb: TextHandlerCallbacks = {
|
|
80
|
+
setAwaitingApprovalText,
|
|
81
|
+
toggleYolo: () => false,
|
|
82
|
+
setYolo: () => undefined,
|
|
83
|
+
runUserTurn,
|
|
84
|
+
tryHostPair,
|
|
85
|
+
};
|
|
86
|
+
return { state, deps, cb, runUserTurn, resolvePendingWithText, setAwaitingApprovalText, tryHostPair };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
describe('handleTextMessage — authorization gate', () => {
|
|
90
|
+
it('refuses an unpaired chat without running a turn', async () => {
|
|
91
|
+
const { ctx, replies } = fakeCtx({ chatId: 666, text: 'do a thing' });
|
|
92
|
+
const h = makeHarness();
|
|
93
|
+
h.deps = { ...h.deps, pairing: { isAuthorized: (id: number) => id !== 666 } as never };
|
|
94
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
95
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
96
|
+
expect(replies.some((r) => /paired/i.test(r))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('ignores a message with no chat id or no text (degrade, never throw)', async () => {
|
|
100
|
+
const h = makeHarness();
|
|
101
|
+
const noChat = { message: { text: 'hi' }, reply: vi.fn() } as unknown as Context;
|
|
102
|
+
await expect(handleTextMessage(noChat, h.state, h.deps, h.cb)).resolves.toBeUndefined();
|
|
103
|
+
const noText = { chat: { id: 1 }, message: {}, reply: vi.fn() } as unknown as Context;
|
|
104
|
+
await expect(handleTextMessage(noText, h.state, h.deps, h.cb)).resolves.toBeUndefined();
|
|
105
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('handleTextMessage — /cancel (interrupt while busy)', () => {
|
|
110
|
+
it('aborts the live per-turn controller even while a turn is in flight', async () => {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const { ctx, replies } = fakeCtx({ text: '/cancel' });
|
|
113
|
+
const h = makeHarness({ busy: true, turnController: controller });
|
|
114
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
115
|
+
expect(controller.signal.aborted).toBe(true);
|
|
116
|
+
expect(replies.some((r) => /cancel/i.test(r))).toBe(true);
|
|
117
|
+
// /cancel must not start a new turn.
|
|
118
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('replies "nothing to cancel" when no turn is running', async () => {
|
|
122
|
+
const { ctx, replies } = fakeCtx({ text: '/cancel' });
|
|
123
|
+
const h = makeHarness({ busy: false, turnController: null });
|
|
124
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
125
|
+
expect(replies.some((r) => /nothing to cancel/i.test(r))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('does not re-abort an already-aborted controller', async () => {
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
controller.abort('earlier');
|
|
131
|
+
const spy = vi.spyOn(controller, 'abort');
|
|
132
|
+
const { ctx, replies } = fakeCtx({ text: '/cancel' });
|
|
133
|
+
const h = makeHarness({ busy: true, turnController: controller });
|
|
134
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
135
|
+
expect(spy).not.toHaveBeenCalled();
|
|
136
|
+
expect(replies.some((r) => /nothing to cancel/i.test(r))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('handleTextMessage — single-flight busy guard', () => {
|
|
141
|
+
it('refuses a plain prompt while a turn is in flight (no second turn)', async () => {
|
|
142
|
+
const { ctx, replies } = fakeCtx({ text: 'another prompt' });
|
|
143
|
+
const h = makeHarness({ busy: true });
|
|
144
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
145
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
146
|
+
expect(replies.some((r) => /still working/i.test(r))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('runs a turn when idle', async () => {
|
|
150
|
+
const { ctx } = fakeCtx({ text: 'do the thing' });
|
|
151
|
+
const h = makeHarness({ busy: false });
|
|
152
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
153
|
+
expect(h.runUserTurn).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(h.runUserTurn).toHaveBeenCalledWith(ctx, 42, 'do the thing');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('handleTextMessage — awaiting approval text (captured BEFORE busy)', () => {
|
|
159
|
+
it('captures the next message as approval text even while busy, and clears the latch', async () => {
|
|
160
|
+
const awaiting: AwaitingApprovalText = { approvalId: 'appr_1', optionId: 'redraft' };
|
|
161
|
+
const { ctx, replies } = fakeCtx({ text: 'please tighten the plan' });
|
|
162
|
+
// busy:true with no controller — the awaiting branch must win BEFORE the
|
|
163
|
+
// busy refusal so the user can actually answer the prompt.
|
|
164
|
+
const h = makeHarness({ busy: true, awaitingApprovalText: awaiting });
|
|
165
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
166
|
+
// Latch cleared first (so a thrown resolver can't re-enter it).
|
|
167
|
+
expect(h.setAwaitingApprovalText).toHaveBeenCalledWith(null);
|
|
168
|
+
expect(h.resolvePendingWithText).toHaveBeenCalledWith(
|
|
169
|
+
'appr_1',
|
|
170
|
+
'redraft',
|
|
171
|
+
'please tighten the plan',
|
|
172
|
+
);
|
|
173
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
174
|
+
expect(replies.some((r) => /submitted/i.test(r))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('replies gracefully when the awaited approval is no longer pending', async () => {
|
|
178
|
+
const awaiting: AwaitingApprovalText = { approvalId: 'appr_old', optionId: 'redraft' };
|
|
179
|
+
const { ctx, replies } = fakeCtx({ text: 'late text' });
|
|
180
|
+
const h = makeHarness({ awaitingApprovalText: awaiting });
|
|
181
|
+
h.resolvePendingWithText.mockReturnValueOnce(false);
|
|
182
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
183
|
+
expect(replies.some((r) => /no longer pending/i.test(r))).toBe(true);
|
|
184
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does NOT treat /cancel as approval text — the awaiting latch is cleared and /cancel still cancels', async () => {
|
|
188
|
+
// A user who clicked a text-requesting option but then types /cancel: the
|
|
189
|
+
// approval text capture consumes the message (it is the awaited follow-up),
|
|
190
|
+
// so /cancel becomes the redraft text rather than a turn cancel. We assert
|
|
191
|
+
// the documented behaviour: awaiting-text wins, the latch is cleared, and
|
|
192
|
+
// no turn is started — so the channel can't be left in a corrupt state.
|
|
193
|
+
const awaiting: AwaitingApprovalText = { approvalId: 'appr_2', optionId: 'redraft' };
|
|
194
|
+
const { ctx } = fakeCtx({ text: '/cancel' });
|
|
195
|
+
const h = makeHarness({ awaitingApprovalText: awaiting });
|
|
196
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
197
|
+
expect(h.setAwaitingApprovalText).toHaveBeenCalledWith(null);
|
|
198
|
+
expect(h.resolvePendingWithText).toHaveBeenCalledWith('appr_2', 'redraft', '/cancel');
|
|
199
|
+
expect(h.runUserTurn).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('handleTextMessage — /exit teardown', () => {
|
|
204
|
+
it('routes /exit through the registry and stops the channel handle', async () => {
|
|
205
|
+
const stop = vi.fn(async () => {});
|
|
206
|
+
const handle = { running: Promise.resolve(), stop } as unknown as ChannelHandle;
|
|
207
|
+
const session = makeSession();
|
|
208
|
+
// Register an /exit command that yields a session-action the handler maps
|
|
209
|
+
// to handle.stop — the real teardown path.
|
|
210
|
+
session.commands.register({
|
|
211
|
+
name: 'exit',
|
|
212
|
+
description: 'close',
|
|
213
|
+
channels: ['telegram'],
|
|
214
|
+
handler: async () => ({ kind: 'session-action', action: 'exit', notice: 'bye' }),
|
|
215
|
+
});
|
|
216
|
+
const { ctx, replies } = fakeCtx({ text: '/exit' });
|
|
217
|
+
const h = makeHarness({ session, handle });
|
|
218
|
+
await handleTextMessage(ctx, h.state, h.deps, h.cb);
|
|
219
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(replies.some((r) => /bye/i.test(r))).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Context } from 'grammy';
|
|
2
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
3
|
+
import type { TelegramApprovalResolver } from '../approval.js';
|
|
4
|
+
import type { TelegramPermissionResolver } from '../permission.js';
|
|
5
|
+
import type { ChannelHandle } from '@moxxy/sdk';
|
|
6
|
+
import type { FramePump } from './frame-pump.js';
|
|
7
|
+
import { runSlash } from './slash-handler.js';
|
|
8
|
+
import type { AwaitingApprovalText } from './callback-handler.js';
|
|
9
|
+
import type { PairingHandler } from './pairing-handler.js';
|
|
10
|
+
|
|
11
|
+
export interface TextHandlerState {
|
|
12
|
+
readonly session: Session | null;
|
|
13
|
+
readonly model: string | undefined;
|
|
14
|
+
readonly activeModelOverride: string | null;
|
|
15
|
+
readonly yolo: boolean;
|
|
16
|
+
readonly voiceReplies: boolean;
|
|
17
|
+
readonly busy: boolean;
|
|
18
|
+
readonly turnController: AbortController | null;
|
|
19
|
+
readonly awaitingApprovalText: AwaitingApprovalText | null;
|
|
20
|
+
readonly handle: ChannelHandle | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TextHandlerDeps {
|
|
24
|
+
readonly pairing: PairingHandler;
|
|
25
|
+
readonly approvalResolver: TelegramApprovalResolver;
|
|
26
|
+
readonly permissionResolver: TelegramPermissionResolver;
|
|
27
|
+
readonly framePump: FramePump;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TextHandlerCallbacks {
|
|
31
|
+
readonly setAwaitingApprovalText: (state: AwaitingApprovalText | null) => void;
|
|
32
|
+
readonly toggleYolo: () => boolean;
|
|
33
|
+
readonly setYolo: (value: boolean) => void;
|
|
34
|
+
readonly setVoiceReplies: (on: boolean) => Promise<void>;
|
|
35
|
+
readonly runUserTurn: (ctx: Context, chatId: number, text: string) => Promise<void>;
|
|
36
|
+
/** Host-issued pairing fallback: try to pair an unauthorized chat whose message
|
|
37
|
+
* is the 6-digit code. Returns true when handled (so we skip the generic
|
|
38
|
+
* "not paired" reply). See {@link TelegramChannel.tryHostPair}. */
|
|
39
|
+
readonly tryHostPair: (chatId: number, text: string) => Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Top-level dispatch for inbound text messages: authorization gate,
|
|
44
|
+
* awaiting-approval-text capture, /cancel, slash routing, and finally
|
|
45
|
+
* the user-turn path.
|
|
46
|
+
*/
|
|
47
|
+
export async function handleTextMessage(
|
|
48
|
+
ctx: Context,
|
|
49
|
+
state: TextHandlerState,
|
|
50
|
+
deps: TextHandlerDeps,
|
|
51
|
+
cb: TextHandlerCallbacks,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const chatId = ctx.chat?.id;
|
|
54
|
+
const text = ctx.message?.text;
|
|
55
|
+
if (!chatId || !text) return;
|
|
56
|
+
|
|
57
|
+
if (!deps.pairing.isAuthorized(chatId)) {
|
|
58
|
+
// Host-issued pairing: a bare 6-digit message from an unpaired chat may be
|
|
59
|
+
// the code presented back to us. If so, pair and stop here.
|
|
60
|
+
if (await cb.tryHostPair(chatId, text)) return;
|
|
61
|
+
await ctx.reply(
|
|
62
|
+
'This bot is paired with a different chat (or not paired yet). Run `moxxy telegram pair` to (re-)pair.',
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Capture awaiting-text BEFORE the busy guard so the user can answer
|
|
68
|
+
// an approval text prompt even while the strategy is technically
|
|
69
|
+
// still mid-turn (it's pending on us).
|
|
70
|
+
if (state.awaitingApprovalText) {
|
|
71
|
+
const { approvalId, optionId } = state.awaitingApprovalText;
|
|
72
|
+
cb.setAwaitingApprovalText(null);
|
|
73
|
+
const handled = deps.approvalResolver.resolvePendingWithText(approvalId, optionId, text);
|
|
74
|
+
if (handled) {
|
|
75
|
+
await ctx.reply(`✓ submitted (${optionId})`);
|
|
76
|
+
} else {
|
|
77
|
+
await ctx.reply('that approval is no longer pending');
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// /cancel works even while busy; everything else routes through
|
|
83
|
+
// runSlash or the user-turn path.
|
|
84
|
+
if (text === '/cancel') {
|
|
85
|
+
if (state.turnController && !state.turnController.signal.aborted) {
|
|
86
|
+
state.turnController.abort('user cancel');
|
|
87
|
+
await ctx.reply('cancelling current turn…');
|
|
88
|
+
} else {
|
|
89
|
+
await ctx.reply('nothing to cancel.');
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (text.startsWith('/')) {
|
|
95
|
+
await runSlash(
|
|
96
|
+
ctx,
|
|
97
|
+
text,
|
|
98
|
+
{
|
|
99
|
+
session: state.session,
|
|
100
|
+
model: state.model,
|
|
101
|
+
activeModelOverride: state.activeModelOverride,
|
|
102
|
+
yolo: state.yolo,
|
|
103
|
+
voiceReplies: state.voiceReplies,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
toggleYolo: cb.toggleYolo,
|
|
107
|
+
setVoiceReplies: cb.setVoiceReplies,
|
|
108
|
+
performSessionAction: (c, action, notice) =>
|
|
109
|
+
performSessionAction(c, action, notice, state, deps, cb),
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (state.busy) {
|
|
116
|
+
await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await cb.runUserTurn(ctx, chatId, text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Channel-side handler for `session-action` outputs from registered
|
|
125
|
+
* commands. The TUI does the same thing; both channels translate the
|
|
126
|
+
* action into their own UI semantics (Telegram = reply text, Ink =
|
|
127
|
+
* setState + exit).
|
|
128
|
+
*/
|
|
129
|
+
async function performSessionAction(
|
|
130
|
+
ctx: Context,
|
|
131
|
+
action: 'new' | 'clear' | 'exit',
|
|
132
|
+
notice: string | undefined,
|
|
133
|
+
state: TextHandlerState,
|
|
134
|
+
deps: TextHandlerDeps,
|
|
135
|
+
cb: TextHandlerCallbacks,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
if (!state.session) return;
|
|
138
|
+
if (action === 'exit') {
|
|
139
|
+
await ctx.reply(notice ?? 'closing Telegram channel');
|
|
140
|
+
if (state.handle) await state.handle.stop('user /exit');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (action === 'clear') {
|
|
144
|
+
deps.framePump.resetRenderer();
|
|
145
|
+
if (notice) await ctx.reply(`✓ ${notice}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (action === 'new') {
|
|
149
|
+
if (state.turnController && !state.turnController.signal.aborted) {
|
|
150
|
+
state.turnController.abort('user reset');
|
|
151
|
+
}
|
|
152
|
+
deps.framePump.resetRenderer();
|
|
153
|
+
cb.setYolo(false);
|
|
154
|
+
cb.setAwaitingApprovalText(null);
|
|
155
|
+
deps.approvalResolver.abortAll('session reset');
|
|
156
|
+
deps.permissionResolver.abortAll('session reset');
|
|
157
|
+
// Wipe the history at its source. On a RemoteSession `reset()` asks the
|
|
158
|
+
// runner to clear its authoritative log (and persisted JSONL) and
|
|
159
|
+
// re-sync every attached mirror; on a local Session it clears the
|
|
160
|
+
// EventLog + truncates the sidecar. A mirror-only `log.clear()` would
|
|
161
|
+
// be cosmetic AND desync this client, so only claim success when the
|
|
162
|
+
// reset actually happened.
|
|
163
|
+
try {
|
|
164
|
+
if (typeof state.session.reset === 'function') await state.session.reset();
|
|
165
|
+
else state.session.log.clear();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await ctx.reply(
|
|
168
|
+
`⚠ /new failed: ${err instanceof Error ? err.message : String(err)} — history NOT cleared`,
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await ctx.reply(`✓ ${notice ?? 'new session — conversation history cleared'}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Bot, Context } from 'grammy';
|
|
2
|
+
import type { newTurnId } from '@moxxy/core';
|
|
3
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
4
|
+
import { driveTurn, subscribeTurn } from '@moxxy/channel-kit';
|
|
5
|
+
import type { FramePump } from './frame-pump.js';
|
|
6
|
+
import type { TypingIndicator } from './typing-indicator.js';
|
|
7
|
+
|
|
8
|
+
export interface TurnRunnerLogger {
|
|
9
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TurnRunnerDeps {
|
|
13
|
+
readonly session: Session;
|
|
14
|
+
readonly bot: Bot | null;
|
|
15
|
+
readonly framePump: FramePump;
|
|
16
|
+
readonly typing: TypingIndicator;
|
|
17
|
+
readonly logger?: TurnRunnerLogger;
|
|
18
|
+
/**
|
|
19
|
+
* Called once with the FINAL assistant text after it has been flushed to the
|
|
20
|
+
* chat (so the text reply always lands first). Backs the optional voice
|
|
21
|
+
* reply. Best-effort — its failure is logged and never breaks the text turn.
|
|
22
|
+
*/
|
|
23
|
+
readonly onFinalReply?: (text: string) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TurnRunnerOptions {
|
|
27
|
+
readonly chatId: number;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
readonly model: string | undefined;
|
|
30
|
+
readonly controller: AbortController;
|
|
31
|
+
/** turnId for this turn. The channel mints it so it can also record it as an
|
|
32
|
+
* own-turn id (mirrorForeignTurn filters on those). */
|
|
33
|
+
readonly turnId: ReturnType<typeof newTurnId>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drive a single user turn end-to-end: kick off typing, subscribe the
|
|
38
|
+
* frame pump to session events, run the turn through `runTurn`, flush
|
|
39
|
+
* the final frame, and unwind state in `finally`.
|
|
40
|
+
*
|
|
41
|
+
* The controller is owned by the caller so /cancel can abort just this
|
|
42
|
+
* turn without poisoning the session-level signal.
|
|
43
|
+
*/
|
|
44
|
+
export async function runUserTurn(
|
|
45
|
+
ctx: Context,
|
|
46
|
+
deps: TurnRunnerDeps,
|
|
47
|
+
opts: TurnRunnerOptions,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const { session, bot, framePump, typing, logger, onFinalReply } = deps;
|
|
50
|
+
const { chatId, text, model, controller, turnId } = opts;
|
|
51
|
+
|
|
52
|
+
framePump.beginTurn(chatId);
|
|
53
|
+
// Kick off "typing…" right away so the user gets immediate feedback.
|
|
54
|
+
// Don't send an ellipsis placeholder message — the typing indicator
|
|
55
|
+
// IS the placeholder. The frame pump lazily sends the first real
|
|
56
|
+
// frame when there's content to display, then edits that message for
|
|
57
|
+
// every subsequent frame.
|
|
58
|
+
typing.start(bot, chatId);
|
|
59
|
+
|
|
60
|
+
// turnId is minted by the caller (the channel records it as an own-turn id).
|
|
61
|
+
// The frame-pump subscriber filters by it: `session.log` fans out to every
|
|
62
|
+
// listener; without this a concurrent turn driven by another channel
|
|
63
|
+
// (HTTP/runner) on the same Session would render into THIS chat. (AGENTS.md:
|
|
64
|
+
// filter event-log subscribers by turnId.)
|
|
65
|
+
const unsubscribe = subscribeTurn(session, turnId, (event) => {
|
|
66
|
+
const frame = framePump.renderState.accept(event);
|
|
67
|
+
if (frame.hasUpdate) framePump.scheduleEdit();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await driveTurn(session, { turnId, prompt: text, model, signal: controller.signal });
|
|
72
|
+
await framePump.flush(true);
|
|
73
|
+
// The text reply is now out. Speak the final assistant body if a voice
|
|
74
|
+
// reply is wired — isolated so a synth/transcode/transport failure can
|
|
75
|
+
// never break (or re-report) the already-delivered text turn.
|
|
76
|
+
if (onFinalReply) {
|
|
77
|
+
const finalText = framePump.renderState.snapshot().body;
|
|
78
|
+
if (finalText.trim()) {
|
|
79
|
+
try {
|
|
80
|
+
await onFinalReply(finalText);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger?.warn('telegram voice reply hook failed', {
|
|
83
|
+
err: err instanceof Error ? err.message : String(err),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger?.warn('telegram turn failed', {
|
|
90
|
+
err: err instanceof Error ? err.message : String(err),
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
await ctx.reply(`Turn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
} catch {
|
|
95
|
+
/* ignore */
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
typing.stop();
|
|
99
|
+
unsubscribe();
|
|
100
|
+
framePump.endTurn();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Bot } from 'grammy';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Show a "typing…" indicator in the chat for the lifetime of a turn.
|
|
5
|
+
* Telegram clears the indicator ~5s after the last sendChatAction, so
|
|
6
|
+
* we re-fire every 4s. Best-effort — a single failure shouldn't crash
|
|
7
|
+
* the turn (we keep the interval going so transient network blips
|
|
8
|
+
* recover on the next tick).
|
|
9
|
+
*/
|
|
10
|
+
export class TypingIndicator {
|
|
11
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
12
|
+
|
|
13
|
+
start(bot: Bot | null, chatId: number): void {
|
|
14
|
+
if (!bot) return;
|
|
15
|
+
this.stop();
|
|
16
|
+
const fire = (): void => {
|
|
17
|
+
bot.api.sendChatAction(chatId, 'typing').catch(() => {
|
|
18
|
+
/* best-effort */
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
fire();
|
|
22
|
+
this.timer = setInterval(fire, 4_000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
stop(): void {
|
|
26
|
+
if (this.timer) {
|
|
27
|
+
clearInterval(this.timer);
|
|
28
|
+
this.timer = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|