@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +37 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +75 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/channel/approval-prompt.d.ts +20 -0
  7. package/dist/channel/approval-prompt.d.ts.map +1 -0
  8. package/dist/channel/approval-prompt.js +34 -0
  9. package/dist/channel/approval-prompt.js.map +1 -0
  10. package/dist/channel/callback-handler.d.ts +32 -0
  11. package/dist/channel/callback-handler.d.ts.map +1 -0
  12. package/dist/channel/callback-handler.js +212 -0
  13. package/dist/channel/callback-handler.js.map +1 -0
  14. package/dist/channel/frame-pump.d.ts +58 -0
  15. package/dist/channel/frame-pump.d.ts.map +1 -0
  16. package/dist/channel/frame-pump.js +159 -0
  17. package/dist/channel/frame-pump.js.map +1 -0
  18. package/dist/channel/html.d.ts +24 -0
  19. package/dist/channel/html.d.ts.map +1 -0
  20. package/dist/channel/html.js +62 -0
  21. package/dist/channel/html.js.map +1 -0
  22. package/dist/channel/pairing-handler.d.ts +66 -0
  23. package/dist/channel/pairing-handler.d.ts.map +1 -0
  24. package/dist/channel/pairing-handler.js +147 -0
  25. package/dist/channel/pairing-handler.js.map +1 -0
  26. package/dist/channel/permission-prompt.d.ts +21 -0
  27. package/dist/channel/permission-prompt.d.ts.map +1 -0
  28. package/dist/channel/permission-prompt.js +36 -0
  29. package/dist/channel/permission-prompt.js.map +1 -0
  30. package/dist/channel/slash-handler.d.ts +47 -0
  31. package/dist/channel/slash-handler.d.ts.map +1 -0
  32. package/dist/channel/slash-handler.js +214 -0
  33. package/dist/channel/slash-handler.js.map +1 -0
  34. package/dist/channel/text-handler.d.ts +43 -0
  35. package/dist/channel/text-handler.d.ts.map +1 -0
  36. package/dist/channel/text-handler.js +116 -0
  37. package/dist/channel/text-handler.js.map +1 -0
  38. package/dist/channel/turn-runner.d.ts +40 -0
  39. package/dist/channel/turn-runner.d.ts.map +1 -0
  40. package/dist/channel/turn-runner.js +67 -0
  41. package/dist/channel/turn-runner.js.map +1 -0
  42. package/dist/channel/typing-indicator.d.ts +14 -0
  43. package/dist/channel/typing-indicator.d.ts.map +1 -0
  44. package/dist/channel/typing-indicator.js +29 -0
  45. package/dist/channel/typing-indicator.js.map +1 -0
  46. package/dist/channel/voice-handler.d.ts +42 -0
  47. package/dist/channel/voice-handler.d.ts.map +1 -0
  48. package/dist/channel/voice-handler.js +138 -0
  49. package/dist/channel/voice-handler.js.map +1 -0
  50. package/dist/channel.d.ts +118 -0
  51. package/dist/channel.d.ts.map +1 -0
  52. package/dist/channel.js +478 -0
  53. package/dist/channel.js.map +1 -0
  54. package/dist/channel.test-d.d.ts +2 -0
  55. package/dist/channel.test-d.d.ts.map +1 -0
  56. package/dist/channel.test-d.js +5 -0
  57. package/dist/channel.test-d.js.map +1 -0
  58. package/dist/format.d.ts +38 -0
  59. package/dist/format.d.ts.map +1 -0
  60. package/dist/format.js +237 -0
  61. package/dist/format.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +304 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +37 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +42 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +18 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +119 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +90 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +97 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +25 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +49 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +68 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +439 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/setup-wizard.d.ts +21 -0
  87. package/dist/setup-wizard.d.ts.map +1 -0
  88. package/dist/setup-wizard.js +140 -0
  89. package/dist/setup-wizard.js.map +1 -0
  90. package/package.json +91 -0
  91. package/src/approval.test.ts +103 -0
  92. package/src/approval.ts +86 -0
  93. package/src/channel/approval-prompt.ts +46 -0
  94. package/src/channel/callback-handler.test.ts +188 -0
  95. package/src/channel/callback-handler.ts +256 -0
  96. package/src/channel/frame-pump.ts +176 -0
  97. package/src/channel/html.test.ts +40 -0
  98. package/src/channel/html.ts +60 -0
  99. package/src/channel/pairing-handler.ts +181 -0
  100. package/src/channel/permission-prompt.ts +51 -0
  101. package/src/channel/slash-handler.ts +259 -0
  102. package/src/channel/text-handler.test.ts +222 -0
  103. package/src/channel/text-handler.ts +174 -0
  104. package/src/channel/turn-runner.ts +102 -0
  105. package/src/channel/typing-indicator.ts +31 -0
  106. package/src/channel/voice-handler.test.ts +328 -0
  107. package/src/channel/voice-handler.ts +198 -0
  108. package/src/channel/voice-reply.test.ts +223 -0
  109. package/src/channel.test-d.ts +7 -0
  110. package/src/channel.ts +564 -0
  111. package/src/format.test.ts +159 -0
  112. package/src/format.ts +257 -0
  113. package/src/index.ts +347 -0
  114. package/src/keys.ts +52 -0
  115. package/src/pair-flow.ts +131 -0
  116. package/src/pairing.test.ts +113 -0
  117. package/src/pairing.ts +184 -0
  118. package/src/permission.test.ts +56 -0
  119. package/src/permission.ts +61 -0
  120. package/src/render.test.ts +350 -0
  121. package/src/render.ts +522 -0
  122. package/src/setup-wizard.ts +178 -0
  123. package/src/subcommands.test.ts +153 -0
