@xmoxmo/bncr 0.2.3 → 0.2.5

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 (36) hide show
  1. package/README.md +67 -4
  2. package/index.ts +24 -1
  3. package/package.json +1 -1
  4. package/src/channel.ts +2823 -1178
  5. package/src/core/connection-capability.ts +70 -0
  6. package/src/core/connection-reachability.ts +141 -0
  7. package/src/core/diagnostics.ts +49 -0
  8. package/src/core/downlink-health.ts +56 -0
  9. package/src/core/extended-diagnostics.ts +65 -0
  10. package/src/core/lease-state.ts +94 -0
  11. package/src/core/outbox-enqueue.ts +22 -0
  12. package/src/core/outbox-entry-builders.ts +91 -0
  13. package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
  14. package/src/core/outbox-file-transfer-failure.ts +25 -0
  15. package/src/core/outbox-file-transfer-guards.ts +66 -0
  16. package/src/core/outbox-file-transfer-prep.ts +31 -0
  17. package/src/core/outbox-file-transfer-success.ts +34 -0
  18. package/src/core/outbox-push-args.ts +67 -0
  19. package/src/core/outbox-queue.ts +69 -0
  20. package/src/core/outbox-summary.ts +14 -0
  21. package/src/core/outbox-text-push-failure.ts +10 -0
  22. package/src/core/outbox-text-push-guards.ts +51 -0
  23. package/src/core/outbox-text-push-prep.ts +36 -0
  24. package/src/core/outbox-text-push-success.ts +62 -0
  25. package/src/core/register-trace.ts +110 -0
  26. package/src/core/status.ts +62 -10
  27. package/src/core/types.ts +3 -0
  28. package/src/messaging/inbound/dispatch.ts +86 -48
  29. package/src/messaging/outbound/diagnostics.ts +246 -0
  30. package/src/messaging/outbound/media-dedupe.ts +51 -0
  31. package/src/messaging/outbound/queue-selectors.ts +186 -0
  32. package/src/messaging/outbound/reasons.ts +48 -0
  33. package/src/messaging/outbound/reply-enqueue.ts +329 -0
  34. package/src/messaging/outbound/retry-policy.ts +133 -0
  35. package/src/messaging/outbound/send.ts +2 -0
  36. package/src/messaging/outbound/session-route.ts +34 -5
