@moxxy/plugin-telegram 0.41.3 → 0.42.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/dist/channel/callback-handler.d.ts +5 -2
- package/dist/channel/callback-handler.d.ts.map +1 -1
- package/dist/channel/callback-handler.js +31 -58
- package/dist/channel/callback-handler.js.map +1 -1
- package/dist/channel/mirror.d.ts +23 -0
- package/dist/channel/mirror.d.ts.map +1 -0
- package/dist/channel/mirror.js +23 -0
- package/dist/channel/mirror.js.map +1 -0
- package/dist/channel/model-picker.d.ts +17 -0
- package/dist/channel/model-picker.d.ts.map +1 -0
- package/dist/channel/model-picker.js +58 -0
- package/dist/channel/model-picker.js.map +1 -0
- package/dist/channel/model.d.ts +22 -0
- package/dist/channel/model.d.ts.map +1 -0
- package/dist/channel/model.js +11 -0
- package/dist/channel/model.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +2 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -1
- package/dist/channel/pairing-handler.js +4 -0
- package/dist/channel/pairing-handler.js.map +1 -1
- package/dist/channel/send-rich.d.ts +16 -0
- package/dist/channel/send-rich.d.ts.map +1 -0
- package/dist/channel/send-rich.js +25 -0
- package/dist/channel/send-rich.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +8 -6
- package/dist/channel/slash-handler.d.ts.map +1 -1
- package/dist/channel/slash-handler.js +29 -51
- package/dist/channel/slash-handler.js.map +1 -1
- package/dist/channel/text-handler.d.ts +6 -4
- package/dist/channel/text-handler.d.ts.map +1 -1
- package/dist/channel/text-handler.js +23 -52
- package/dist/channel/text-handler.js.map +1 -1
- package/dist/channel/turn-runner.d.ts +10 -4
- package/dist/channel/turn-runner.d.ts.map +1 -1
- package/dist/channel/turn-runner.js +22 -5
- package/dist/channel/turn-runner.js.map +1 -1
- package/dist/channel/voice-handler.d.ts.map +1 -1
- package/dist/channel/voice-handler.js +2 -0
- package/dist/channel/voice-handler.js.map +1 -1
- package/dist/channel.d.ts +8 -10
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +55 -55
- package/dist/channel.js.map +1 -1
- package/dist/format.d.ts +1 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -72
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +3 -0
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +3 -0
- package/dist/keys.js.map +1 -1
- package/dist/pair-flow.d.ts +2 -1
- package/dist/pair-flow.d.ts.map +1 -1
- package/dist/pair-flow.js +9 -23
- package/dist/pair-flow.js.map +1 -1
- package/dist/tools/send-message.d.ts +21 -0
- package/dist/tools/send-message.d.ts.map +1 -0
- package/dist/tools/send-message.js +84 -0
- package/dist/tools/send-message.js.map +1 -0
- package/package.json +11 -10
- package/src/channel/callback-handler.test.ts +4 -33
- package/src/channel/callback-handler.ts +34 -65
- package/src/channel/mirror.test.ts +59 -0
- package/src/channel/mirror.ts +36 -0
- package/src/channel/model-picker.ts +75 -0
- package/src/channel/model.test.ts +203 -0
- package/src/channel/model.ts +33 -0
- package/src/channel/pairing-handler.test.ts +38 -0
- package/src/channel/pairing-handler.ts +5 -0
- package/src/channel/send-rich.ts +36 -0
- package/src/channel/slash-handler.test.ts +68 -0
- package/src/channel/slash-handler.ts +35 -66
- package/src/channel/text-handler.test.ts +6 -5
- package/src/channel/text-handler.ts +28 -52
- package/src/channel/turn-runner.test.ts +84 -0
- package/src/channel/turn-runner.ts +31 -9
- package/src/channel/voice-handler.ts +2 -0
- package/src/channel/voice-reply.test.ts +16 -14
- package/src/channel.ts +68 -57
- package/src/format.ts +1 -1
- package/src/index.ts +8 -75
- package/src/keys.ts +4 -0
- package/src/pair-flow.ts +9 -23
- package/src/subcommands.test.ts +2 -1
- package/src/tools/send-message.test.ts +118 -0
- package/src/tools/send-message.ts +97 -0
|
@@ -4,15 +4,14 @@ import type { TelegramApprovalResolver } from '../approval.js';
|
|
|
4
4
|
import type { TelegramPermissionResolver } from '../permission.js';
|
|
5
5
|
import type { ChannelHandle } from '@moxxy/sdk';
|
|
6
6
|
import type { FramePump } from './frame-pump.js';
|
|
7
|
+
import { applySessionAction } from '@moxxy/channel-kit';
|
|
7
8
|
import { runSlash } from './slash-handler.js';
|
|
9
|
+
import type { TelegramModel } from './model.js';
|
|
8
10
|
import type { AwaitingApprovalText } from './callback-handler.js';
|
|
9
11
|
import type { PairingHandler } from './pairing-handler.js';
|
|
10
12
|
|
|
11
13
|
export interface TextHandlerState {
|
|
12
14
|
readonly session: Session | null;
|
|
13
|
-
readonly model: string | undefined;
|
|
14
|
-
readonly activeModelOverride: string | null;
|
|
15
|
-
readonly yolo: boolean;
|
|
16
15
|
readonly voiceReplies: boolean;
|
|
17
16
|
readonly busy: boolean;
|
|
18
17
|
readonly turnController: AbortController | null;
|
|
@@ -29,9 +28,13 @@ export interface TextHandlerDeps {
|
|
|
29
28
|
|
|
30
29
|
export interface TextHandlerCallbacks {
|
|
31
30
|
readonly setAwaitingApprovalText: (state: AwaitingApprovalText | null) => void;
|
|
32
|
-
|
|
31
|
+
/** `/auto-approve` — switch the conversation's shared auto-approve. */
|
|
32
|
+
readonly toggleYolo: () => Promise<boolean>;
|
|
33
|
+
/** Drop the bot-local auto-approve flag (a `/new` conversation starts with it off). */
|
|
33
34
|
readonly setYolo: (value: boolean) => void;
|
|
34
35
|
readonly setVoiceReplies: (on: boolean) => Promise<void>;
|
|
36
|
+
/** This bot's own model (`/model`). */
|
|
37
|
+
readonly model: Pick<TelegramModel, 'run' | 'choices'>;
|
|
35
38
|
readonly runUserTurn: (ctx: Context, chatId: number, text: string) => Promise<void>;
|
|
36
39
|
/** Host-issued pairing fallback: try to pair an unauthorized chat whose message
|
|
37
40
|
* is the 6-digit code. Returns true when handled (so we skip the generic
|
|
@@ -95,16 +98,11 @@ export async function handleTextMessage(
|
|
|
95
98
|
await runSlash(
|
|
96
99
|
ctx,
|
|
97
100
|
text,
|
|
98
|
-
{
|
|
99
|
-
session: state.session,
|
|
100
|
-
model: state.model,
|
|
101
|
-
activeModelOverride: state.activeModelOverride,
|
|
102
|
-
yolo: state.yolo,
|
|
103
|
-
voiceReplies: state.voiceReplies,
|
|
104
|
-
},
|
|
101
|
+
{ session: state.session, voiceReplies: state.voiceReplies },
|
|
105
102
|
{
|
|
106
103
|
toggleYolo: cb.toggleYolo,
|
|
107
104
|
setVoiceReplies: cb.setVoiceReplies,
|
|
105
|
+
model: cb.model,
|
|
108
106
|
performSessionAction: (c, action, notice) =>
|
|
109
107
|
performSessionAction(c, action, notice, state, deps, cb),
|
|
110
108
|
},
|
|
@@ -121,10 +119,8 @@ export async function handleTextMessage(
|
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
/**
|
|
124
|
-
* Channel-side handler for `session-action` outputs from registered
|
|
125
|
-
*
|
|
126
|
-
* action into their own UI semantics (Telegram = reply text, Ink =
|
|
127
|
-
* setState + exit).
|
|
122
|
+
* Channel-side handler for `session-action` outputs from registered commands
|
|
123
|
+
* (`applySessionAction` in @moxxy/channel-kit, shared with the other bots).
|
|
128
124
|
*/
|
|
129
125
|
async function performSessionAction(
|
|
130
126
|
ctx: Context,
|
|
@@ -134,41 +130,21 @@ async function performSessionAction(
|
|
|
134
130
|
deps: TextHandlerDeps,
|
|
135
131
|
cb: TextHandlerCallbacks,
|
|
136
132
|
): Promise<void> {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
}
|
|
133
|
+
const reply = await applySessionAction(action, notice, {
|
|
134
|
+
session: state.session,
|
|
135
|
+
turnController: state.turnController,
|
|
136
|
+
handle: state.handle,
|
|
137
|
+
channelName: 'Telegram',
|
|
138
|
+
abortPending: (reason) => {
|
|
139
|
+
deps.approvalResolver.abortAll(reason);
|
|
140
|
+
deps.permissionResolver.abortAll(reason);
|
|
141
|
+
},
|
|
142
|
+
onReset: (kind) => {
|
|
143
|
+
deps.framePump.resetRenderer();
|
|
144
|
+
if (kind !== 'new') return;
|
|
145
|
+
cb.setYolo(false);
|
|
146
|
+
cb.setAwaitingApprovalText(null);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
await ctx.reply(reply);
|
|
174
150
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { asTurnId, type MoxxyEvent, type RunTurnOptions } from '@moxxy/sdk';
|
|
3
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
4
|
+
import type { Context } from 'grammy';
|
|
5
|
+
import { TELEGRAM_TURN_CONTEXT, TELEGRAM_VOICE_CONTEXT, runUserTurn } from './turn-runner.js';
|
|
6
|
+
|
|
7
|
+
function recordingSession() {
|
|
8
|
+
const runs: RunTurnOptions[] = [];
|
|
9
|
+
const session = {
|
|
10
|
+
log: { subscribe: () => () => undefined },
|
|
11
|
+
runTurn: (_prompt: string, opts: RunTurnOptions) => {
|
|
12
|
+
runs.push(opts);
|
|
13
|
+
return (async function* () {
|
|
14
|
+
yield undefined as unknown as MoxxyEvent;
|
|
15
|
+
})();
|
|
16
|
+
},
|
|
17
|
+
} as unknown as Session;
|
|
18
|
+
return { session, runs };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const typing = { start: () => undefined, stop: () => undefined } as never;
|
|
22
|
+
|
|
23
|
+
function pumpWith(body: string) {
|
|
24
|
+
return {
|
|
25
|
+
beginTurn: () => undefined,
|
|
26
|
+
endTurn: () => undefined,
|
|
27
|
+
scheduleEdit: () => undefined,
|
|
28
|
+
flush: async () => undefined,
|
|
29
|
+
renderState: { accept: () => ({ hasUpdate: false }), snapshot: () => ({ body }) },
|
|
30
|
+
} as never;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function run(opts: { spoken?: boolean } = {}, spoken: string[] = []) {
|
|
34
|
+
const { session, runs } = recordingSession();
|
|
35
|
+
const deps = {
|
|
36
|
+
session,
|
|
37
|
+
bot: null,
|
|
38
|
+
framePump: pumpWith('Cześć!'),
|
|
39
|
+
typing,
|
|
40
|
+
speakReply: async (text: string) => {
|
|
41
|
+
spoken.push(text);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
await runUserTurn({ reply: async () => undefined } as unknown as Context, deps, {
|
|
45
|
+
chatId: 42,
|
|
46
|
+
text: 'hi',
|
|
47
|
+
model: 'a-large',
|
|
48
|
+
controller: new AbortController(),
|
|
49
|
+
turnId: asTurnId('t1'),
|
|
50
|
+
...opts,
|
|
51
|
+
});
|
|
52
|
+
return runs[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('a Telegram turn tells the model where it replies', () => {
|
|
56
|
+
it('passes the Telegram context with the model the bot runs', async () => {
|
|
57
|
+
const opts = await run();
|
|
58
|
+
|
|
59
|
+
expect(opts?.systemPrompt).toBe(TELEGRAM_TURN_CONTEXT);
|
|
60
|
+
expect(opts?.model).toBe('a-large');
|
|
61
|
+
expect(TELEGRAM_TURN_CONTEXT).toContain('`telegram_send_message` with `files:');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('asks for a short spoken answer when the prompt was a voice message', async () => {
|
|
65
|
+
const opts = await run({ spoken: true });
|
|
66
|
+
|
|
67
|
+
expect(opts?.systemPrompt).toBe(`${TELEGRAM_TURN_CONTEXT}\n\n${TELEGRAM_VOICE_CONTEXT}`);
|
|
68
|
+
expect(TELEGRAM_VOICE_CONTEXT).toMatch(/voice message/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('/voice decides whether a Telegram reply is spoken', () => {
|
|
73
|
+
it('sends the reply as a voice note while voice replies are on', async () => {
|
|
74
|
+
const spoken: string[] = [];
|
|
75
|
+
await run({ spoken: true }, spoken);
|
|
76
|
+
expect(spoken).toEqual(['Cześć!']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('answers in text only while voice replies are off, even to a voice message', async () => {
|
|
80
|
+
const spoken: string[] = [];
|
|
81
|
+
await run({}, spoken);
|
|
82
|
+
expect(spoken).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Bot, Context } from 'grammy';
|
|
2
2
|
import type { newTurnId } from '@moxxy/core';
|
|
3
3
|
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
4
|
-
import { driveTurn, subscribeTurn } from '@moxxy/channel-kit';
|
|
4
|
+
import { channelTurnContext, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
|
|
5
5
|
import type { FramePump } from './frame-pump.js';
|
|
6
6
|
import type { TypingIndicator } from './typing-indicator.js';
|
|
7
7
|
|
|
@@ -16,16 +16,32 @@ export interface TurnRunnerDeps {
|
|
|
16
16
|
readonly typing: TypingIndicator;
|
|
17
17
|
readonly logger?: TurnRunnerLogger;
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
* chat (so the text reply always lands first)
|
|
21
|
-
*
|
|
19
|
+
* Sends the FINAL assistant text as a voice note after it has been flushed to
|
|
20
|
+
* the chat (so the text reply always lands first), on a `spoken` turn only.
|
|
21
|
+
* Best-effort — its failure is logged and never breaks the text turn.
|
|
22
22
|
*/
|
|
23
|
-
readonly
|
|
23
|
+
readonly speakReply?: (text: string) => Promise<void>;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** What the model must know about replying on Telegram (see channelTurnContext). */
|
|
27
|
+
export const TELEGRAM_TURN_CONTEXT = channelTurnContext({
|
|
28
|
+
service: 'Telegram',
|
|
29
|
+
sendTool: 'telegram_send_message',
|
|
30
|
+
delivery: 'their Telegram chat with the bot',
|
|
31
|
+
uploadLimit: '50 MB per file',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Added while voice replies are on: the reply goes back as a voice message too. */
|
|
35
|
+
export const TELEGRAM_VOICE_CONTEXT =
|
|
36
|
+
'Your reply will also be sent back as a voice message, read ' +
|
|
37
|
+
'aloud: answer briefly, in plain spoken sentences, without markdown, code blocks, tables or links. ' +
|
|
38
|
+
'Speak in the language of the request.';
|
|
39
|
+
|
|
26
40
|
export interface TurnRunnerOptions {
|
|
27
41
|
readonly chatId: number;
|
|
28
42
|
readonly text: string;
|
|
43
|
+
/** Voice replies are on (`/voice`): the reply is also spoken back. */
|
|
44
|
+
readonly spoken?: boolean;
|
|
29
45
|
readonly model: string | undefined;
|
|
30
46
|
readonly controller: AbortController;
|
|
31
47
|
/** turnId for this turn. The channel mints it so it can also record it as an
|
|
@@ -46,7 +62,7 @@ export async function runUserTurn(
|
|
|
46
62
|
deps: TurnRunnerDeps,
|
|
47
63
|
opts: TurnRunnerOptions,
|
|
48
64
|
): Promise<void> {
|
|
49
|
-
const { session, bot, framePump, typing, logger,
|
|
65
|
+
const { session, bot, framePump, typing, logger, speakReply } = deps;
|
|
50
66
|
const { chatId, text, model, controller, turnId } = opts;
|
|
51
67
|
|
|
52
68
|
framePump.beginTurn(chatId);
|
|
@@ -68,16 +84,22 @@ export async function runUserTurn(
|
|
|
68
84
|
});
|
|
69
85
|
|
|
70
86
|
try {
|
|
71
|
-
await driveTurn(session, {
|
|
87
|
+
await driveTurn(session, {
|
|
88
|
+
turnId,
|
|
89
|
+
prompt: text,
|
|
90
|
+
model,
|
|
91
|
+
systemPrompt: opts.spoken ? `${TELEGRAM_TURN_CONTEXT}\n\n${TELEGRAM_VOICE_CONTEXT}` : TELEGRAM_TURN_CONTEXT,
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
});
|
|
72
94
|
await framePump.flush(true);
|
|
73
95
|
// The text reply is now out. Speak the final assistant body if a voice
|
|
74
96
|
// reply is wired — isolated so a synth/transcode/transport failure can
|
|
75
97
|
// never break (or re-report) the already-delivered text turn.
|
|
76
|
-
if (
|
|
98
|
+
if (opts.spoken && speakReply) {
|
|
77
99
|
const finalText = framePump.renderState.snapshot().body;
|
|
78
100
|
if (finalText.trim()) {
|
|
79
101
|
try {
|
|
80
|
-
await
|
|
102
|
+
await speakReply(finalText);
|
|
81
103
|
} catch (err) {
|
|
82
104
|
logger?.warn('telegram voice reply hook failed', {
|
|
83
105
|
err: err instanceof Error ? err.message : String(err),
|
|
@@ -194,5 +194,7 @@ export async function handleVoiceMessage(
|
|
|
194
194
|
// reply.
|
|
195
195
|
await ctx.reply(`_heard:_ ${transcript}`, { parse_mode: 'Markdown' });
|
|
196
196
|
|
|
197
|
+
// The transcript is a prompt like any other: `/voice` decides whether the
|
|
198
|
+
// reply is spoken back.
|
|
197
199
|
await cb.runUserTurn(ctx, chatId, transcript);
|
|
198
200
|
}
|
|
@@ -8,6 +8,7 @@ import type { ClientSession as Session } from '@moxxy/sdk';
|
|
|
8
8
|
import { loadVoiceReplies, saveVoiceReplies, TELEGRAM_VOICE_REPLIES_KEY } from '../keys.js';
|
|
9
9
|
import { runSlash } from './slash-handler.js';
|
|
10
10
|
import { runUserTurn } from './turn-runner.js';
|
|
11
|
+
import { removeDir } from '@moxxy/vitest-preset/fs';
|
|
11
12
|
|
|
12
13
|
describe('telegram voice-replies vault flag round-trip', () => {
|
|
13
14
|
let tmp: string;
|
|
@@ -21,7 +22,7 @@ describe('telegram voice-replies vault flag round-trip', () => {
|
|
|
21
22
|
});
|
|
22
23
|
});
|
|
23
24
|
afterEach(async () => {
|
|
24
|
-
await
|
|
25
|
+
await removeDir(tmp);
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
it('defaults to off, persists on, then off', async () => {
|
|
@@ -52,7 +53,8 @@ function slashCtx(): { ctx: any; replies: string[] } {
|
|
|
52
53
|
|
|
53
54
|
describe('/voice slash command', () => {
|
|
54
55
|
const cbBase = {
|
|
55
|
-
toggleYolo: () => false,
|
|
56
|
+
toggleYolo: async () => false,
|
|
57
|
+
model: { run: async () => '', choices: async () => ({ current: null, options: [] }) },
|
|
56
58
|
performSessionAction: async () => undefined,
|
|
57
59
|
};
|
|
58
60
|
|
|
@@ -62,7 +64,7 @@ describe('/voice slash command', () => {
|
|
|
62
64
|
await runSlash(
|
|
63
65
|
ctx,
|
|
64
66
|
'/voice on',
|
|
65
|
-
{ session: slashSession({ synth: { name: 'fake' } }),
|
|
67
|
+
{ session: slashSession({ synth: { name: 'fake' } }), voiceReplies: false },
|
|
66
68
|
{ ...cbBase, setVoiceReplies: async (on: boolean) => void persisted.push(on) },
|
|
67
69
|
);
|
|
68
70
|
expect(persisted).toEqual([true]);
|
|
@@ -76,7 +78,7 @@ describe('/voice slash command', () => {
|
|
|
76
78
|
await runSlash(
|
|
77
79
|
ctx,
|
|
78
80
|
'/voice on',
|
|
79
|
-
{ session: slashSession(),
|
|
81
|
+
{ session: slashSession(), voiceReplies: false },
|
|
80
82
|
{ ...cbBase, setVoiceReplies: async (on: boolean) => void persisted.push(on) },
|
|
81
83
|
);
|
|
82
84
|
expect(persisted).toEqual([true]);
|
|
@@ -91,7 +93,7 @@ describe('/voice slash command', () => {
|
|
|
91
93
|
await runSlash(
|
|
92
94
|
off.ctx,
|
|
93
95
|
'/voice',
|
|
94
|
-
{ session: slashSession(),
|
|
96
|
+
{ session: slashSession(), voiceReplies: true },
|
|
95
97
|
{ ...cbBase, setVoiceReplies },
|
|
96
98
|
);
|
|
97
99
|
expect(persisted).toEqual([false]);
|
|
@@ -101,7 +103,7 @@ describe('/voice slash command', () => {
|
|
|
101
103
|
await runSlash(
|
|
102
104
|
status.ctx,
|
|
103
105
|
'/voice status',
|
|
104
|
-
{ session: slashSession(),
|
|
106
|
+
{ session: slashSession(), voiceReplies: true },
|
|
105
107
|
{ ...cbBase, setVoiceReplies },
|
|
106
108
|
);
|
|
107
109
|
// status did NOT persist anything new.
|
|
@@ -161,8 +163,8 @@ function fakeFramePump(): { framePump: any; flushedFinal: boolean[] } {
|
|
|
161
163
|
|
|
162
164
|
const typingNoop = { start: () => {}, stop: () => {} } as any;
|
|
163
165
|
|
|
164
|
-
describe('turn-runner
|
|
165
|
-
it('calls
|
|
166
|
+
describe('turn-runner speakReply seam (final assistant text, voice replies on)', () => {
|
|
167
|
+
it('calls speakReply with the final assistant body AFTER flushing the text', async () => {
|
|
166
168
|
const { framePump, flushedFinal } = fakeFramePump();
|
|
167
169
|
const seen: string[] = [];
|
|
168
170
|
const session = turnSession((emit) => {
|
|
@@ -176,9 +178,9 @@ describe('turn-runner onFinalReply seam (final assistant text)', () => {
|
|
|
176
178
|
bot: null,
|
|
177
179
|
framePump,
|
|
178
180
|
typing: typingNoop,
|
|
179
|
-
|
|
181
|
+
speakReply: async (t) => void seen.push(t),
|
|
180
182
|
},
|
|
181
|
-
{ chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t1') },
|
|
183
|
+
{ chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t1'), spoken: true },
|
|
182
184
|
);
|
|
183
185
|
expect(flushedFinal).toContain(true);
|
|
184
186
|
expect(seen).toEqual(['Hello there.']);
|
|
@@ -192,8 +194,8 @@ describe('turn-runner onFinalReply seam (final assistant text)', () => {
|
|
|
192
194
|
});
|
|
193
195
|
await runUserTurn(
|
|
194
196
|
{ reply: async () => {} } as any,
|
|
195
|
-
{ session, bot: null, framePump, typing: typingNoop,
|
|
196
|
-
{ chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t2') },
|
|
197
|
+
{ session, bot: null, framePump, typing: typingNoop, speakReply: async (t) => void seen.push(t) },
|
|
198
|
+
{ chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t2'), spoken: true },
|
|
197
199
|
);
|
|
198
200
|
expect(seen).toEqual([]);
|
|
199
201
|
});
|
|
@@ -210,11 +212,11 @@ describe('turn-runner onFinalReply seam (final assistant text)', () => {
|
|
|
210
212
|
bot: null,
|
|
211
213
|
framePump,
|
|
212
214
|
typing: typingNoop,
|
|
213
|
-
|
|
215
|
+
speakReply: async () => {
|
|
214
216
|
throw new Error('tts exploded');
|
|
215
217
|
},
|
|
216
218
|
},
|
|
217
|
-
{ chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t3') },
|
|
219
|
+
{ chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t3'), spoken: true },
|
|
218
220
|
),
|
|
219
221
|
).resolves.toBeUndefined();
|
|
220
222
|
// No "Turn failed" reply — the hook failure was swallowed.
|
package/src/channel.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { Bot, GrammyError, HttpError, InputFile } from 'grammy';
|
|
2
2
|
import type { Context } from 'grammy';
|
|
3
3
|
import { newTurnId } from '@moxxy/core';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AutoApproveSwitch,
|
|
6
|
+
TurnCoordinator,
|
|
7
|
+
deliverVoiceReply,
|
|
8
|
+
resolveSecret,
|
|
9
|
+
type ForeignTurnMirror,
|
|
10
|
+
type MirrorTarget,
|
|
11
|
+
} from '@moxxy/channel-kit';
|
|
5
12
|
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
6
13
|
import type {
|
|
7
14
|
ApprovalRequest,
|
|
8
15
|
Channel,
|
|
9
16
|
ChannelHandle,
|
|
10
17
|
ChannelStartOptsBase,
|
|
11
|
-
MoxxyEvent,
|
|
12
18
|
PendingToolCall,
|
|
13
19
|
PermissionContext,
|
|
14
20
|
} from '@moxxy/sdk';
|
|
@@ -29,6 +35,8 @@ import { runUserTurn } from './channel/turn-runner.js';
|
|
|
29
35
|
import { handleTextMessage } from './channel/text-handler.js';
|
|
30
36
|
import { handleVoiceMessage } from './channel/voice-handler.js';
|
|
31
37
|
import { loadVoiceReplies, saveVoiceReplies } from './keys.js';
|
|
38
|
+
import { createTelegramMirror } from './channel/mirror.js';
|
|
39
|
+
import { telegramModel, type TelegramModel } from './channel/model.js';
|
|
32
40
|
|
|
33
41
|
const TOKEN_KEY = 'telegram_bot_token';
|
|
34
42
|
|
|
@@ -91,14 +99,15 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
91
99
|
// re-publish the channel's status file. See `onConnectChange` on the handle.
|
|
92
100
|
private readonly connectListeners = new Set<() => void>();
|
|
93
101
|
private currentChatId: number | null = null;
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
private
|
|
102
|
+
// Turns another surface ran on this bot's session (the desktop's chat with
|
|
103
|
+
// the bot) shown here: into the chat we last served, else the paired chat.
|
|
104
|
+
private readonly mirror: ForeignTurnMirror<number>;
|
|
105
|
+
private readonly mirrorTarget: MirrorTarget<number>;
|
|
97
106
|
private logUnsub: (() => void) | null = null;
|
|
98
107
|
private session: Session | null = null;
|
|
99
108
|
private model: string | undefined;
|
|
100
|
-
|
|
101
|
-
private
|
|
109
|
+
/** `/auto-approve` — the conversation's shared switch (see AutoApproveSwitch). */
|
|
110
|
+
private readonly autoApprove = new AutoApproveSwitch(() => this.session);
|
|
102
111
|
// When true, the final assistant reply of each turn is also synthesized (via
|
|
103
112
|
// the session's active Synthesizer) and sent as a voice note. Persisted per
|
|
104
113
|
// paired chat in the vault (`telegram_voice_replies`), toggled with `/voice`.
|
|
@@ -137,6 +146,14 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
137
146
|
// A completed pairing flips this channel's connect-state to "connected";
|
|
138
147
|
// notify the host so it can swap the QR for a "✓ Connected" affordance.
|
|
139
148
|
this.pairing.onPaired(() => this.notifyConnectChange());
|
|
149
|
+
const { mirror, target } = createTelegramMirror({
|
|
150
|
+
turns: this.turns,
|
|
151
|
+
api: () => this.bot?.api ?? null,
|
|
152
|
+
pairedChat: () => this.pairing.authorizedChatId(),
|
|
153
|
+
...(opts.logger ? { logger: opts.logger } : {}),
|
|
154
|
+
});
|
|
155
|
+
this.mirror = mirror;
|
|
156
|
+
this.mirrorTarget = target;
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
/** This channel's connect value (see {@link Channel.requestUrl}): the resolved
|
|
@@ -219,11 +236,10 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
219
236
|
// headless code paths after channel close don't see a stale handler.
|
|
220
237
|
this.session.setApprovalResolver(this.approvalResolver);
|
|
221
238
|
|
|
222
|
-
// Mirror-to-both:
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
|
|
239
|
+
// Mirror-to-both: a turn this channel did NOT initiate (a message written
|
|
240
|
+
// in the desktop's chat with this bot) shows here — its prompt, then its
|
|
241
|
+
// reply. Our OWN turns are rendered by the FramePump, so the mirror skips them.
|
|
242
|
+
this.logUnsub = this.session.log.subscribe((event) => this.mirror.accept(event));
|
|
227
243
|
|
|
228
244
|
this.bot.command('start', (ctx) => this.pairing.handleStartCommand(ctx));
|
|
229
245
|
this.bot.on('callback_query:data', (ctx) => this.dispatchCallback(ctx));
|
|
@@ -367,9 +383,6 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
367
383
|
ctx,
|
|
368
384
|
{
|
|
369
385
|
session: this.session,
|
|
370
|
-
model: this.model,
|
|
371
|
-
activeModelOverride: this.activeModelOverride,
|
|
372
|
-
yolo: this.yolo,
|
|
373
386
|
voiceReplies: this.voiceReplies,
|
|
374
387
|
busy: this.turns.busy,
|
|
375
388
|
turnController: this.turns.controller,
|
|
@@ -386,14 +399,10 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
386
399
|
setAwaitingApprovalText: (state) => {
|
|
387
400
|
this.awaitingApprovalText = state;
|
|
388
401
|
},
|
|
389
|
-
toggleYolo: () =>
|
|
390
|
-
|
|
391
|
-
return this.yolo;
|
|
392
|
-
},
|
|
393
|
-
setYolo: (value) => {
|
|
394
|
-
this.yolo = value;
|
|
395
|
-
},
|
|
402
|
+
toggleYolo: () => this.autoApprove.toggle(),
|
|
403
|
+
setYolo: () => this.autoApprove.forgetLocal(),
|
|
396
404
|
setVoiceReplies: (on) => this.setVoiceReplies(on),
|
|
405
|
+
model: this.pickerModel(),
|
|
397
406
|
runUserTurn: (c, chatId, text) => this.runUserTurn(c, chatId, text),
|
|
398
407
|
tryHostPair: (chatId, text) => this.tryHostPair(ctx, chatId, text),
|
|
399
408
|
},
|
|
@@ -417,7 +426,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
417
426
|
* unavailable), and deliver via grammy. The text reply already went out.
|
|
418
427
|
*/
|
|
419
428
|
private async sendVoiceReply(chatId: number, text: string): Promise<void> {
|
|
420
|
-
if (!this.
|
|
429
|
+
if (!this.bot || !this.session) return;
|
|
421
430
|
const bot = this.bot;
|
|
422
431
|
const outcome = await deliverVoiceReply(this.session, text, {
|
|
423
432
|
send: async (audio, meta) => {
|
|
@@ -464,25 +473,41 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
464
473
|
});
|
|
465
474
|
}
|
|
466
475
|
|
|
467
|
-
|
|
468
|
-
|
|
476
|
+
/** `/model` and its picker, answering "not ready" before start(). */
|
|
477
|
+
private pickerModel(): Pick<TelegramModel, 'run' | 'choices'> {
|
|
478
|
+
return {
|
|
479
|
+
run: (arg) => this.botModel()?.run(arg) ?? Promise.resolve('Session is not ready yet.'),
|
|
480
|
+
choices: () => this.botModel()?.choices() ?? Promise.resolve({ current: null, options: [] }),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** This bot's own model (see TelegramModel); null before start(). */
|
|
485
|
+
private botModel(): TelegramModel | null {
|
|
486
|
+
return this.session ? telegramModel({ session: this.session, vault: this.opts.vault }) : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async runUserTurn(
|
|
490
|
+
ctx: Context,
|
|
491
|
+
chatId: number,
|
|
492
|
+
text: string,
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
const model = this.botModel();
|
|
495
|
+
if (!this.session || !model) throw new Error('TelegramChannel.start() must be called first');
|
|
469
496
|
// Atomic single-flight guard: `begin` claims the slot synchronously BEFORE
|
|
470
497
|
// any await so a second turn dispatched concurrently (the poll loop is no
|
|
471
498
|
// longer parked on us) can't slip past the busy check in the text/voice
|
|
472
|
-
// handlers.
|
|
473
|
-
//
|
|
474
|
-
// The turnId is minted here so the coordinator records it as an own-turn
|
|
475
|
-
// id — that's what mirrorForeignTurn filters on.
|
|
499
|
+
// handlers. The turnId is minted here so the coordinator records it as an
|
|
500
|
+
// own-turn id — that's what the mirror filters on.
|
|
476
501
|
const lease = this.turns.begin(newTurnId());
|
|
477
502
|
if (!lease) {
|
|
478
503
|
await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
|
|
479
504
|
return;
|
|
480
505
|
}
|
|
481
506
|
this.currentChatId = chatId;
|
|
482
|
-
this.
|
|
483
|
-
const effectiveModel = this.activeModelOverride ?? this.model;
|
|
484
|
-
|
|
507
|
+
this.mirrorTarget.remember(chatId);
|
|
485
508
|
try {
|
|
509
|
+
const channelModel = await model.resolve();
|
|
510
|
+
if (channelModel.warning) await ctx.reply(channelModel.warning);
|
|
486
511
|
await runUserTurn(
|
|
487
512
|
ctx,
|
|
488
513
|
{
|
|
@@ -491,9 +516,16 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
491
516
|
framePump: this.framePump,
|
|
492
517
|
typing: this.typing,
|
|
493
518
|
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
494
|
-
|
|
519
|
+
speakReply: (finalText) => this.sendVoiceReply(chatId, finalText),
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
chatId,
|
|
523
|
+
text,
|
|
524
|
+
...(this.voiceReplies ? { spoken: true } : {}),
|
|
525
|
+
model: channelModel.model ?? this.model,
|
|
526
|
+
controller: lease.controller,
|
|
527
|
+
turnId: lease.turnId,
|
|
495
528
|
},
|
|
496
|
-
{ chatId, text, model: effectiveModel, controller: lease.controller, turnId: lease.turnId },
|
|
497
529
|
);
|
|
498
530
|
} finally {
|
|
499
531
|
lease.end();
|
|
@@ -501,32 +533,13 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
501
533
|
}
|
|
502
534
|
}
|
|
503
535
|
|
|
504
|
-
/**
|
|
505
|
-
* Post the assistant's prose for a turn this channel did not initiate. Gated
|
|
506
|
-
* by `!busy` (our own turns are rendered by the FramePump from the runUserTurn
|
|
507
|
-
* iterator) and by having served a chat at least once. Sent as plain text to
|
|
508
|
-
* avoid parse-mode pitfalls; the view itself lives on the web surface.
|
|
509
|
-
*/
|
|
510
|
-
private mirrorForeignTurn(event: MoxxyEvent): void {
|
|
511
|
-
// The coordinator skips turns THIS channel initiated, by turnId — robust to
|
|
512
|
-
// events that arrive after `busy` flips false (async ordering /
|
|
513
|
-
// RemoteSession replay), which the `busy` flag alone could mis-mirror as
|
|
514
|
-
// foreign (invariant #8) — and yields the trimmed assistant prose.
|
|
515
|
-
const text = this.turns.mirrorText(event);
|
|
516
|
-
if (text == null) return;
|
|
517
|
-
if (!this.bot || this.lastChatId == null) return;
|
|
518
|
-
void this.bot.api.sendMessage(this.lastChatId, text).catch((err) => {
|
|
519
|
-
this.opts.logger?.warn('telegram mirror failed', { err: String(err) });
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
|
|
523
536
|
private askForPermission(call: PendingToolCall, ctx: PermissionContext): Promise<void> {
|
|
524
537
|
return askForPermission(call, ctx, {
|
|
525
538
|
bot: this.bot,
|
|
526
539
|
chatId: this.currentChatId,
|
|
527
540
|
session: this.session,
|
|
528
541
|
resolver: this.permissionResolver,
|
|
529
|
-
yolo: this.
|
|
542
|
+
yolo: this.autoApprove.enabled,
|
|
530
543
|
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
531
544
|
});
|
|
532
545
|
}
|
|
@@ -555,9 +568,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
|
|
|
555
568
|
setAwaitingApprovalText: (state) => {
|
|
556
569
|
this.awaitingApprovalText = state;
|
|
557
570
|
},
|
|
558
|
-
|
|
559
|
-
this.activeModelOverride = modelId;
|
|
560
|
-
},
|
|
571
|
+
model: this.pickerModel(),
|
|
561
572
|
},
|
|
562
573
|
);
|
|
563
574
|
}
|
package/src/format.ts
CHANGED
|
@@ -290,7 +290,7 @@ function isAllowedUrl(url: string): boolean {
|
|
|
290
290
|
return ALLOWED_URL_SCHEMES.has((m[1] ?? '').toLowerCase());
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
function escapeHtml(s: string): string {
|
|
293
|
+
export function escapeHtml(s: string): string {
|
|
294
294
|
return s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>'));
|
|
295
295
|
}
|
|
296
296
|
|