@moxxy/channel-kit 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/frame-pump.d.ts +77 -0
- package/dist/frame-pump.d.ts.map +1 -0
- package/dist/frame-pump.js +118 -0
- package/dist/frame-pump.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/dedupe.d.ts +31 -0
- package/dist/ingest/dedupe.d.ts.map +1 -0
- package/dist/ingest/dedupe.js +66 -0
- package/dist/ingest/dedupe.js.map +1 -0
- package/dist/ingest/http-server.d.ts +76 -0
- package/dist/ingest/http-server.d.ts.map +1 -0
- package/dist/ingest/http-server.js +101 -0
- package/dist/ingest/http-server.js.map +1 -0
- package/dist/pairing/host-code.d.ts +94 -0
- package/dist/pairing/host-code.d.ts.map +1 -0
- package/dist/pairing/host-code.js +107 -0
- package/dist/pairing/host-code.js.map +1 -0
- package/dist/pairing/tofu.d.ts +33 -0
- package/dist/pairing/tofu.d.ts.map +1 -0
- package/dist/pairing/tofu.js +41 -0
- package/dist/pairing/tofu.js.map +1 -0
- package/dist/permission.d.ts +28 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +17 -0
- package/dist/permission.js.map +1 -0
- package/dist/plain-turn-renderer.d.ts +17 -0
- package/dist/plain-turn-renderer.d.ts.map +1 -0
- package/dist/plain-turn-renderer.js +28 -0
- package/dist/plain-turn-renderer.js.map +1 -0
- package/dist/secrets.d.ts +18 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +16 -0
- package/dist/secrets.js.map +1 -0
- package/dist/turn.d.ts +96 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +106 -0
- package/dist/turn.js.map +1 -0
- package/dist/voice-reply.d.ts +139 -0
- package/dist/voice-reply.d.ts.map +1 -0
- package/dist/voice-reply.js +333 -0
- package/dist/voice-reply.js.map +1 -0
- package/package.json +54 -0
- package/src/frame-pump.test.ts +209 -0
- package/src/frame-pump.ts +154 -0
- package/src/index.ts +66 -0
- package/src/ingest/dedupe.test.ts +58 -0
- package/src/ingest/dedupe.ts +66 -0
- package/src/ingest/http-server.test.ts +120 -0
- package/src/ingest/http-server.ts +173 -0
- package/src/pairing/host-code.test.ts +121 -0
- package/src/pairing/host-code.ts +159 -0
- package/src/pairing/tofu.test.ts +62 -0
- package/src/pairing/tofu.ts +60 -0
- package/src/permission.test.ts +69 -0
- package/src/permission.ts +46 -0
- package/src/plain-turn-renderer.ts +31 -0
- package/src/secrets.test.ts +59 -0
- package/src/secrets.ts +26 -0
- package/src/turn.test.ts +165 -0
- package/src/turn.ts +162 -0
- package/src/voice-reply.test.ts +316 -0
- package/src/voice-reply.ts +449 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { FramePump, type FrameSink } from './frame-pump.js';
|
|
3
|
+
|
|
4
|
+
interface SinkCall {
|
|
5
|
+
readonly op: 'send' | 'edit';
|
|
6
|
+
readonly id?: number;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly final: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeSink(opts: { failSends?: boolean } = {}): { sink: FrameSink<number>; calls: SinkCall[] } {
|
|
12
|
+
const calls: SinkCall[] = [];
|
|
13
|
+
let nextId = 0;
|
|
14
|
+
const sink: FrameSink<number> = {
|
|
15
|
+
async send(text, final) {
|
|
16
|
+
calls.push({ op: 'send', text, final });
|
|
17
|
+
if (opts.failSends) return null;
|
|
18
|
+
return ++nextId;
|
|
19
|
+
},
|
|
20
|
+
async edit(id, text, final) {
|
|
21
|
+
calls.push({ op: 'edit', id, text, final });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return { sink, calls };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('FramePump', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('sends the first frame, then edits the same message for later frames', async () => {
|
|
36
|
+
const { sink, calls } = makeSink();
|
|
37
|
+
let text = '';
|
|
38
|
+
const pump = new FramePump<number>({ sink, editFrameMs: 1000, frame: () => text });
|
|
39
|
+
|
|
40
|
+
text = 'Hello';
|
|
41
|
+
pump.scheduleEdit();
|
|
42
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
43
|
+
expect(calls).toEqual([{ op: 'send', text: 'Hello', final: false }]);
|
|
44
|
+
expect(pump.messageId).toBe(1);
|
|
45
|
+
|
|
46
|
+
text = 'Hello, world';
|
|
47
|
+
pump.scheduleEdit();
|
|
48
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
49
|
+
expect(calls[1]).toEqual({ op: 'edit', id: 1, text: 'Hello, world', final: false });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('throttles edits: many schedules inside one window produce one flush', async () => {
|
|
53
|
+
const { sink, calls } = makeSink();
|
|
54
|
+
let text = '';
|
|
55
|
+
const pump = new FramePump<number>({ sink, editFrameMs: 1000, frame: () => text });
|
|
56
|
+
|
|
57
|
+
for (const t of ['a', 'ab', 'abc', 'abcd']) {
|
|
58
|
+
text = t;
|
|
59
|
+
pump.scheduleEdit();
|
|
60
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
61
|
+
}
|
|
62
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
63
|
+
// One send carrying the newest text at flush time — not four.
|
|
64
|
+
expect(calls).toHaveLength(1);
|
|
65
|
+
expect(calls[0]?.text).toBe('abcd');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips a flush whose frame is unchanged', async () => {
|
|
69
|
+
const { sink, calls } = makeSink();
|
|
70
|
+
const pump = new FramePump<number>({ sink, editFrameMs: 1000, frame: () => 'same' });
|
|
71
|
+
|
|
72
|
+
await pump.flush(false);
|
|
73
|
+
await pump.flush(false);
|
|
74
|
+
await pump.flush(true);
|
|
75
|
+
expect(calls).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('alwaysFlushFinal delivers the final frame to the sink even when unchanged', async () => {
|
|
79
|
+
const { sink, calls } = makeSink();
|
|
80
|
+
const pump = new FramePump<number>({
|
|
81
|
+
sink,
|
|
82
|
+
editFrameMs: 1000,
|
|
83
|
+
frame: () => 'same',
|
|
84
|
+
alwaysFlushFinal: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await pump.flush(false);
|
|
88
|
+
await pump.flush(true);
|
|
89
|
+
expect(calls).toEqual([
|
|
90
|
+
{ op: 'send', text: 'same', final: false },
|
|
91
|
+
{ op: 'edit', id: 1, text: 'same', final: true },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('posts emptyFinalText when a final flush finds no content and nothing was sent', async () => {
|
|
96
|
+
const { sink, calls } = makeSink();
|
|
97
|
+
const pump = new FramePump<number>({
|
|
98
|
+
sink,
|
|
99
|
+
editFrameMs: 1000,
|
|
100
|
+
frame: () => '',
|
|
101
|
+
emptyFinalText: '(no output)',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await pump.flush(false); // mid-stream empty → nothing
|
|
105
|
+
expect(calls).toHaveLength(0);
|
|
106
|
+
await pump.flush(true);
|
|
107
|
+
expect(calls).toEqual([{ op: 'send', text: '(no output)', final: true }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not post emptyFinalText when a message was already sent', async () => {
|
|
111
|
+
const { sink, calls } = makeSink();
|
|
112
|
+
let text = 'content';
|
|
113
|
+
const pump = new FramePump<number>({
|
|
114
|
+
sink,
|
|
115
|
+
editFrameMs: 1000,
|
|
116
|
+
frame: () => text,
|
|
117
|
+
emptyFinalText: '(no output)',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await pump.flush(false);
|
|
121
|
+
text = '';
|
|
122
|
+
await pump.flush(true);
|
|
123
|
+
expect(calls).toEqual([{ op: 'send', text: 'content', final: false }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('retries as a fresh send when the first send failed (id stays null)', async () => {
|
|
127
|
+
const failing = makeSink({ failSends: true });
|
|
128
|
+
let text = 'one';
|
|
129
|
+
const pump = new FramePump<number>({
|
|
130
|
+
sink: failing.sink,
|
|
131
|
+
editFrameMs: 1000,
|
|
132
|
+
frame: () => text,
|
|
133
|
+
});
|
|
134
|
+
await pump.flush(false);
|
|
135
|
+
expect(pump.messageId).toBeNull();
|
|
136
|
+
|
|
137
|
+
text = 'two';
|
|
138
|
+
await pump.flush(false);
|
|
139
|
+
// Still a send (never an edit on a null id).
|
|
140
|
+
expect(failing.calls.map((c) => c.op)).toEqual(['send', 'send']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('a final flush waits out an in-flight send instead of being dropped', async () => {
|
|
144
|
+
const calls: SinkCall[] = [];
|
|
145
|
+
let text = 'streamed';
|
|
146
|
+
let release: (() => void) | null = null;
|
|
147
|
+
const gate = new Promise<void>((resolve) => {
|
|
148
|
+
release = resolve;
|
|
149
|
+
});
|
|
150
|
+
const sink: FrameSink<number> = {
|
|
151
|
+
async send(t, final) {
|
|
152
|
+
calls.push({ op: 'send', text: t, final });
|
|
153
|
+
await gate; // hold the first send in flight
|
|
154
|
+
return 1;
|
|
155
|
+
},
|
|
156
|
+
async edit(id, t, final) {
|
|
157
|
+
calls.push({ op: 'edit', id, text: t, final });
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
const pump = new FramePump<number>({ sink, editFrameMs: 1000, frame: () => text });
|
|
161
|
+
|
|
162
|
+
const first = pump.flush(false); // in flight, parked on the gate
|
|
163
|
+
text = 'streamed + final';
|
|
164
|
+
const final = pump.flush(true); // must wait, then deliver the final text
|
|
165
|
+
release?.();
|
|
166
|
+
await first;
|
|
167
|
+
await final;
|
|
168
|
+
|
|
169
|
+
expect(calls).toEqual([
|
|
170
|
+
{ op: 'send', text: 'streamed', final: false },
|
|
171
|
+
{ op: 'edit', id: 1, text: 'streamed + final', final: true },
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('re-arms the timer when content advances during a send', async () => {
|
|
176
|
+
const calls: SinkCall[] = [];
|
|
177
|
+
let text = 'v1';
|
|
178
|
+
let sends = 0;
|
|
179
|
+
const sink: FrameSink<number> = {
|
|
180
|
+
async send(t, final) {
|
|
181
|
+
calls.push({ op: 'send', text: t, final });
|
|
182
|
+
sends += 1;
|
|
183
|
+
if (sends === 1) text = 'v2'; // new content lands mid-send
|
|
184
|
+
return sends;
|
|
185
|
+
},
|
|
186
|
+
async edit(id, t, final) {
|
|
187
|
+
calls.push({ op: 'edit', id, text: t, final });
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const pump = new FramePump<number>({ sink, editFrameMs: 1000, frame: () => text });
|
|
191
|
+
|
|
192
|
+
await pump.flush(false);
|
|
193
|
+
// The pump noticed the newer frame and re-armed its own timer.
|
|
194
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
195
|
+
expect(calls).toEqual([
|
|
196
|
+
{ op: 'send', text: 'v1', final: false },
|
|
197
|
+
{ op: 'edit', id: 1, text: 'v2', final: false },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('dispose cancels a pending edit', async () => {
|
|
202
|
+
const { sink, calls } = makeSink();
|
|
203
|
+
const pump = new FramePump<number>({ sink, editFrameMs: 1000, frame: () => 'x' });
|
|
204
|
+
pump.scheduleEdit();
|
|
205
|
+
pump.dispose();
|
|
206
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
207
|
+
expect(calls).toHaveLength(0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throttled "send once, then edit one message" streaming loop — the shared core
|
|
3
|
+
* behind the Telegram and Slack frame pumps. The channel feeds it "something
|
|
4
|
+
* changed" signals (`scheduleEdit`); the pump pulls the current frame text from
|
|
5
|
+
* the `frame` callback at flush time and drives a tiny messenger-agnostic
|
|
6
|
+
* {@link FrameSink} (`send` a new message, `edit` an existing one).
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle per turn:
|
|
9
|
+
* 1. construct with a sink bound to the turn's target (chat / thread).
|
|
10
|
+
* 2. `scheduleEdit()` whenever the rendered snapshot changes → debounced
|
|
11
|
+
* `flush(false)` after `editFrameMs`.
|
|
12
|
+
* 3. `flush(true)` on turn completion drains the final snapshot (and posts
|
|
13
|
+
* `emptyFinalText` when the turn rendered nothing at all).
|
|
14
|
+
* 4. `dispose()` clears the timer.
|
|
15
|
+
*
|
|
16
|
+
* Messenger-specific concerns stay in the sink: Telegram's HTML parse-mode
|
|
17
|
+
* fallback and 4096-char message splitting, Slack's `chat.postMessage` /
|
|
18
|
+
* `chat.update` calls. The `final` flag is forwarded so a sink can perform
|
|
19
|
+
* final-only work (Telegram sends split-overflow tails only on the last frame).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Messenger adapter the pump drives. Implementations should swallow their own
|
|
23
|
+
* transport errors (log + return null / resolve) — a failed frame must never
|
|
24
|
+
* abort the turn. */
|
|
25
|
+
export interface FrameSink<Id> {
|
|
26
|
+
/** Send a NEW message; returns its id, or null when the send failed. */
|
|
27
|
+
send(text: string, final: boolean): Promise<Id | null>;
|
|
28
|
+
/** Edit a previously sent message in place. */
|
|
29
|
+
edit(id: Id, text: string, final: boolean): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FramePumpOptions<Id> {
|
|
33
|
+
readonly sink: FrameSink<Id>;
|
|
34
|
+
/** Debounce window for streaming edits (typically ~1s). */
|
|
35
|
+
readonly editFrameMs: number;
|
|
36
|
+
/**
|
|
37
|
+
* Produce the current frame text. Called at flush time (pull model) so the
|
|
38
|
+
* flushed frame always reflects the newest renderer state; `final` lets a
|
|
39
|
+
* renderer emit a different last frame (e.g. Telegram collapses its activity
|
|
40
|
+
* trace only on the final flush). An empty string means "nothing to show".
|
|
41
|
+
*/
|
|
42
|
+
readonly frame: (final: boolean) => string;
|
|
43
|
+
/**
|
|
44
|
+
* Sent when the FINAL flush finds no content and no message was ever sent,
|
|
45
|
+
* so the user isn't left staring at a placeholder-less turn. Omit to skip.
|
|
46
|
+
*/
|
|
47
|
+
readonly emptyFinalText?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Deliver the final frame to the sink even when its text is identical to the
|
|
50
|
+
* last sent frame. Channels whose sink does final-only work that must not be
|
|
51
|
+
* skipped (Telegram's split tails) set this; channels where the final frame
|
|
52
|
+
* is a plain re-send (Slack) leave it off and save the no-op API call.
|
|
53
|
+
*/
|
|
54
|
+
readonly alwaysFlushFinal?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class FramePump<Id> {
|
|
58
|
+
private readonly opts: FramePumpOptions<Id>;
|
|
59
|
+
private id: Id | null = null;
|
|
60
|
+
private lastSent = '';
|
|
61
|
+
private editTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
/** In-flight send/edit, so timer + flush never overlap (single-flight). */
|
|
63
|
+
private inflight: Promise<void> | null = null;
|
|
64
|
+
|
|
65
|
+
constructor(opts: FramePumpOptions<Id>) {
|
|
66
|
+
this.opts = opts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The id of the streamed message once sent (null until the first frame lands). */
|
|
70
|
+
get messageId(): Id | null {
|
|
71
|
+
return this.id;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Debounced "content changed" signal → `flush(false)` after `editFrameMs`. */
|
|
75
|
+
scheduleEdit(): void {
|
|
76
|
+
if (this.editTimer || this.inflight) return;
|
|
77
|
+
this.editTimer = setTimeout(() => {
|
|
78
|
+
this.editTimer = null;
|
|
79
|
+
void this.flush(false);
|
|
80
|
+
}, this.opts.editFrameMs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Drain the current frame to the sink. The first non-empty frame `send`s a
|
|
85
|
+
* new message; every later frame `edit`s it in place. A `final` flush is
|
|
86
|
+
* never dropped: if a send is in flight it waits for it, and it guarantees
|
|
87
|
+
* at least one message (via `emptyFinalText`) when the turn rendered nothing.
|
|
88
|
+
*/
|
|
89
|
+
async flush(final: boolean): Promise<void> {
|
|
90
|
+
this.cancelTimer();
|
|
91
|
+
if (this.inflight) {
|
|
92
|
+
if (!final) {
|
|
93
|
+
// A send is running; re-arm so the newest text lands after it.
|
|
94
|
+
if (this.opts.frame(false) !== this.lastSent) this.scheduleEdit();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Final flushes must not be lost — wait out the in-flight send.
|
|
98
|
+
await this.inflight;
|
|
99
|
+
}
|
|
100
|
+
const text = this.opts.frame(final);
|
|
101
|
+
if (!text) {
|
|
102
|
+
if (final && this.id == null && this.opts.emptyFinalText) {
|
|
103
|
+
const placeholder = this.opts.emptyFinalText;
|
|
104
|
+
await this.track(async () => {
|
|
105
|
+
const sent = await this.opts.sink.send(placeholder, final);
|
|
106
|
+
if (sent != null) this.id = sent;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (text === this.lastSent && !(final && this.opts.alwaysFlushFinal)) return;
|
|
112
|
+
await this.track(async () => {
|
|
113
|
+
if (this.id == null) {
|
|
114
|
+
const sent = await this.opts.sink.send(text, final);
|
|
115
|
+
if (sent != null) this.id = sent;
|
|
116
|
+
} else {
|
|
117
|
+
await this.opts.sink.edit(this.id, text, final);
|
|
118
|
+
}
|
|
119
|
+
this.lastSent = text;
|
|
120
|
+
});
|
|
121
|
+
// Content may have advanced while the send was in flight.
|
|
122
|
+
if (final) {
|
|
123
|
+
if (this.opts.frame(true) !== this.lastSent) await this.flush(true);
|
|
124
|
+
} else if (this.opts.frame(false) !== this.lastSent) {
|
|
125
|
+
this.scheduleEdit();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dispose(): void {
|
|
130
|
+
this.cancelTimer();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async track(work: () => Promise<void>): Promise<void> {
|
|
134
|
+
const run = work();
|
|
135
|
+
// Waiters must never observe a rejection here; errors propagate to the
|
|
136
|
+
// caller of `flush` below (sinks are expected to swallow transport errors).
|
|
137
|
+
this.inflight = run.then(
|
|
138
|
+
() => undefined,
|
|
139
|
+
() => undefined,
|
|
140
|
+
);
|
|
141
|
+
try {
|
|
142
|
+
await run;
|
|
143
|
+
} finally {
|
|
144
|
+
this.inflight = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private cancelTimer(): void {
|
|
149
|
+
if (this.editTimer) {
|
|
150
|
+
clearTimeout(this.editTimer);
|
|
151
|
+
this.editTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @moxxy/channel-kit — shared machinery for building moxxy messaging channels.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the Telegram and Slack channel plugins so new channels
|
|
5
|
+
* (Discord, WhatsApp, Signal, ...) are thin adapters: messenger-specific quirks
|
|
6
|
+
* (formatting, transport error handling, signature schemes, pairing wording)
|
|
7
|
+
* stay in each plugin; the load-bearing loop mechanics live here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { FramePump, type FrameSink, type FramePumpOptions } from './frame-pump.js';
|
|
11
|
+
export {
|
|
12
|
+
TurnCoordinator,
|
|
13
|
+
driveTurn,
|
|
14
|
+
subscribeTurn,
|
|
15
|
+
type DriveTurnOptions,
|
|
16
|
+
type TurnCoordinatorOptions,
|
|
17
|
+
type TurnEventSource,
|
|
18
|
+
type TurnLease,
|
|
19
|
+
type TurnSession,
|
|
20
|
+
} from './turn.js';
|
|
21
|
+
export { PlainTurnRenderer } from './plain-turn-renderer.js';
|
|
22
|
+
export {
|
|
23
|
+
clearHostCodePairing,
|
|
24
|
+
createHostCodeState,
|
|
25
|
+
greetPeer,
|
|
26
|
+
isPeerAuthorized,
|
|
27
|
+
openHostCodeWindow,
|
|
28
|
+
submitPeerCode,
|
|
29
|
+
type HostCodeAction,
|
|
30
|
+
type HostCodeDecision,
|
|
31
|
+
type HostCodePhase,
|
|
32
|
+
type HostCodeState,
|
|
33
|
+
} from './pairing/host-code.js';
|
|
34
|
+
export { TofuPairingWindow, type TofuPairingWindowOptions } from './pairing/tofu.js';
|
|
35
|
+
export { resolveSecret, type SecretReader, type SecretSpec } from './secrets.js';
|
|
36
|
+
export {
|
|
37
|
+
createAuditedAllowListResolver,
|
|
38
|
+
type AuditedAllowListOptions,
|
|
39
|
+
} from './permission.js';
|
|
40
|
+
export {
|
|
41
|
+
IngestHttpServer,
|
|
42
|
+
respondJson,
|
|
43
|
+
type IngestHttpServerHandle,
|
|
44
|
+
type IngestHttpServerOptions,
|
|
45
|
+
type IngestLogger,
|
|
46
|
+
type IngestVerdict,
|
|
47
|
+
} from './ingest/http-server.js';
|
|
48
|
+
export { DeliveryDedupeCache } from './ingest/dedupe.js';
|
|
49
|
+
export {
|
|
50
|
+
audioExtForMime,
|
|
51
|
+
deliverVoiceReply,
|
|
52
|
+
ensureOggOpus,
|
|
53
|
+
resolveVoiceToggle,
|
|
54
|
+
synthesizeReply,
|
|
55
|
+
toSpeech,
|
|
56
|
+
type DeliverVoiceReplyOptions,
|
|
57
|
+
type EnsureOggOpusOptions,
|
|
58
|
+
type EnsureOggOpusResult,
|
|
59
|
+
type SynthesizeReplyOptions,
|
|
60
|
+
type SynthesizeReplyResult,
|
|
61
|
+
type SynthesizerSource,
|
|
62
|
+
type VoiceReplyOutcome,
|
|
63
|
+
type VoiceReplySink,
|
|
64
|
+
type VoiceToggleInput,
|
|
65
|
+
type VoiceToggleResult,
|
|
66
|
+
} from './voice-reply.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { DeliveryDedupeCache } from './dedupe.js';
|
|
3
|
+
|
|
4
|
+
describe('DeliveryDedupeCache', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(0);
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('admits a new event_id once, then rejects the repeat', () => {
|
|
14
|
+
const c = new DeliveryDedupeCache();
|
|
15
|
+
expect(c.check('Ev123')).toBe(true);
|
|
16
|
+
expect(c.check('Ev123')).toBe(false);
|
|
17
|
+
expect(c.check('Ev123')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('distinct event ids are independent', () => {
|
|
21
|
+
const c = new DeliveryDedupeCache();
|
|
22
|
+
expect(c.check('Ev1')).toBe(true);
|
|
23
|
+
expect(c.check('Ev2')).toBe(true);
|
|
24
|
+
expect(c.check('Ev1')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('re-admits an event after its TTL has elapsed', () => {
|
|
28
|
+
const c = new DeliveryDedupeCache({ ttlMs: 1000 });
|
|
29
|
+
expect(c.check('k')).toBe(true);
|
|
30
|
+
expect(c.check('k')).toBe(false);
|
|
31
|
+
vi.setSystemTime(1000);
|
|
32
|
+
expect(c.check('k')).toBe(false);
|
|
33
|
+
vi.setSystemTime(2001);
|
|
34
|
+
expect(c.check('k')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('evicts the oldest entry once maxEntries is exceeded', () => {
|
|
38
|
+
const c = new DeliveryDedupeCache({ maxEntries: 2 });
|
|
39
|
+
expect(c.check('a')).toBe(true);
|
|
40
|
+
vi.setSystemTime(1);
|
|
41
|
+
expect(c.check('b')).toBe(true);
|
|
42
|
+
expect(c.size()).toBe(2);
|
|
43
|
+
vi.setSystemTime(2);
|
|
44
|
+
expect(c.check('c')).toBe(true); // evicts 'a'
|
|
45
|
+
expect(c.size()).toBe(2);
|
|
46
|
+
expect(c.check('a')).toBe(true); // 'a' was dropped → new again
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('clear() empties the cache', () => {
|
|
50
|
+
const c = new DeliveryDedupeCache();
|
|
51
|
+
c.check('a');
|
|
52
|
+
c.check('b');
|
|
53
|
+
expect(c.size()).toBe(2);
|
|
54
|
+
c.clear();
|
|
55
|
+
expect(c.size()).toBe(0);
|
|
56
|
+
expect(c.check('a')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory LRU for inbound-delivery idempotency. Webhook providers retry
|
|
3
|
+
* deliveries and the same event id may arrive more than once, so channels
|
|
4
|
+
* dedupe by delivery id for a bounded window (Slack: `event_id` +
|
|
5
|
+
* `X-Slack-Retry-Num`; same at-least-once pattern as `@moxxy/plugin-webhooks`).
|
|
6
|
+
*
|
|
7
|
+
* Not persistent — a restart resets the cache, which is acceptable: worst case
|
|
8
|
+
* the agent processes one event twice immediately after restart, which is rare
|
|
9
|
+
* and self-limiting.
|
|
10
|
+
*/
|
|
11
|
+
export class DeliveryDedupeCache {
|
|
12
|
+
private readonly seen = new Map<string, number>();
|
|
13
|
+
private readonly maxEntries: number;
|
|
14
|
+
private readonly ttlMs: number;
|
|
15
|
+
/** Sweep TTL-expired entries at most once per this interval. The LRU overflow
|
|
16
|
+
* cap already bounds memory, so the TTL sweep need not run on every check —
|
|
17
|
+
* amortizing it keeps the pre-ACK path O(1) under retry storms. */
|
|
18
|
+
private readonly sweepEveryMs: number;
|
|
19
|
+
private lastSweepMs = -Infinity;
|
|
20
|
+
|
|
21
|
+
constructor(opts: { maxEntries?: number; ttlMs?: number; sweepEveryMs?: number } = {}) {
|
|
22
|
+
this.maxEntries = opts.maxEntries ?? 4096;
|
|
23
|
+
this.ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000;
|
|
24
|
+
this.sweepEveryMs = opts.sweepEveryMs ?? Math.min(this.ttlMs, 15_000);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns true if this is a new key (now recorded); false if a duplicate. */
|
|
28
|
+
check(key: string): boolean {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
this.evictExpired(now);
|
|
31
|
+
const seenAt = this.seen.get(key);
|
|
32
|
+
// Honor the TTL on the looked-up key directly so amortizing the full sweep
|
|
33
|
+
// never makes an already-expired key read as a live duplicate.
|
|
34
|
+
if (seenAt !== undefined && seenAt >= now - this.ttlMs) {
|
|
35
|
+
this.seen.delete(key);
|
|
36
|
+
this.seen.set(key, now);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (seenAt !== undefined) this.seen.delete(key);
|
|
40
|
+
this.seen.set(key, now);
|
|
41
|
+
if (this.seen.size > this.maxEntries) {
|
|
42
|
+
const first = this.seen.keys().next();
|
|
43
|
+
if (!first.done) this.seen.delete(first.value);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
size(): number {
|
|
49
|
+
return this.seen.size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clear(): void {
|
|
53
|
+
this.seen.clear();
|
|
54
|
+
this.lastSweepMs = -Infinity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private evictExpired(now: number): void {
|
|
58
|
+
if (now - this.lastSweepMs < this.sweepEveryMs) return;
|
|
59
|
+
this.lastSweepMs = now;
|
|
60
|
+
const cutoff = now - this.ttlMs;
|
|
61
|
+
for (const [k, ts] of this.seen) {
|
|
62
|
+
if (ts >= cutoff) break;
|
|
63
|
+
this.seen.delete(k);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { IngestHttpServer, respondJson } from './http-server.js';
|
|
4
|
+
|
|
5
|
+
const EVENTS = '/test/events';
|
|
6
|
+
const HEALTH = '/test/health';
|
|
7
|
+
|
|
8
|
+
interface Harness {
|
|
9
|
+
server: IngestHttpServer;
|
|
10
|
+
base: string;
|
|
11
|
+
received: Array<{ body: string; headers: Record<string, unknown> }>;
|
|
12
|
+
stop(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function makeServer(
|
|
16
|
+
over: Partial<{
|
|
17
|
+
verifyOk: boolean;
|
|
18
|
+
maxBodyBytes: number;
|
|
19
|
+
handleVerified: (raw: Buffer, req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
20
|
+
}> = {},
|
|
21
|
+
): Promise<Harness> {
|
|
22
|
+
const received: Harness['received'] = [];
|
|
23
|
+
const server = new IngestHttpServer({
|
|
24
|
+
eventsPath: EVENTS,
|
|
25
|
+
healthPath: HEALTH,
|
|
26
|
+
healthBody: () => ({ status: 'ok', listener: 'test' }),
|
|
27
|
+
label: 'test',
|
|
28
|
+
...(over.maxBodyBytes !== undefined ? { maxBodyBytes: over.maxBodyBytes } : {}),
|
|
29
|
+
verify: () =>
|
|
30
|
+
(over.verifyOk ?? true) ? { ok: true } : { ok: false, reason: 'bad signature' },
|
|
31
|
+
handleVerified:
|
|
32
|
+
over.handleVerified ??
|
|
33
|
+
(async (raw, req, res) => {
|
|
34
|
+
received.push({ body: raw.toString('utf8'), headers: { ...req.headers } });
|
|
35
|
+
respondJson(res, 200, { status: 'ok' });
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
const bound = await server.start();
|
|
39
|
+
return {
|
|
40
|
+
server,
|
|
41
|
+
base: `http://${bound.host}:${bound.port}`,
|
|
42
|
+
received,
|
|
43
|
+
stop: () => server.stop(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('IngestHttpServer', () => {
|
|
48
|
+
let h: Harness;
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await h?.stop();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('serves the health probe', async () => {
|
|
54
|
+
h = await makeServer();
|
|
55
|
+
const res = await fetch(`${h.base}${HEALTH}`);
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
expect(await res.json()).toEqual({ status: 'ok', listener: 'test' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('404s anything that is not POST on the events path', async () => {
|
|
61
|
+
h = await makeServer();
|
|
62
|
+
expect((await fetch(`${h.base}${EVENTS}`, { method: 'GET' })).status).toBe(404);
|
|
63
|
+
expect((await fetch(`${h.base}/other`, { method: 'POST', body: '{}' })).status).toBe(404);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('hands the verified raw body to handleVerified', async () => {
|
|
67
|
+
h = await makeServer();
|
|
68
|
+
const body = JSON.stringify({ hello: 'world' });
|
|
69
|
+
const res = await fetch(`${h.base}${EVENTS}`, { method: 'POST', body });
|
|
70
|
+
expect(res.status).toBe(200);
|
|
71
|
+
expect(h.received).toHaveLength(1);
|
|
72
|
+
expect(h.received[0]?.body).toBe(body);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('401s when verification fails, before handleVerified', async () => {
|
|
76
|
+
h = await makeServer({ verifyOk: false });
|
|
77
|
+
const res = await fetch(`${h.base}${EVENTS}`, { method: 'POST', body: '{}' });
|
|
78
|
+
expect(res.status).toBe(401);
|
|
79
|
+
expect(await res.json()).toEqual({ error: 'verification_failed' });
|
|
80
|
+
expect(h.received).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects an oversized body without reaching handleVerified', async () => {
|
|
84
|
+
h = await makeServer({ maxBodyBytes: 16 });
|
|
85
|
+
// readRequestBody may destroy the socket at the cap, so accept 413 or a
|
|
86
|
+
// dropped connection — what MUST hold is that the handler never ran.
|
|
87
|
+
let status = 0;
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${h.base}${EVENTS}`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: 'x'.repeat(500),
|
|
92
|
+
});
|
|
93
|
+
status = res.status;
|
|
94
|
+
} catch {
|
|
95
|
+
status = -1;
|
|
96
|
+
}
|
|
97
|
+
expect([413, -1]).toContain(status);
|
|
98
|
+
expect(h.received).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a throwing handler becomes a 500, never an uncaught exception', async () => {
|
|
102
|
+
h = await makeServer({
|
|
103
|
+
handleVerified: async () => {
|
|
104
|
+
throw new Error('boom');
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const res = await fetch(`${h.base}${EVENTS}`, { method: 'POST', body: '{}' });
|
|
108
|
+
expect(res.status).toBe(500);
|
|
109
|
+
expect(await res.json()).toEqual({ error: 'internal' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('start is idempotent and stop unbinds', async () => {
|
|
113
|
+
h = await makeServer();
|
|
114
|
+
const again = await h.server.start();
|
|
115
|
+
expect(again.port).toBe(h.server.port);
|
|
116
|
+
await h.stop();
|
|
117
|
+
await expect(fetch(`${h.base}${HEALTH}`)).rejects.toThrow();
|
|
118
|
+
h = { stop: async () => {} } as Harness;
|
|
119
|
+
});
|
|
120
|
+
});
|