@@ -0,0 +1,329 @@
1
+ import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
+ import { buildReplyMediaFallbackDebugInfo } from './diagnostics.ts';
3
+ import { normalizeReplyToId } from './media-dedupe.ts';
4
+
5
+ export type ReplyPayloadInput = {
6
+ text?: string;
7
+ mediaUrl?: string;
8
+ mediaUrls?: string[];
9
+ asVoice?: boolean;
10
+ audioAsVoice?: boolean;
11
+ kind?: 'tool' | 'block' | 'final';
12
+ replyToId?: string;
13
+ };
14
+
15
+ export type NormalizedReplyPayload = {
16
+ text: string;
17
+ mediaUrl: string;
18
+ mediaUrls?: string[];
19
+ mediaList: string[];
20
+ asVoice: boolean;
21
+ audioAsVoice: boolean;
22
+ kind?: 'tool' | 'block' | 'final';
23
+ replyToId: string;
24
+ };
25
+
26
+ export type ReplyMediaEntriesParams = {
27
+ accountId: string;
28
+ sessionKey: string;
29
+ route: BncrRoute;
30
+ payload: NormalizedReplyPayload;
31
+ mediaLocalRoots?: readonly string[];
32
+ };
33
+
34
+ export type EnqueueNormalizedReplyPayloadParams = {
35
+ accountId: string;
36
+ sessionKey: string;
37
+ route: BncrRoute;
38
+ payload: NormalizedReplyPayload;
39
+ mediaLocalRoots?: readonly string[];
40
+ };
41
+
42
+ export type ReplyMediaFileTransferParams = {
43
+ accountId: string;
44
+ sessionKey: string;
45
+ route: BncrRoute;
46
+ mediaUrl: string;
47
+ mediaLocalRoots?: readonly string[];
48
+ text: string;
49
+ normalizedText: string;
50
+ asVoice: boolean;
51
+ audioAsVoice: boolean;
52
+ kind?: 'tool' | 'block' | 'final';
53
+ replyToId: string;
54
+ createdAt: number;
55
+ };
56
+
57
+ export type EnqueueSingleReplyMediaEntryParams = {
58
+ params: ReplyMediaEntriesParams;
59
+ mediaUrl: string;
60
+ normalizedText: string;
61
+ text: string;
62
+ fallback: { text: string; reason: string } | null;
63
+ currentTime: number;
64
+ };
65
+
66
+ export type ReplyMediaFallbackTextEntryParams = {
67
+ accountId: string;
68
+ sessionKey: string;
69
+ route: BncrRoute;
70
+ mediaUrl: string;
71
+ kind?: 'tool' | 'block' | 'final';
72
+ replyToId: string;
73
+ fallback: { text: string; reason: string };
74
+ };
75
+
76
+ export function hasReplyMediaEntries(payload: NormalizedReplyPayload) {
77
+ return payload.mediaList.length > 0;
78
+ }
79
+
80
+ export function buildReplyTextOutboxEntry(
81
+ params: {
82
+ accountId: string;
83
+ sessionKey: string;
84
+ route: BncrRoute;
85
+ text: string;
86
+ kind?: 'tool' | 'block' | 'final';
87
+ replyToId: string;
88
+ },
89
+ helpers: {
90
+ buildTextOutboxEntry: (args: {
91
+ accountId: string;
92
+ sessionKey: string;
93
+ route: BncrRoute;
94
+ text: string;
95
+ kind?: 'tool' | 'block' | 'final';
96
+ replyToId?: string;
97
+ }) => OutboxEntry;
98
+ },
99
+ ): OutboxEntry {
100
+ return helpers.buildTextOutboxEntry({
101
+ accountId: params.accountId,
102
+ sessionKey: params.sessionKey,
103
+ route: params.route,
104
+ text: params.text,
105
+ kind: params.kind,
106
+ replyToId: params.replyToId || undefined,
107
+ });
108
+ }
109
+
110
+ export function enqueueReplyTextEntry(
111
+ params: {
112
+ accountId: string;
113
+ sessionKey: string;
114
+ route: BncrRoute;
115
+ payload: NormalizedReplyPayload;
116
+ },
117
+ helpers: {
118
+ enqueueOutbound: (entry: OutboxEntry) => void;
119
+ buildTextOutboxEntry: (args: {
120
+ accountId: string;
121
+ sessionKey: string;
122
+ route: BncrRoute;
123
+ text: string;
124
+ kind?: 'tool' | 'block' | 'final';
125
+ replyToId?: string;
126
+ }) => OutboxEntry;
127
+ },
128
+ ): void {
129
+ if (!params.payload.text) return;
130
+
131
+ helpers.enqueueOutbound(
132
+ buildReplyTextOutboxEntry(
133
+ {
134
+ accountId: params.accountId,
135
+ sessionKey: params.sessionKey,
136
+ route: params.route,
137
+ text: params.payload.text,
138
+ kind: params.payload.kind,
139
+ replyToId: params.payload.replyToId,
140
+ },
141
+ { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
142
+ ),
143
+ );
144
+ }
145
+
146
+ export function enqueueReplyMediaFallbackTextEntry(
147
+ params: ReplyMediaFallbackTextEntryParams,
148
+ helpers: {
149
+ logInfo: (scope: string | undefined, message: string, options?: { debugOnly?: boolean }) => void;
150
+ enqueueOutbound: (entry: OutboxEntry) => void;
151
+ buildTextOutboxEntry: (args: {
152
+ accountId: string;
153
+ sessionKey: string;
154
+ route: BncrRoute;
155
+ text: string;
156
+ kind?: 'tool' | 'block' | 'final';
157
+ replyToId?: string;
158
+ }) => OutboxEntry;
159
+ },
160
+ ): void {
161
+ helpers.logInfo(
162
+ 'outbound',
163
+ `media-dedupe-hit ${JSON.stringify(buildReplyMediaFallbackDebugInfo(params))}`,
164
+ { debugOnly: true },
165
+ );
166
+ helpers.enqueueOutbound(
167
+ buildReplyTextOutboxEntry(
168
+ {
169
+ accountId: params.accountId,
170
+ sessionKey: params.sessionKey,
171
+ route: params.route,
172
+ text: params.fallback.text,
173
+ kind: params.kind,
174
+ replyToId: params.replyToId,
175
+ },
176
+ { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
177
+ ),
178
+ );
179
+ }
180
+
181
+ export function enqueueReplyMediaFileTransferEntry(
182
+ params: ReplyMediaFileTransferParams,
183
+ helpers: {
184
+ enqueueOutbound: (entry: OutboxEntry) => void;
185
+ buildFileTransferOutboxEntry: (args: {
186
+ accountId: string;
187
+ sessionKey: string;
188
+ route: BncrRoute;
189
+ mediaUrl: string;
190
+ mediaLocalRoots?: readonly string[];
191
+ text: string;
192
+ asVoice: boolean;
193
+ audioAsVoice: boolean;
194
+ kind?: 'tool' | 'block' | 'final';
195
+ replyToId?: string;
196
+ }) => OutboxEntry;
197
+ rememberRecentMediaSend: (args: {
198
+ sessionKey: string;
199
+ mediaUrl: string;
200
+ text: string;
201
+ replyToId: string;
202
+ createdAt: number;
203
+ }) => void;
204
+ },
205
+ ): void {
206
+ helpers.enqueueOutbound(
207
+ helpers.buildFileTransferOutboxEntry({
208
+ accountId: params.accountId,
209
+ sessionKey: params.sessionKey,
210
+ route: params.route,
211
+ mediaUrl: params.mediaUrl,
212
+ mediaLocalRoots: params.mediaLocalRoots,
213
+ text: params.text,
214
+ asVoice: params.asVoice,
215
+ audioAsVoice: params.audioAsVoice,
216
+ kind: params.kind,
217
+ replyToId: params.replyToId || undefined,
218
+ }),
219
+ );
220
+ helpers.rememberRecentMediaSend({
221
+ sessionKey: params.sessionKey,
222
+ mediaUrl: params.mediaUrl,
223
+ text: params.normalizedText,
224
+ replyToId: params.replyToId,
225
+ createdAt: params.createdAt,
226
+ });
227
+ }
228
+
229
+ export function enqueueSingleReplyMediaEntry(
230
+ params: EnqueueSingleReplyMediaEntryParams,
231
+ helpers: {
232
+ enqueueReplyMediaFallbackTextEntry: (params: ReplyMediaFallbackTextEntryParams) => void;
233
+ enqueueReplyMediaFileTransferEntry: (params: ReplyMediaFileTransferParams) => void;
234
+ },
235
+ ): void {
236
+ if (params.fallback !== null) {
237
+ helpers.enqueueReplyMediaFallbackTextEntry({
238
+ accountId: params.params.accountId,
239
+ sessionKey: params.params.sessionKey,
240
+ route: params.params.route,
241
+ mediaUrl: params.mediaUrl,
242
+ kind: params.params.payload.kind,
243
+ replyToId: params.params.payload.replyToId,
244
+ fallback: params.fallback,
245
+ });
246
+ return;
247
+ }
248
+
249
+ helpers.enqueueReplyMediaFileTransferEntry({
250
+ accountId: params.params.accountId,
251
+ sessionKey: params.params.sessionKey,
252
+ route: params.params.route,
253
+ mediaUrl: params.mediaUrl,
254
+ mediaLocalRoots: params.params.mediaLocalRoots,
255
+ text: params.text,
256
+ normalizedText: params.normalizedText,
257
+ asVoice: params.params.payload.asVoice,
258
+ audioAsVoice: params.params.payload.audioAsVoice,
259
+ kind: params.params.payload.kind,
260
+ replyToId: params.params.payload.replyToId,
261
+ createdAt: params.currentTime,
262
+ });
263
+ }
264
+
265
+ export function enqueueNormalizedReplyPayload(
266
+ params: EnqueueNormalizedReplyPayloadParams,
267
+ helpers: {
268
+ logEnqueueFromReply: (args: {
269
+ accountId: string;
270
+ sessionKey: string;
271
+ route: BncrRoute;
272
+ payload: NormalizedReplyPayload;
273
+ }) => void;
274
+ hasReplyMediaEntries: (payload: NormalizedReplyPayload) => boolean;
275
+ enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
276
+ enqueueReplyTextEntry: (params: {
277
+ accountId: string;
278
+ sessionKey: string;
279
+ route: BncrRoute;
280
+ payload: NormalizedReplyPayload;
281
+ }) => void;
282
+ },
283
+ ): void {
284
+ helpers.logEnqueueFromReply({
285
+ accountId: params.accountId,
286
+ sessionKey: params.sessionKey,
287
+ route: params.route,
288
+ payload: params.payload,
289
+ });
290
+
291
+ if (helpers.hasReplyMediaEntries(params.payload)) {
292
+ helpers.enqueueReplyMediaEntries({
293
+ accountId: params.accountId,
294
+ sessionKey: params.sessionKey,
295
+ route: params.route,
296
+ payload: params.payload,
297
+ mediaLocalRoots: params.mediaLocalRoots,
298
+ });
299
+ return;
300
+ }
301
+
302
+ helpers.enqueueReplyTextEntry({
303
+ accountId: params.accountId,
304
+ sessionKey: params.sessionKey,
305
+ route: params.route,
306
+ payload: params.payload,
307
+ });
308
+ }
309
+
310
+ export function normalizeReplyPayload(
311
+ payload: ReplyPayloadInput,
312
+ helpers: { asString: (value: unknown, fallback?: string) => string },
313
+ ): NormalizedReplyPayload {
314
+ const text = helpers.asString(payload?.text || '').trim();
315
+ const mediaUrl = helpers.asString(payload?.mediaUrl || '').trim();
316
+ const mediaUrls = Array.isArray(payload?.mediaUrls)
317
+ ? payload.mediaUrls.map((v) => helpers.asString(v || '').trim()).filter(Boolean)
318
+ : undefined;
319
+ return {
320
+ text,
321
+ mediaUrl,
322
+ mediaUrls,
323
+ mediaList: mediaUrls?.length ? mediaUrls : mediaUrl ? [mediaUrl] : [],
324
+ asVoice: payload?.asVoice === true,
325
+ audioAsVoice: payload?.audioAsVoice === true,
326
+ kind: payload?.kind,
327
+ replyToId: normalizeReplyToId(payload?.replyToId),
328
+ };
329
+ }
@@ -0,0 +1,133 @@
1
+ import type { BncrRoute } from '../../channel.ts';
2
+
3
+ export type RetryRerouteDecisionInput = {
4
+ nowMs: number;
5
+ maxRetry: number;
6
+ requireAck: boolean;
7
+ currentRetryCount: number;
8
+ currentRouteAttemptRound: number;
9
+ currentFastReroutePending: boolean;
10
+ lastError?: string;
11
+ currentConnId?: string;
12
+ attemptedConnIds: string[];
13
+ availableConnIds: string[];
14
+ };
15
+
16
+ export type RetryRerouteDecision =
17
+ | {
18
+ kind: 'dead-letter';
19
+ terminalReason: string;
20
+ nextRetryCount: number;
21
+ lastAttemptAt: number;
22
+ }
23
+ | {
24
+ kind: 'retry';
25
+ nextRetryCount: number;
26
+ lastAttemptAt: number;
27
+ nextAttemptAt: number;
28
+ lastError: string;
29
+ attemptedConnIds: string[];
30
+ fastReroutePending: boolean;
31
+ routeAttemptRound: number;
32
+ hasUntriedAlternative: boolean;
33
+ shouldFastReroute: boolean;
34
+ revalidatedConnIds: string[];
35
+ };
36
+
37
+ export function computeRetryRerouteDecision(
38
+ input: RetryRerouteDecisionInput,
39
+ deps: { backoffMs: (retryCount: number) => number },
40
+ ): RetryRerouteDecision {
41
+ const attemptedConnIds = Array.isArray(input.attemptedConnIds)
42
+ ? input.attemptedConnIds.filter((v): v is string => typeof v === 'string' && !!v)
43
+ : [];
44
+ const currentConnId = `${input.currentConnId || ''}`.trim();
45
+ if (currentConnId && !attemptedConnIds.includes(currentConnId)) attemptedConnIds.push(currentConnId);
46
+
47
+ const availableConnIds = Array.isArray(input.availableConnIds)
48
+ ? input.availableConnIds.filter((v): v is string => typeof v === 'string' && !!v)
49
+ : [];
50
+ const revalidatedConnIds = attemptedConnIds.filter((connId) => availableConnIds.includes(connId));
51
+ const hasUntriedAlternative = availableConnIds.some((connId) => !attemptedConnIds.includes(connId));
52
+ const shouldFastReroute = input.requireAck && input.currentFastReroutePending !== true && hasUntriedAlternative;
53
+
54
+ const nextRetryCount = Number(input.currentRetryCount || 0) + 1;
55
+ const lastAttemptAt = input.nowMs;
56
+ const terminalReason =
57
+ input.lastError || (input.requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed');
58
+
59
+ if (nextRetryCount > input.maxRetry) {
60
+ return {
61
+ kind: 'dead-letter',
62
+ terminalReason,
63
+ nextRetryCount,
64
+ lastAttemptAt,
65
+ };
66
+ }
67
+
68
+ const nextAttemptAt = shouldFastReroute ? input.nowMs + 1_000 : input.nowMs + deps.backoffMs(nextRetryCount);
69
+ const lastError = input.requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
70
+ const routeAttemptRound = hasUntriedAlternative ? Number(input.currentRouteAttemptRound || 0) : Number(input.currentRouteAttemptRound || 0) + 1;
71
+ const fastReroutePending = hasUntriedAlternative ? shouldFastReroute || input.currentFastReroutePending === true : false;
72
+
73
+ return {
74
+ kind: 'retry',
75
+ nextRetryCount,
76
+ lastAttemptAt,
77
+ nextAttemptAt,
78
+ lastError,
79
+ attemptedConnIds: hasUntriedAlternative ? attemptedConnIds : [],
80
+ fastReroutePending,
81
+ routeAttemptRound,
82
+ hasUntriedAlternative,
83
+ shouldFastReroute,
84
+ revalidatedConnIds,
85
+ };
86
+ }
87
+
88
+ export type PushFailureDecisionInput = {
89
+ nowMs: number;
90
+ maxRetry: number;
91
+ currentRetryCount: number;
92
+ lastError?: string;
93
+ };
94
+
95
+ export type PushFailureDecision =
96
+ | {
97
+ kind: 'dead-letter';
98
+ terminalReason: string;
99
+ nextRetryCount: number;
100
+ lastAttemptAt: number;
101
+ }
102
+ | {
103
+ kind: 'retry';
104
+ nextRetryCount: number;
105
+ lastAttemptAt: number;
106
+ nextAttemptAt: number;
107
+ lastError: string;
108
+ };
109
+
110
+ export function computePushFailureDecision(
111
+ input: PushFailureDecisionInput,
112
+ deps: { backoffMs: (retryCount: number) => number },
113
+ ): PushFailureDecision {
114
+ const nextRetryCount = Number(input.currentRetryCount || 0) + 1;
115
+ const lastAttemptAt = input.nowMs;
116
+
117
+ if (nextRetryCount > input.maxRetry) {
118
+ return {
119
+ kind: 'dead-letter',
120
+ terminalReason: input.lastError || 'push-retry-limit',
121
+ nextRetryCount,
122
+ lastAttemptAt,
123
+ };
124
+ }
125
+
126
+ return {
127
+ kind: 'retry',
128
+ nextRetryCount,
129
+ lastAttemptAt,
130
+ nextAttemptAt: input.nowMs + deps.backoffMs(nextRetryCount),
131
+ lastError: input.lastError || 'push-retry',
132
+ };
133
+ }
@@ -51,6 +51,7 @@ export async function sendBncrMedia(params: {
51
51
  to: string;
52
52
  text?: string;
53
53
  mediaUrl?: string;
54
+ mediaUrls?: string[];
54
55
  asVoice?: boolean;
55
56
  audioAsVoice?: boolean;
56
57
  replyToId?: string;
@@ -86,6 +87,7 @@ export async function sendBncrMedia(params: {
86
87
  payload: {
87
88
  text: params.text || '',
88
89
  mediaUrl: params.mediaUrl || '',
90
+ mediaUrls: params.mediaUrls?.length ? params.mediaUrls : undefined,
89
91
  asVoice: params.asVoice === true ? true : undefined,
90
92
  audioAsVoice: params.audioAsVoice === true ? true : undefined,
91
93
  replyToId: params.replyToId,
@@ -26,6 +26,28 @@ function asString(v: unknown, fallback = ''): string {
26
26
  return String(v);
27
27
  }
28
28
 
29
+ function attachBncrChannelRouteRefFields(args: {
30
+ built: Record<string, unknown>;
31
+ channel: string;
32
+ accountId?: string;
33
+ to: string;
34
+ chatType: 'direct' | 'group';
35
+ threadId?: string;
36
+ }) {
37
+ const { built, channel, accountId, to, chatType, threadId } = args;
38
+ return {
39
+ ...built,
40
+ channel,
41
+ ...(accountId !== undefined ? { accountId } : {}),
42
+ target: {
43
+ to,
44
+ rawTo: to,
45
+ chatType,
46
+ },
47
+ ...(threadId !== undefined ? { thread: { id: threadId } } : {}),
48
+ };
49
+ }
50
+
29
51
  export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessionRouteParams) {
30
52
  const raw = asString(params.resolvedTarget?.to || params.target).trim();
31
53
  if (!raw) return null;
@@ -65,9 +87,16 @@ export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessi
65
87
  ...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
66
88
  });
67
89
 
68
- return {
69
- ...built,
70
- sessionKey,
71
- baseSessionKey: sessionKey,
72
- };
90
+ return attachBncrChannelRouteRefFields({
91
+ built: {
92
+ ...built,
93
+ sessionKey,
94
+ baseSessionKey: sessionKey,
95
+ },
96
+ channel: params.channel,
97
+ accountId: params.accountId,
98
+ to: displayTo,
99
+ chatType: 'direct',
100
+ threadId: params.threadId,
101
+ });
73
102
  }