@@ -0,0 +1,223 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
6
+ import { asTurnId, type MoxxyEvent } from '@moxxy/sdk';
7
+ import type { ClientSession as Session } from '@moxxy/sdk';
8
+ import { loadVoiceReplies, saveVoiceReplies, TELEGRAM_VOICE_REPLIES_KEY } from '../keys.js';
9
+ import { runSlash } from './slash-handler.js';
10
+ import { runUserTurn } from './turn-runner.js';
11
+
12
+ describe('telegram voice-replies vault flag round-trip', () => {
13
+ let tmp: string;
14
+ let vault: VaultStore;
15
+
16
+ beforeEach(async () => {
17
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-tg-voice-'));
18
+ vault = new VaultStore({
19
+ filePath: path.join(tmp, 'vault.json'),
20
+ keySource: createStaticKeySource(deriveKey('test', generateSalt())),
21
+ });
22
+ });
23
+ afterEach(async () => {
24
+ await fs.rm(tmp, { recursive: true, force: true });
25
+ });
26
+
27
+ it('defaults to off, persists on, then off', async () => {
28
+ expect(await loadVoiceReplies(vault)).toBe(false);
29
+ await saveVoiceReplies(vault, true);
30
+ expect(await vault.get(TELEGRAM_VOICE_REPLIES_KEY)).toBe('1');
31
+ expect(await loadVoiceReplies(vault)).toBe(true);
32
+ await saveVoiceReplies(vault, false);
33
+ expect(await vault.get(TELEGRAM_VOICE_REPLIES_KEY)).toBe(null);
34
+ expect(await loadVoiceReplies(vault)).toBe(false);
35
+ });
36
+ });
37
+
38
+ /** Minimal session: `/voice` only touches `commands.get` (miss → channel-local)
39
+ * and `synthesizers.tryGetActive`. */
40
+ function slashSession(opts: { synth?: unknown } = {}): Session {
41
+ return {
42
+ id: 'sess',
43
+ commands: { get: () => undefined },
44
+ synthesizers: { tryGetActive: () => opts.synth ?? null },
45
+ } as unknown as Session;
46
+ }
47
+
48
+ function slashCtx(): { ctx: any; replies: string[] } {
49
+ const replies: string[] = [];
50
+ return { replies, ctx: { reply: async (t: string) => void replies.push(t) } };
51
+ }
52
+
53
+ describe('/voice slash command', () => {
54
+ const cbBase = {
55
+ toggleYolo: () => false,
56
+ performSessionAction: async () => undefined,
57
+ };
58
+
59
+ it('enables + persists when a synthesizer is active', async () => {
60
+ const { ctx, replies } = slashCtx();
61
+ const persisted: boolean[] = [];
62
+ await runSlash(
63
+ ctx,
64
+ '/voice on',
65
+ { session: slashSession({ synth: { name: 'fake' } }), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: false },
66
+ { ...cbBase, setVoiceReplies: async (on: boolean) => void persisted.push(on) },
67
+ );
68
+ expect(persisted).toEqual([true]);
69
+ expect(replies[0]).toContain('ON');
70
+ expect(replies[0]).not.toContain('tts-openai');
71
+ });
72
+
73
+ it('still enables but shows install guidance when no synthesizer is active', async () => {
74
+ const { ctx, replies } = slashCtx();
75
+ const persisted: boolean[] = [];
76
+ await runSlash(
77
+ ctx,
78
+ '/voice on',
79
+ { session: slashSession(), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: false },
80
+ { ...cbBase, setVoiceReplies: async (on: boolean) => void persisted.push(on) },
81
+ );
82
+ expect(persisted).toEqual([true]);
83
+ expect(replies[0]).toContain('moxxy plugins install tts-openai');
84
+ });
85
+
86
+ it('toggles off and reports status without persisting', async () => {
87
+ const persisted: boolean[] = [];
88
+ const setVoiceReplies = async (on: boolean): Promise<void> => void persisted.push(on);
89
+
90
+ const off = slashCtx();
91
+ await runSlash(
92
+ off.ctx,
93
+ '/voice',
94
+ { session: slashSession(), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: true },
95
+ { ...cbBase, setVoiceReplies },
96
+ );
97
+ expect(persisted).toEqual([false]);
98
+ expect(off.replies[0]).toContain('OFF');
99
+
100
+ const status = slashCtx();
101
+ await runSlash(
102
+ status.ctx,
103
+ '/voice status',
104
+ { session: slashSession(), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: true },
105
+ { ...cbBase, setVoiceReplies },
106
+ );
107
+ // status did NOT persist anything new.
108
+ expect(persisted).toEqual([false]);
109
+ expect(status.replies[0]).toContain('ON');
110
+ });
111
+ });
112
+
113
+ /**
114
+ * Fake session whose `runTurn` fans scripted events (stamped with the turnId)
115
+ * through the log subscribers, mirroring the real Session. Matches the pattern
116
+ * in the Discord turn-runner test.
117
+ */
118
+ function turnSession(
119
+ script: (emit: (e: Record<string, unknown>) => void) => void,
120
+ ): Session {
121
+ const listeners = new Set<(e: MoxxyEvent) => void>();
122
+ return {
123
+ log: {
124
+ subscribe(fn: (e: MoxxyEvent) => void) {
125
+ listeners.add(fn);
126
+ return () => listeners.delete(fn);
127
+ },
128
+ },
129
+ runTurn: (_p: string, opts: { turnId: string }) =>
130
+ (async function* () {
131
+ const emit = (e: Record<string, unknown>): void => {
132
+ const ev = { ...e, turnId: opts.turnId } as unknown as MoxxyEvent;
133
+ for (const fn of listeners) fn(ev);
134
+ };
135
+ script(emit);
136
+ yield undefined as never;
137
+ })(),
138
+ } as unknown as Session;
139
+ }
140
+
141
+ /** A frame-pump stand-in that captures the final assistant text the same way the
142
+ * real renderer does (assistant_message overwrites the body). */
143
+ function fakeFramePump(): { framePump: any; flushedFinal: boolean[] } {
144
+ let body = '';
145
+ const flushedFinal: boolean[] = [];
146
+ const framePump = {
147
+ beginTurn: () => {},
148
+ endTurn: () => {},
149
+ scheduleEdit: () => {},
150
+ flush: async (final: boolean) => void flushedFinal.push(final),
151
+ renderState: {
152
+ accept: (e: MoxxyEvent) => {
153
+ if (e.type === 'assistant_message') body = (e as { content: string }).content;
154
+ return { hasUpdate: true };
155
+ },
156
+ snapshot: () => ({ body }),
157
+ },
158
+ };
159
+ return { framePump, flushedFinal };
160
+ }
161
+
162
+ const typingNoop = { start: () => {}, stop: () => {} } as any;
163
+
164
+ describe('turn-runner onFinalReply seam (final assistant text)', () => {
165
+ it('calls onFinalReply with the final assistant body AFTER flushing the text', async () => {
166
+ const { framePump, flushedFinal } = fakeFramePump();
167
+ const seen: string[] = [];
168
+ const session = turnSession((emit) => {
169
+ emit({ type: 'assistant_chunk', delta: 'Hel' });
170
+ emit({ type: 'assistant_message', content: 'Hello there.' });
171
+ });
172
+ await runUserTurn(
173
+ { reply: async () => {} } as any,
174
+ {
175
+ session,
176
+ bot: null,
177
+ framePump,
178
+ typing: typingNoop,
179
+ onFinalReply: async (t) => void seen.push(t),
180
+ },
181
+ { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t1') },
182
+ );
183
+ expect(flushedFinal).toContain(true);
184
+ expect(seen).toEqual(['Hello there.']);
185
+ });
186
+
187
+ it('does not speak a tool-only turn (empty assistant body)', async () => {
188
+ const { framePump } = fakeFramePump();
189
+ const seen: string[] = [];
190
+ const session = turnSession((emit) => {
191
+ emit({ type: 'tool_call_requested', callId: 'c1', name: 'read', input: {} });
192
+ });
193
+ await runUserTurn(
194
+ { reply: async () => {} } as any,
195
+ { session, bot: null, framePump, typing: typingNoop, onFinalReply: async (t) => void seen.push(t) },
196
+ { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t2') },
197
+ );
198
+ expect(seen).toEqual([]);
199
+ });
200
+
201
+ it('never breaks the (already-sent) text turn when the voice hook rejects', async () => {
202
+ const { framePump } = fakeFramePump();
203
+ const replies: string[] = [];
204
+ const session = turnSession((emit) => emit({ type: 'assistant_message', content: 'Done.' }));
205
+ await expect(
206
+ runUserTurn(
207
+ { reply: async (t: string) => void replies.push(t) } as any,
208
+ {
209
+ session,
210
+ bot: null,
211
+ framePump,
212
+ typing: typingNoop,
213
+ onFinalReply: async () => {
214
+ throw new Error('tts exploded');
215
+ },
216
+ },
217
+ { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t3') },
218
+ ),
219
+ ).resolves.toBeUndefined();
220
+ // No "Turn failed" reply — the hook failure was swallowed.
221
+ expect(replies.join(' ')).not.toContain('Turn failed');
222
+ });
223
+ });
@@ -0,0 +1,7 @@
1
+ import { expectTypeOf } from 'vitest';
2
+ import type { Channel } from '@moxxy/sdk';
3
+ import type { TelegramChannel} from './channel.js';
4
+ import { type TelegramStartOpts } from './channel.js';
5
+
6
+ // Compile-time assertion: TelegramChannel satisfies the Channel<TelegramStartOpts> contract.
7
+ expectTypeOf<TelegramChannel>().toMatchTypeOf<Channel<TelegramStartOpts>>();