@sigrun/ext-gateway 0.0.0-stage → 0.1.1
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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +111 -0
- package/dist/interactions/chat.d.ts +20 -0
- package/dist/interactions/chat.js +155 -0
- package/dist/interactions/prompts.d.ts +42 -0
- package/dist/interactions/prompts.js +96 -0
- package/dist/interactions/receipts.d.ts +6 -0
- package/dist/interactions/receipts.js +36 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +57 -0
- package/dist/outbound/delivery.d.ts +124 -0
- package/dist/outbound/delivery.js +387 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +80 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +8 -0
- package/dist/session-meta.js +21 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +61 -0
- package/dist/state/identities.js +131 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +152 -0
- package/dist/state/ledger.js +207 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- package/package.json +54 -5
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-channel delivery worker (spec §8.6): ONE in-flight send per
|
|
3
|
+
* channel, entries pulled from the ledger in enqueue order.
|
|
4
|
+
*
|
|
5
|
+
* Retry ladder (pinned): a `transient` failure retries at 1000, 5000
|
|
6
|
+
* and 30000 ms, then the entry is `failed`. A `permanent` failure
|
|
7
|
+
* fails at once. A `chat_gone` failure marks the binding dead
|
|
8
|
+
* (future sends to that chat skip; the chat's next inbound message
|
|
9
|
+
* clears it) and drops the entry. A send that exceeds the 60 s bound
|
|
10
|
+
* (spec's Review Focus 2 — an adapter call that never resolves)
|
|
11
|
+
* classifies TRANSIENT: the worker abandons the call, retries, and
|
|
12
|
+
* never wedges the channel's queue.
|
|
13
|
+
*
|
|
14
|
+
* Recovered entries (restart recovery flagged them) are resent with the
|
|
15
|
+
* leading "(resent after restart)" line — the platform may already
|
|
16
|
+
* hold the message, and the marker tells the human what happened.
|
|
17
|
+
*
|
|
18
|
+
* `drain(deadlineMs)` is the shutdown path: send until the ledger is
|
|
19
|
+
* empty or the deadline passes; retries are NOT waited out (a retry
|
|
20
|
+
* timer armed by drain settles after stop() — the entry stays pending
|
|
21
|
+
* and the next boot's recovery resends it).
|
|
22
|
+
*/
|
|
23
|
+
import type { ChannelContribution, OutboundMessage } from "@sigrun/sigrun/extension";
|
|
24
|
+
import type { Ledger, LedgerEntry } from "../state/ledger.js";
|
|
25
|
+
import type { BindingStore } from "../state/bindings.js";
|
|
26
|
+
import { type EngineLog } from "../log.js";
|
|
27
|
+
/** The worker's injectable dependencies. */
|
|
28
|
+
export interface DeliveryDeps {
|
|
29
|
+
readonly bindings: BindingStore;
|
|
30
|
+
/** The send bound (a hung adapter call never wedges the queue). */
|
|
31
|
+
readonly timeoutMs: number;
|
|
32
|
+
/** The pinned retry ladder: delays[attempt] before retry N. */
|
|
33
|
+
readonly delays: readonly [number, number, number];
|
|
34
|
+
/** Schedule `fn` at `ms`; the return value cancels it. */
|
|
35
|
+
readonly setTimer: (fn: () => void, ms: number) => () => void;
|
|
36
|
+
readonly log: EngineLog;
|
|
37
|
+
/** Clock (the tests' virtual clock). Default: real. */
|
|
38
|
+
readonly now?: () => number;
|
|
39
|
+
/** Re-read file bytes at send time; ledger entries persist paths only. */
|
|
40
|
+
readonly resolveFiles?: (entry: LedgerEntry) => Promise<OutboundMessage["files"]>;
|
|
41
|
+
/** Terminal delivery outcomes for the channel's status counters. */
|
|
42
|
+
readonly counts?: {
|
|
43
|
+
delivered(): void;
|
|
44
|
+
failed(): void;
|
|
45
|
+
};
|
|
46
|
+
/** Persist a delivered interaction prompt's platform refs for edits/replies. */
|
|
47
|
+
readonly onInteractionReceipt?: (entry: LedgerEntry, receipt: {
|
|
48
|
+
readonly messageIds: readonly string[];
|
|
49
|
+
}) => void | Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/** The pinned resend marker for recovered entries. */
|
|
52
|
+
export declare const RESENT_MARKER = "(resent after restart)";
|
|
53
|
+
/**
|
|
54
|
+
* One channel's delivery worker. `kick()` starts the loop if idle (an
|
|
55
|
+
* enqueue calls it); `drain(deadlineMs)` runs sends until empty or the
|
|
56
|
+
* deadline; `stop()` cancels timers and joins the in-flight send. Not
|
|
57
|
+
* restartable after `stop()` (the engine stops once per boot).
|
|
58
|
+
*/
|
|
59
|
+
export declare class DeliveryWorker {
|
|
60
|
+
private readonly channel;
|
|
61
|
+
private readonly ledger;
|
|
62
|
+
private readonly deps;
|
|
63
|
+
private readonly now;
|
|
64
|
+
private busy;
|
|
65
|
+
private stopped;
|
|
66
|
+
/** The in-flight send's settle handle (stop joins it). */
|
|
67
|
+
private inFlight;
|
|
68
|
+
/** The cancel for the armed retry timer. */
|
|
69
|
+
private cancelRetry;
|
|
70
|
+
constructor(channel: ChannelContribution, ledger: Ledger, deps: DeliveryDeps);
|
|
71
|
+
/** Start the loop if idle (the enqueue-side poke). A no-op while a
|
|
72
|
+
* drain owns the queue (the drain IS the loop until it resolves). */
|
|
73
|
+
kick(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Send until the ledger's channel queue is empty or `deadlineMs`
|
|
76
|
+
* (absolute clock ms) passes. The drain TAKES OWNERSHIP of the
|
|
77
|
+
* queue: `busy` is set for its duration (a kick during a drain is a
|
|
78
|
+
* no-op), the armed retry timer is CANCELLED (the drain pulls the
|
|
79
|
+
* re-pended entry directly — no timer-driven second loop races
|
|
80
|
+
* it), and any in-flight loop is joined first. Retries are NOT
|
|
81
|
+
* waited out: a retry-armed entry parks the drain (the entry stays
|
|
82
|
+
* pending; the next boot's recovery resends it).
|
|
83
|
+
*/
|
|
84
|
+
drain(deadlineMs: number): Promise<void>;
|
|
85
|
+
/** Cancel the retry timer and join the in-flight send. Idempotent. */
|
|
86
|
+
stop(): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* The steady-state loop: pull, send, repeat; a retry-armed state
|
|
89
|
+
* parks until the timer fires (kick re-arms after each timer hop).
|
|
90
|
+
*/
|
|
91
|
+
private runLoop;
|
|
92
|
+
/**
|
|
93
|
+
* Send one entry: attempting → send → delivered (receipt) / the
|
|
94
|
+
* error-classified failure path. Returns false when the entry needs
|
|
95
|
+
* a retry wait (the loop parks until the retry timer re-kicks) —
|
|
96
|
+
* enqueue ORDER holds: no later entry overtakes a retrying one.
|
|
97
|
+
* Never throws.
|
|
98
|
+
*/
|
|
99
|
+
private sendOne;
|
|
100
|
+
/**
|
|
101
|
+
* The platform call, abandoned at `timeoutMs`: a send that exceeds
|
|
102
|
+
* the bound classifies TRANSIENT (the worker retries, never wedges).
|
|
103
|
+
*/
|
|
104
|
+
private boundedSend;
|
|
105
|
+
/** Apply an edit entry; its ledger row carries the target MessageRef. */
|
|
106
|
+
private boundedEdit;
|
|
107
|
+
/**
|
|
108
|
+
* The classified failure path: transient retries (the ladder),
|
|
109
|
+
* permanent fails at once, chat_gone marks the binding dead.
|
|
110
|
+
* Returns false when a retry wait was armed (the caller parks).
|
|
111
|
+
*/
|
|
112
|
+
private onSendError;
|
|
113
|
+
/** A ledger mark with its own error isolation (never throws). */
|
|
114
|
+
private markLedger;
|
|
115
|
+
/**
|
|
116
|
+
* The classification read (adapter's `classifyError` when present,
|
|
117
|
+
* transient otherwise) with the timeout error forced transient.
|
|
118
|
+
*/
|
|
119
|
+
private classify;
|
|
120
|
+
/** The send-shaped message for one entry (the recovered marker rule). */
|
|
121
|
+
private buildMessage;
|
|
122
|
+
}
|
|
123
|
+
/** The timeout error's name (matched by `classify`, never instanceof). */
|
|
124
|
+
export declare const SEND_TIMEOUT_NAME = "GatewaySendTimeout";
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-channel delivery worker (spec §8.6): ONE in-flight send per
|
|
3
|
+
* channel, entries pulled from the ledger in enqueue order.
|
|
4
|
+
*
|
|
5
|
+
* Retry ladder (pinned): a `transient` failure retries at 1000, 5000
|
|
6
|
+
* and 30000 ms, then the entry is `failed`. A `permanent` failure
|
|
7
|
+
* fails at once. A `chat_gone` failure marks the binding dead
|
|
8
|
+
* (future sends to that chat skip; the chat's next inbound message
|
|
9
|
+
* clears it) and drops the entry. A send that exceeds the 60 s bound
|
|
10
|
+
* (spec's Review Focus 2 — an adapter call that never resolves)
|
|
11
|
+
* classifies TRANSIENT: the worker abandons the call, retries, and
|
|
12
|
+
* never wedges the channel's queue.
|
|
13
|
+
*
|
|
14
|
+
* Recovered entries (restart recovery flagged them) are resent with the
|
|
15
|
+
* leading "(resent after restart)" line — the platform may already
|
|
16
|
+
* hold the message, and the marker tells the human what happened.
|
|
17
|
+
*
|
|
18
|
+
* `drain(deadlineMs)` is the shutdown path: send until the ledger is
|
|
19
|
+
* empty or the deadline passes; retries are NOT waited out (a retry
|
|
20
|
+
* timer armed by drain settles after stop() — the entry stays pending
|
|
21
|
+
* and the next boot's recovery resends it).
|
|
22
|
+
*/
|
|
23
|
+
import { hashId } from "../log.js";
|
|
24
|
+
/** The pinned resend marker for recovered entries. */
|
|
25
|
+
export const RESENT_MARKER = "(resent after restart)";
|
|
26
|
+
/**
|
|
27
|
+
* One channel's delivery worker. `kick()` starts the loop if idle (an
|
|
28
|
+
* enqueue calls it); `drain(deadlineMs)` runs sends until empty or the
|
|
29
|
+
* deadline; `stop()` cancels timers and joins the in-flight send. Not
|
|
30
|
+
* restartable after `stop()` (the engine stops once per boot).
|
|
31
|
+
*/
|
|
32
|
+
export class DeliveryWorker {
|
|
33
|
+
channel;
|
|
34
|
+
ledger;
|
|
35
|
+
deps;
|
|
36
|
+
now;
|
|
37
|
+
busy = false;
|
|
38
|
+
stopped = false;
|
|
39
|
+
/** The in-flight send's settle handle (stop joins it). */
|
|
40
|
+
inFlight;
|
|
41
|
+
/** The cancel for the armed retry timer. */
|
|
42
|
+
cancelRetry;
|
|
43
|
+
constructor(channel, ledger, deps) {
|
|
44
|
+
this.channel = channel;
|
|
45
|
+
this.ledger = ledger;
|
|
46
|
+
this.deps = deps;
|
|
47
|
+
this.now = deps.now ?? Date.now;
|
|
48
|
+
}
|
|
49
|
+
/** Start the loop if idle (the enqueue-side poke). A no-op while a
|
|
50
|
+
* drain owns the queue (the drain IS the loop until it resolves). */
|
|
51
|
+
kick() {
|
|
52
|
+
if (this.stopped || this.busy)
|
|
53
|
+
return;
|
|
54
|
+
const loop = this.runLoop();
|
|
55
|
+
this.inFlight = loop.catch((error) => {
|
|
56
|
+
// A loop failure is engine-side (the ledger or the adapter
|
|
57
|
+
// wrapper): logged loud, never silent — the channel's queue
|
|
58
|
+
// stays honest (the next kick retries).
|
|
59
|
+
this.deps.log.error(`delivery: loop failed (channel "${this.channel.id}")`, error);
|
|
60
|
+
});
|
|
61
|
+
void loop;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Send until the ledger's channel queue is empty or `deadlineMs`
|
|
65
|
+
* (absolute clock ms) passes. The drain TAKES OWNERSHIP of the
|
|
66
|
+
* queue: `busy` is set for its duration (a kick during a drain is a
|
|
67
|
+
* no-op), the armed retry timer is CANCELLED (the drain pulls the
|
|
68
|
+
* re-pended entry directly — no timer-driven second loop races
|
|
69
|
+
* it), and any in-flight loop is joined first. Retries are NOT
|
|
70
|
+
* waited out: a retry-armed entry parks the drain (the entry stays
|
|
71
|
+
* pending; the next boot's recovery resends it).
|
|
72
|
+
*/
|
|
73
|
+
async drain(deadlineMs) {
|
|
74
|
+
if (this.stopped)
|
|
75
|
+
return;
|
|
76
|
+
// Cancel the pending retry: the drain owns the queue now.
|
|
77
|
+
this.cancelRetry?.();
|
|
78
|
+
this.cancelRetry = undefined;
|
|
79
|
+
// Join any in-flight loop before taking the busy flag.
|
|
80
|
+
await this.inFlight;
|
|
81
|
+
if (this.stopped)
|
|
82
|
+
return;
|
|
83
|
+
if (this.busy) {
|
|
84
|
+
// A concurrent drain (the engine's stop is idempotent, but a
|
|
85
|
+
// caller may race): join it rather than run a second loop.
|
|
86
|
+
await this.inFlight;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.busy = true;
|
|
90
|
+
try {
|
|
91
|
+
for (;;) {
|
|
92
|
+
if (this.stopped)
|
|
93
|
+
return;
|
|
94
|
+
if (this.now() >= deadlineMs)
|
|
95
|
+
return;
|
|
96
|
+
const entry = this.ledger.nextPending(this.channel.id);
|
|
97
|
+
if (entry === undefined)
|
|
98
|
+
return;
|
|
99
|
+
const progressed = await this.sendOne(entry);
|
|
100
|
+
if (!progressed)
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.busy = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Cancel the retry timer and join the in-flight send. Idempotent. */
|
|
109
|
+
async stop() {
|
|
110
|
+
if (this.stopped)
|
|
111
|
+
return;
|
|
112
|
+
this.stopped = true;
|
|
113
|
+
this.cancelRetry?.();
|
|
114
|
+
this.cancelRetry = undefined;
|
|
115
|
+
const flight = this.inFlight;
|
|
116
|
+
if (flight !== undefined)
|
|
117
|
+
await flight.catch(() => { });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The steady-state loop: pull, send, repeat; a retry-armed state
|
|
121
|
+
* parks until the timer fires (kick re-arms after each timer hop).
|
|
122
|
+
*/
|
|
123
|
+
async runLoop() {
|
|
124
|
+
if (this.busy)
|
|
125
|
+
return;
|
|
126
|
+
this.busy = true;
|
|
127
|
+
try {
|
|
128
|
+
for (;;) {
|
|
129
|
+
if (this.stopped)
|
|
130
|
+
return;
|
|
131
|
+
const entry = this.ledger.nextPending(this.channel.id);
|
|
132
|
+
if (entry === undefined)
|
|
133
|
+
return;
|
|
134
|
+
const progressed = await this.sendOne(entry);
|
|
135
|
+
if (!progressed) {
|
|
136
|
+
// A retry is armed (or the entry is terminal-failed): the loop
|
|
137
|
+
// parks — the retry timer's kick() restarts it. Enqueue order
|
|
138
|
+
// holds (no later entry overtakes a retrying one).
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
this.busy = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Send one entry: attempting → send → delivered (receipt) / the
|
|
149
|
+
* error-classified failure path. Returns false when the entry needs
|
|
150
|
+
* a retry wait (the loop parks until the retry timer re-kicks) —
|
|
151
|
+
* enqueue ORDER holds: no later entry overtakes a retrying one.
|
|
152
|
+
* Never throws.
|
|
153
|
+
*/
|
|
154
|
+
async sendOne(entry) {
|
|
155
|
+
try {
|
|
156
|
+
// Resolution belongs inside the classified boundary: a deleted
|
|
157
|
+
// or unreadable file must fail this entry, never wedge the queue.
|
|
158
|
+
const message = await this.buildMessage(entry);
|
|
159
|
+
// The attempting mark rides the same isolation as the rest: a
|
|
160
|
+
// failing ledger write is LOGGED and the send does not run (an
|
|
161
|
+
// unmarked entry must not be sent — the crash window would
|
|
162
|
+
// duplicate it on recovery).
|
|
163
|
+
await this.ledger.mark(entry.id, "attempting");
|
|
164
|
+
const receipt = entry.kind === "edit"
|
|
165
|
+
? await this.boundedEdit(entry, message)
|
|
166
|
+
: await this.boundedSend(message);
|
|
167
|
+
await this.ledger.mark(entry.id, "delivered", receipt.messageIds);
|
|
168
|
+
if (entry.kind === "interaction") {
|
|
169
|
+
await this.deps.onInteractionReceipt?.(entry, receipt);
|
|
170
|
+
}
|
|
171
|
+
this.deps.counts?.delivered();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return await this.onSendError(entry, error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The platform call, abandoned at `timeoutMs`: a send that exceeds
|
|
180
|
+
* the bound classifies TRANSIENT (the worker retries, never wedges).
|
|
181
|
+
*/
|
|
182
|
+
async boundedSend(message) {
|
|
183
|
+
return await new Promise((resolve, reject) => {
|
|
184
|
+
let settled = false;
|
|
185
|
+
const timer = this.deps.setTimer(() => {
|
|
186
|
+
if (settled)
|
|
187
|
+
return;
|
|
188
|
+
settled = true;
|
|
189
|
+
reject(timeoutError(this.deps.timeoutMs));
|
|
190
|
+
}, this.deps.timeoutMs);
|
|
191
|
+
this.channel
|
|
192
|
+
.send(message)
|
|
193
|
+
.then((receipt) => {
|
|
194
|
+
if (settled)
|
|
195
|
+
return;
|
|
196
|
+
settled = true;
|
|
197
|
+
timer();
|
|
198
|
+
resolve(receipt);
|
|
199
|
+
}, (error) => {
|
|
200
|
+
if (settled)
|
|
201
|
+
return;
|
|
202
|
+
settled = true;
|
|
203
|
+
timer();
|
|
204
|
+
reject(error);
|
|
205
|
+
})
|
|
206
|
+
.catch((error) => {
|
|
207
|
+
// The .then handlers never throw; the catch is the
|
|
208
|
+
// belt-and-braces shape for a misbehaving adapter.
|
|
209
|
+
if (settled)
|
|
210
|
+
return;
|
|
211
|
+
settled = true;
|
|
212
|
+
timer();
|
|
213
|
+
reject(error);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Apply an edit entry; its ledger row carries the target MessageRef. */
|
|
218
|
+
async boundedEdit(entry, message) {
|
|
219
|
+
if (entry.ref === undefined || this.channel.edit === undefined) {
|
|
220
|
+
const error = new Error("gateway: edit delivery requires a ref and adapter edit()");
|
|
221
|
+
error.name = EDIT_PRECONDITION_ERROR;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
await new Promise((resolve, reject) => {
|
|
225
|
+
let settled = false;
|
|
226
|
+
const timer = this.deps.setTimer(() => {
|
|
227
|
+
if (settled)
|
|
228
|
+
return;
|
|
229
|
+
settled = true;
|
|
230
|
+
reject(timeoutError(this.deps.timeoutMs));
|
|
231
|
+
}, this.deps.timeoutMs);
|
|
232
|
+
this.channel.edit(entry.ref, message).then(() => {
|
|
233
|
+
if (settled)
|
|
234
|
+
return;
|
|
235
|
+
settled = true;
|
|
236
|
+
timer();
|
|
237
|
+
resolve();
|
|
238
|
+
}, (error) => {
|
|
239
|
+
if (settled)
|
|
240
|
+
return;
|
|
241
|
+
settled = true;
|
|
242
|
+
timer();
|
|
243
|
+
reject(error);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
return { messageIds: [entry.ref.messageId] };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The classified failure path: transient retries (the ladder),
|
|
250
|
+
* permanent fails at once, chat_gone marks the binding dead.
|
|
251
|
+
* Returns false when a retry wait was armed (the caller parks).
|
|
252
|
+
*/
|
|
253
|
+
async onSendError(entry, error) {
|
|
254
|
+
const kind = this.classify(error);
|
|
255
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
256
|
+
// A ledger-mark failure arrives here too (sendOne's try wraps the
|
|
257
|
+
// attempting mark): it is NOT a send failure — a transient retry of
|
|
258
|
+
// an unmarked entry would double-send after recovery. Mark errors
|
|
259
|
+
// log loud and park the entry as-is (the next boot's recovery owns
|
|
260
|
+
// the resend).
|
|
261
|
+
if (kind === "chat_gone") {
|
|
262
|
+
if (entry.bindingKey !== undefined) {
|
|
263
|
+
try {
|
|
264
|
+
await this.deps.bindings.markDead(entry.bindingKey);
|
|
265
|
+
}
|
|
266
|
+
catch (markError) {
|
|
267
|
+
this.deps.log.error(`delivery: markDead failed for binding "${hashId(entry.bindingKey)}"`, markError);
|
|
268
|
+
this.deps.log.debug(`delivery: markDead failed for binding "${entry.bindingKey}"`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.deps.log.warn(`delivery: chat gone for "${entry.channel}:${hashId(entry.chat.chatId)}" — binding marked dead`);
|
|
272
|
+
this.deps.log.debug(`delivery: chat gone for "${entry.channel}:${entry.chat.chatId}" — binding marked dead`);
|
|
273
|
+
await this.markLedger(entry.id, "failed");
|
|
274
|
+
this.deps.counts?.failed();
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
if (kind === "permanent") {
|
|
278
|
+
const fileDetail = error instanceof Error && error.name === FILE_RESOLUTION_ERROR
|
|
279
|
+
? ` (${error.message})`
|
|
280
|
+
: "";
|
|
281
|
+
this.deps.log.error(`delivery: permanent failure ("${entry.kind}" to "${hashId(entry.chat.chatId)}")${fileDetail}`, error);
|
|
282
|
+
this.deps.log.debug(`delivery: permanent failure ("${entry.kind}" to "${entry.chat.chatId}")${fileDetail}`);
|
|
283
|
+
await this.markLedger(entry.id, "failed");
|
|
284
|
+
this.deps.counts?.failed();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
// Transient: the retry ladder. attempts was already incremented by
|
|
288
|
+
// the attempting mark; a send is allowed delays.length + 1 tries
|
|
289
|
+
// total (the initial attempt plus one per delay).
|
|
290
|
+
const retriesUsed = entry.attempts;
|
|
291
|
+
if (retriesUsed >= this.deps.delays.length) {
|
|
292
|
+
this.deps.log.error(`delivery: retries exhausted ("${entry.kind}" to "${hashId(entry.chat.chatId)}"): ${detail}`);
|
|
293
|
+
this.deps.log.debug(`delivery: retries exhausted ("${entry.kind}" to "${entry.chat.chatId}"): ${detail}`);
|
|
294
|
+
await this.markLedger(entry.id, "failed");
|
|
295
|
+
this.deps.counts?.failed();
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
const delay = this.deps.delays[retriesUsed] ?? 0;
|
|
299
|
+
this.deps.log.warn(`delivery: transient failure (attempt ${retriesUsed + 1}, retry in ${delay} ms): ${detail}`);
|
|
300
|
+
// The entry returns to pending (the retry re-pulls it); the timer
|
|
301
|
+
// re-kicks the loop. A stop cancels the timer — the entry stays
|
|
302
|
+
// pending for the next boot's recovery.
|
|
303
|
+
await this.markLedger(entry.id, "pending");
|
|
304
|
+
if (this.stopped)
|
|
305
|
+
return false;
|
|
306
|
+
this.cancelRetry?.();
|
|
307
|
+
this.cancelRetry = this.deps.setTimer(() => {
|
|
308
|
+
this.cancelRetry = undefined;
|
|
309
|
+
this.kick();
|
|
310
|
+
}, delay);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
/** A ledger mark with its own error isolation (never throws). */
|
|
314
|
+
async markLedger(id, state) {
|
|
315
|
+
try {
|
|
316
|
+
await this.ledger.mark(id, state);
|
|
317
|
+
}
|
|
318
|
+
catch (markError) {
|
|
319
|
+
this.deps.log.error("delivery: ledger mark failed", markError);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The classification read (adapter's `classifyError` when present,
|
|
324
|
+
* transient otherwise) with the timeout error forced transient.
|
|
325
|
+
*/
|
|
326
|
+
classify(error) {
|
|
327
|
+
if (error instanceof Error && (error.name === FILE_RESOLUTION_ERROR || error.name === EDIT_PRECONDITION_ERROR)) {
|
|
328
|
+
return "permanent";
|
|
329
|
+
}
|
|
330
|
+
if (error instanceof Error &&
|
|
331
|
+
error.name === "GatewaySendTimeout") {
|
|
332
|
+
// Review Focus 2: a send over the bound is TRANSIENT — never
|
|
333
|
+
// permanent, never chat_gone.
|
|
334
|
+
return "transient";
|
|
335
|
+
}
|
|
336
|
+
if (this.channel.classifyError !== undefined) {
|
|
337
|
+
return this.channel.classifyError(error);
|
|
338
|
+
}
|
|
339
|
+
return "transient";
|
|
340
|
+
}
|
|
341
|
+
/** The send-shaped message for one entry (the recovered marker rule). */
|
|
342
|
+
async buildMessage(entry) {
|
|
343
|
+
// An edit replaces its target; adding a resend prefix would corrupt
|
|
344
|
+
// the already-final outcome text (the marker belongs on fresh sends).
|
|
345
|
+
const text = entry.recovered === true && entry.kind !== "edit"
|
|
346
|
+
? `${RESENT_MARKER}\n${entry.message.text}`
|
|
347
|
+
: entry.message.text;
|
|
348
|
+
let files;
|
|
349
|
+
if (entry.message.files !== undefined && this.deps.resolveFiles !== undefined) {
|
|
350
|
+
try {
|
|
351
|
+
files = await this.deps.resolveFiles(entry);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
const paths = entry.message.files.map((file) => file.path).join(", ");
|
|
355
|
+
this.deps.log.warn(`delivery: file resolution failed for ${paths}`);
|
|
356
|
+
const failure = new Error(`file resolution failed for ${paths}`, {
|
|
357
|
+
cause: error,
|
|
358
|
+
});
|
|
359
|
+
failure.name = FILE_RESOLUTION_ERROR;
|
|
360
|
+
throw failure;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
chat: entry.message.chat,
|
|
365
|
+
text,
|
|
366
|
+
...(entry.message.replyTo === undefined
|
|
367
|
+
? {}
|
|
368
|
+
: { replyTo: entry.message.replyTo }),
|
|
369
|
+
...(entry.message.buttons === undefined
|
|
370
|
+
? {}
|
|
371
|
+
: { buttons: entry.message.buttons }),
|
|
372
|
+
...(files === undefined ? {} : { files }),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** A workspace file no longer exists/readable; retrying cannot repair it. */
|
|
377
|
+
const FILE_RESOLUTION_ERROR = "GatewayFileResolutionError";
|
|
378
|
+
/** A structurally undeliverable edit can never succeed on retry. */
|
|
379
|
+
const EDIT_PRECONDITION_ERROR = "GatewayEditPreconditionError";
|
|
380
|
+
/** The timeout error's name (matched by `classify`, never instanceof). */
|
|
381
|
+
export const SEND_TIMEOUT_NAME = "GatewaySendTimeout";
|
|
382
|
+
/** Build the timeout error. */
|
|
383
|
+
function timeoutError(ms) {
|
|
384
|
+
const error = new Error(`gateway: send exceeded ${ms} ms`);
|
|
385
|
+
error.name = SEND_TIMEOUT_NAME;
|
|
386
|
+
return error;
|
|
387
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown splitting and stripping (spec §8.6): the outbound chunker's
|
|
3
|
+
* step 1 (split at paragraph, then line, then hard boundaries, with
|
|
4
|
+
* fence close-and-reopen) and the plain-format renderer's stripper
|
|
5
|
+
* (headings, emphasis, links → `text (url)`, fences → plain blocks).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions over strings — no imports, no clock, no I/O.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Split `text` into chunks of at most `limit` characters, preferring
|
|
11
|
+
* paragraph boundaries, then line boundaries, then hard cuts.
|
|
12
|
+
*
|
|
13
|
+
* Fence rule: a code fence cut by a split is CLOSED in the earlier
|
|
14
|
+
* chunk and REOPENED with its original info string at the head of the
|
|
15
|
+
* next. An unterminated fence in the input is treated as running to
|
|
16
|
+
* the end (the last chunk's close terminates it — every emitted chunk
|
|
17
|
+
* is well-formed on its own).
|
|
18
|
+
*
|
|
19
|
+
* Every EMITTED chunk respects the limit: the close suffix a
|
|
20
|
+
* fence-interior chunk carries ("\n```") is reserved in each fit
|
|
21
|
+
* check, and hard-split pieces shrink by the ACTUAL reopen width
|
|
22
|
+
* (info string included — "typescript" reopens 13 chars wide, so a
|
|
23
|
+
* fixed reserve under-sizes pieces).
|
|
24
|
+
*
|
|
25
|
+
* The join of the chunks is a lossless representation of the input
|
|
26
|
+
* apart from the inserted fence markers and the whitespace the
|
|
27
|
+
* paragraph/line splits consumed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function splitMarkdown(text: string, limit: number): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Strip markdown to plain text (the `plain` capability's formatter):
|
|
32
|
+
* headings lose their `#` markers, emphasis markers are removed, links
|
|
33
|
+
* render as `text (url)`, and fenced code blocks become their inner
|
|
34
|
+
* lines. Structure (paragraphs, line breaks) is preserved.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripMarkdown(text: string): string;
|