@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,70 @@
1
+ import type { BncrConnection } from './types.ts';
2
+
3
+ type CapabilityConnection = BncrConnection & {
4
+ outboundReadyUntil?: number;
5
+ preferredForOutboundUntil?: number;
6
+ inboundOnly?: boolean;
7
+ lastAckOkAt?: number;
8
+ lastPushTimeoutAt?: number;
9
+ };
10
+
11
+ export function applyOutboundCapability(args: {
12
+ connection: CapabilityConnection;
13
+ at: number;
14
+ outboundReadyTtlMs: number;
15
+ preferredOutboundTtlMs: number;
16
+ outboundReady?: boolean;
17
+ preferredForOutbound?: boolean;
18
+ inboundOnly?: boolean;
19
+ }) {
20
+ const next: CapabilityConnection = { ...args.connection };
21
+
22
+ if (args.inboundOnly === true) {
23
+ next.inboundOnly = true;
24
+ next.outboundReadyUntil = undefined;
25
+ next.preferredForOutboundUntil = undefined;
26
+ } else {
27
+ if (typeof args.inboundOnly === 'boolean') next.inboundOnly = false;
28
+ if (args.outboundReady === true || args.preferredForOutbound === true) {
29
+ next.outboundReadyUntil = args.at + args.outboundReadyTtlMs;
30
+ }
31
+ if (args.preferredForOutbound === true) {
32
+ next.preferredForOutboundUntil = args.at + args.preferredOutboundTtlMs;
33
+ }
34
+ }
35
+
36
+ return next;
37
+ }
38
+
39
+ export function findCapabilityConnection(args: {
40
+ accountId: string;
41
+ connId?: string;
42
+ clientId?: string;
43
+ connections: Iterable<[string, BncrConnection]>;
44
+ }) {
45
+ for (const [key, conn] of args.connections) {
46
+ if (conn.accountId !== args.accountId) continue;
47
+ if (args.connId && conn.connId !== args.connId) continue;
48
+ if (args.clientId && conn.clientId !== args.clientId) continue;
49
+ return {
50
+ key,
51
+ connection: conn as CapabilityConnection,
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function buildCapabilitySnapshot(connection: CapabilityConnection) {
58
+ return {
59
+ outboundReadyUntil: connection.outboundReadyUntil || null,
60
+ preferredForOutboundUntil: connection.preferredForOutboundUntil || null,
61
+ inboundOnly: connection.inboundOnly === true,
62
+ };
63
+ }
64
+
65
+ export function clearOutboundCapability(connection: CapabilityConnection) {
66
+ const next: CapabilityConnection = { ...connection };
67
+ next.outboundReadyUntil = undefined;
68
+ next.preferredForOutboundUntil = undefined;
69
+ return next;
70
+ }
@@ -0,0 +1,141 @@
1
+ import type { BncrConnection, OutboxEntry } from './types.ts';
2
+
3
+ export function hasRecentInboundReachability(args: {
4
+ now: number;
5
+ windowMs: number;
6
+ lastInboundAt: number;
7
+ lastActivityAt: number;
8
+ }) {
9
+ const lastReachableAt = Math.max(args.lastInboundAt, args.lastActivityAt);
10
+ return lastReachableAt > 0 && args.now - lastReachableAt <= args.windowMs;
11
+ }
12
+
13
+ export function resolveRecentInboundConnIds(args: {
14
+ accountId: string;
15
+ now: number;
16
+ connectTtlMs: number;
17
+ recentInboundReachable: boolean;
18
+ connections: Iterable<BncrConnection>;
19
+ }) {
20
+ const connIds = new Set<string>();
21
+ if (!args.recentInboundReachable) return connIds;
22
+
23
+ for (const c of args.connections) {
24
+ if (c.accountId !== args.accountId) continue;
25
+ if (!c.connId) continue;
26
+ if (args.now - c.lastSeenAt > args.connectTtlMs * 2) continue;
27
+ connIds.add(c.connId);
28
+ }
29
+
30
+ return connIds;
31
+ }
32
+
33
+ export function isRecentlyReachableConn(args: {
34
+ accountId: string;
35
+ connId?: string;
36
+ clientId?: string;
37
+ recentConnIds: Set<string>;
38
+ activeConnection?: BncrConnection | null;
39
+ }) {
40
+ const cid = String(args.connId || '').trim();
41
+ const client = String(args.clientId || '').trim() || undefined;
42
+ if (!cid) return false;
43
+ if (args.recentConnIds.has(cid)) return true;
44
+
45
+ const active = args.activeConnection;
46
+ if (!active?.connId) return false;
47
+ if (active.accountId !== args.accountId) return false;
48
+ if (active.connId !== cid) return false;
49
+ if (client && active.clientId && active.clientId !== client) return false;
50
+ return true;
51
+ }
52
+
53
+ export function hasAlternativeLiveConnection(args: {
54
+ accountId: string;
55
+ now: number;
56
+ connectTtlMs: number;
57
+ currentConnId?: string;
58
+ currentClientId?: string;
59
+ connections: Iterable<BncrConnection>;
60
+ }) {
61
+ const currentConn = String(args.currentConnId || '').trim();
62
+ const currentClient = String(args.currentClientId || '').trim() || undefined;
63
+
64
+ for (const conn of args.connections) {
65
+ if (conn.accountId !== args.accountId) continue;
66
+ if (!conn.connId) continue;
67
+ if (args.now - conn.lastSeenAt > args.connectTtlMs) continue;
68
+ const sameConn = !!currentConn && conn.connId === currentConn;
69
+ const sameClient = !currentConn && !!currentClient && conn.clientId === currentClient;
70
+ if (sameConn || sameClient) continue;
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ type ReachableConnection = BncrConnection & {
77
+ inboundOnly?: boolean;
78
+ preferredForOutboundUntil?: number;
79
+ outboundReadyUntil?: number;
80
+ lastAckOkAt?: number;
81
+ lastPushTimeoutAt?: number;
82
+ };
83
+
84
+ export function getRevalidatedAttemptReason(args: {
85
+ entry: OutboxEntry;
86
+ connId: string;
87
+ accountId: string;
88
+ now: number;
89
+ connectTtlMs: number;
90
+ recentInboundReachable: boolean;
91
+ connections: Iterable<BncrConnection>;
92
+ }) {
93
+ const targetConnId = String(args.connId || '').trim();
94
+ if (!targetConnId) return null;
95
+
96
+ const lastAttemptAt = Number(args.entry.lastAttemptAt || 0);
97
+ for (const rawConn of args.connections) {
98
+ const conn = rawConn as ReachableConnection;
99
+ if (conn.accountId !== args.accountId) continue;
100
+ if (conn.connId !== targetConnId) continue;
101
+ if (args.now - conn.lastSeenAt > args.connectTtlMs) continue;
102
+ if (conn.inboundOnly === true) continue;
103
+
104
+ const preferredForOutboundUntil = Number(conn.preferredForOutboundUntil || 0);
105
+ const outboundReadyUntil = Number(conn.outboundReadyUntil || 0);
106
+ const lastAckOkAt = Number(conn.lastAckOkAt || 0);
107
+ const lastPushTimeoutAt = Number(conn.lastPushTimeoutAt || 0);
108
+
109
+ const revalidatedByPreferred = preferredForOutboundUntil > args.now;
110
+ const revalidatedByReady = outboundReadyUntil > args.now;
111
+ const revalidatedByAck = lastAckOkAt > 0 && lastAckOkAt > lastAttemptAt;
112
+ const revalidatedByFreshReachability =
113
+ args.recentInboundReachable &&
114
+ lastPushTimeoutAt > 0 &&
115
+ lastPushTimeoutAt <= lastAttemptAt &&
116
+ conn.lastSeenAt > lastPushTimeoutAt;
117
+
118
+ if (!revalidatedByPreferred && !revalidatedByReady && !revalidatedByAck && !revalidatedByFreshReachability) {
119
+ return null;
120
+ }
121
+
122
+ return {
123
+ reason: revalidatedByAck
124
+ ? 'ack-after-last-attempt'
125
+ : revalidatedByPreferred
126
+ ? 'preferred-ttl'
127
+ : revalidatedByReady
128
+ ? 'ready-ttl'
129
+ : 'fresh-reachability',
130
+ lastAttemptAt,
131
+ lastAckOkAt: lastAckOkAt || null,
132
+ lastPushTimeoutAt: lastPushTimeoutAt || null,
133
+ outboundReadyUntil: outboundReadyUntil || null,
134
+ preferredForOutboundUntil: preferredForOutboundUntil || null,
135
+ lastSeenAt: conn.lastSeenAt,
136
+ recentInboundReachable: args.recentInboundReachable,
137
+ };
138
+ }
139
+
140
+ return null;
141
+ }
@@ -0,0 +1,49 @@
1
+ import { buildBncrPermissionSummary } from './permissions.ts';
2
+ import { probeBncrAccount } from './probe.ts';
3
+
4
+ type DiagnosticsPayloadArgs = {
5
+ cfg: any;
6
+ channelId: string;
7
+ accountId: string;
8
+ runtime: any;
9
+ diagnostics: any;
10
+ downlinkHealth: any;
11
+ runtimeFlags: any;
12
+ waiters: { messageAck: number; fileAck: number };
13
+ activeConnections: number;
14
+ invalidOutboxSessionKeys: number;
15
+ legacyAccountResidue: number;
16
+ now: number;
17
+ };
18
+
19
+ export function buildDiagnosticsPayload(args: DiagnosticsPayloadArgs) {
20
+ const permissions = buildBncrPermissionSummary(args.cfg ?? {});
21
+ const probe = probeBncrAccount({
22
+ accountId: args.accountId,
23
+ connected: Boolean(args.runtime?.connected),
24
+ pending: Number(args.runtime?.meta?.pending ?? 0),
25
+ deadLetter: Number(args.runtime?.meta?.deadLetter ?? 0),
26
+ activeConnections: args.activeConnections,
27
+ invalidOutboxSessionKeys: args.invalidOutboxSessionKeys,
28
+ legacyAccountResidue: args.legacyAccountResidue,
29
+ lastActivityAt: args.runtime?.meta?.lastActivityAt ?? null,
30
+ structure: {
31
+ coreComplete: true,
32
+ inboundComplete: true,
33
+ outboundComplete: true,
34
+ },
35
+ });
36
+
37
+ return {
38
+ channel: args.channelId,
39
+ accountId: args.accountId,
40
+ runtime: args.runtime,
41
+ diagnostics: args.diagnostics,
42
+ downlinkHealth: args.downlinkHealth,
43
+ runtimeFlags: args.runtimeFlags,
44
+ waiters: args.waiters,
45
+ permissions,
46
+ probe,
47
+ now: args.now,
48
+ };
49
+ }
@@ -0,0 +1,56 @@
1
+ import type { OutboxEntry } from './types.ts';
2
+
3
+ type DownlinkHealthInput = {
4
+ accountId: string;
5
+ now: number;
6
+ outboxEntries: Iterable<OutboxEntry>;
7
+ lastAckOkAt: number | null;
8
+ lastAckTimeoutAt: number | null;
9
+ recentAckTimeoutCount: number;
10
+ activeConnectionCount: number;
11
+ lastInboundAt: number | null;
12
+ lastActivityAt: number | null;
13
+ onlineByConn: boolean;
14
+ };
15
+
16
+ export function buildDownlinkHealth(input: DownlinkHealthInput) {
17
+ const pending = Array.from(input.outboxEntries).filter((v) => v.accountId === input.accountId);
18
+ const pendingCount = pending.length;
19
+ const oldestPendingCreatedAt = pending.length
20
+ ? Math.min(...pending.map((entry) => Number(entry.createdAt || input.now)))
21
+ : null;
22
+ const oldestPendingAgeMs = oldestPendingCreatedAt
23
+ ? Math.max(0, input.now - oldestPendingCreatedAt)
24
+ : 0;
25
+ const lastSignalAt =
26
+ Math.max(Number(input.lastInboundAt || 0), Number(input.lastActivityAt || 0)) || null;
27
+ const inboundHealthy = !!lastSignalAt && input.now - lastSignalAt <= 5 * 60 * 1000;
28
+ const ackRecentlyHealthy =
29
+ !!input.lastAckOkAt && input.now - input.lastAckOkAt <= 5 * 60 * 1000;
30
+ const ackTimeoutRecent =
31
+ !!input.lastAckTimeoutAt && input.now - input.lastAckTimeoutAt <= 5 * 60 * 1000;
32
+ const ackStalled =
33
+ pendingCount > 0 &&
34
+ input.activeConnectionCount === 1 &&
35
+ inboundHealthy &&
36
+ ackTimeoutRecent &&
37
+ input.recentAckTimeoutCount > 0 &&
38
+ !ackRecentlyHealthy;
39
+
40
+ return {
41
+ pendingOutbox: pendingCount,
42
+ oldestPendingCreatedAt,
43
+ oldestPendingAgeMs,
44
+ lastAckOkAt: input.lastAckOkAt,
45
+ lastAckTimeoutAt: input.lastAckTimeoutAt,
46
+ recentAckTimeoutCount: input.recentAckTimeoutCount,
47
+ activeConnectionCount: input.activeConnectionCount,
48
+ recentInboundReachable: inboundHealthy,
49
+ onlineByConn: input.onlineByConn,
50
+ ackStalled,
51
+ recommendReconnect: ackStalled,
52
+ recommendReason: ackStalled
53
+ ? 'single-conn pending outbox with recent ack timeout and no recent ack-ok while inbound/activity is still alive'
54
+ : '',
55
+ };
56
+ }
@@ -0,0 +1,65 @@
1
+ import type { RegisterTraceEntry } from './register-trace.ts';
2
+
3
+ type ExtendedDiagnosticsInput = {
4
+ diagnostics: Record<string, any>;
5
+ register: {
6
+ bridgeId: string;
7
+ gatewayPid: number;
8
+ pluginVersion: string | null;
9
+ source: string | null;
10
+ apiInstanceId: string | null;
11
+ registryFingerprint: string | null;
12
+ registerCount: number;
13
+ firstRegisterAt: number | null;
14
+ lastRegisterAt: number | null;
15
+ lastApiRebindAt: number | null;
16
+ apiGeneration: number;
17
+ traceRecent: RegisterTraceEntry[];
18
+ traceSummary: Record<string, any>;
19
+ lastDriftSnapshot: any;
20
+ };
21
+ connection: {
22
+ active: number;
23
+ primaryLeaseId: string | null;
24
+ primaryEpoch: number | null;
25
+ acceptedConnections: number;
26
+ lastConnectAt: number | null;
27
+ lastDisconnectAt: number | null;
28
+ lastActivityAt: number | null;
29
+ lastInboundAt: number | null;
30
+ lastAckAt: number | null;
31
+ recent: Array<{
32
+ leaseId: string;
33
+ epoch: number;
34
+ connectedAt: number;
35
+ lastActivityAt: number | null;
36
+ isPrimary: boolean;
37
+ }>;
38
+ };
39
+ protocol: {
40
+ bridgeVersion: number;
41
+ protocolVersion: number;
42
+ minClientProtocol: number;
43
+ features: Record<string, boolean>;
44
+ };
45
+ stale: Record<string, any>;
46
+ };
47
+
48
+ export function buildExtendedDiagnostics(input: ExtendedDiagnosticsInput) {
49
+ return {
50
+ ...input.diagnostics,
51
+ register: {
52
+ ...input.register,
53
+ traceRecent: input.register.traceRecent.slice(),
54
+ },
55
+ connection: {
56
+ ...input.connection,
57
+ recent: input.connection.recent.map((entry) => ({ ...entry })),
58
+ },
59
+ protocol: {
60
+ ...input.protocol,
61
+ features: { ...input.protocol.features },
62
+ },
63
+ stale: { ...input.stale },
64
+ };
65
+ }
@@ -0,0 +1,94 @@
1
+ export type LeaseEventKind =
2
+ | 'connect'
3
+ | 'inbound'
4
+ | 'activity'
5
+ | 'ack'
6
+ | 'file.init'
7
+ | 'file.chunk'
8
+ | 'file.complete'
9
+ | 'file.abort';
10
+
11
+ export type LeaseObservationReason = 'missing' | 'ok' | 'mismatch';
12
+
13
+ export type LeaseObservationResult = {
14
+ stale: boolean;
15
+ reason: LeaseObservationReason;
16
+ };
17
+
18
+ export type StaleCounters = {
19
+ staleConnect: number;
20
+ staleInbound: number;
21
+ staleActivity: number;
22
+ staleAck: number;
23
+ staleFileInit: number;
24
+ staleFileChunk: number;
25
+ staleFileComplete: number;
26
+ staleFileAbort: number;
27
+ lastStaleAt: number | null;
28
+ };
29
+
30
+ export function observeLeaseState(args: {
31
+ kind: LeaseEventKind;
32
+ params: { leaseId?: string; connectionEpoch?: number };
33
+ currentLeaseId: string | null;
34
+ currentConnectionEpoch: number;
35
+ now: number;
36
+ staleCounters: StaleCounters;
37
+ }): LeaseObservationResult {
38
+ const leaseId = typeof args.params.leaseId === 'string' ? args.params.leaseId.trim() : '';
39
+ const connectionEpoch =
40
+ typeof args.params.connectionEpoch === 'number' ? args.params.connectionEpoch : undefined;
41
+ if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' };
42
+
43
+ const staleByLease = !!leaseId && args.currentLeaseId != null && leaseId !== args.currentLeaseId;
44
+ const staleByEpoch =
45
+ connectionEpoch != null &&
46
+ args.currentConnectionEpoch > 0 &&
47
+ connectionEpoch !== args.currentConnectionEpoch;
48
+ const stale = staleByLease || staleByEpoch;
49
+ if (!stale) return { stale: false, reason: 'ok' };
50
+
51
+ args.staleCounters.lastStaleAt = args.now;
52
+ switch (args.kind) {
53
+ case 'connect':
54
+ args.staleCounters.staleConnect += 1;
55
+ break;
56
+ case 'inbound':
57
+ args.staleCounters.staleInbound += 1;
58
+ break;
59
+ case 'activity':
60
+ args.staleCounters.staleActivity += 1;
61
+ break;
62
+ case 'ack':
63
+ args.staleCounters.staleAck += 1;
64
+ break;
65
+ case 'file.init':
66
+ args.staleCounters.staleFileInit += 1;
67
+ break;
68
+ case 'file.chunk':
69
+ args.staleCounters.staleFileChunk += 1;
70
+ break;
71
+ case 'file.complete':
72
+ args.staleCounters.staleFileComplete += 1;
73
+ break;
74
+ case 'file.abort':
75
+ args.staleCounters.staleFileAbort += 1;
76
+ break;
77
+ }
78
+ return { stale: true, reason: 'mismatch' };
79
+ }
80
+
81
+ export function matchesTransferOwner(args: {
82
+ ownerConnId?: string;
83
+ ownerClientId?: string;
84
+ connId: string;
85
+ clientId?: string;
86
+ }) {
87
+ const sameConn = !!args.ownerConnId && args.ownerConnId === args.connId;
88
+ const sameClient =
89
+ !args.ownerConnId &&
90
+ !!args.ownerClientId &&
91
+ !!args.clientId &&
92
+ args.ownerClientId === args.clientId;
93
+ return sameConn || sameClient;
94
+ }
@@ -0,0 +1,22 @@
1
+ import type { OutboxEntry } from './types.ts';
2
+
3
+ export function buildOutboxEnqueueDebugInfo(args: {
4
+ bridgeId: string;
5
+ entry: OutboxEntry;
6
+ asString: (value: unknown) => string;
7
+ formatDisplayScope: (route: OutboxEntry['route']) => string;
8
+ }) {
9
+ const msg = (args.entry.payload as any)?.message || {};
10
+ const type = args.asString(msg.type || (args.entry.payload as any)?.type || 'unknown');
11
+ const text = args.asString(msg.msg || '');
12
+ return {
13
+ bridge: args.bridgeId,
14
+ messageId: args.entry.messageId,
15
+ accountId: args.entry.accountId,
16
+ sessionKey: args.entry.sessionKey,
17
+ scope: args.formatDisplayScope(args.entry.route),
18
+ type,
19
+ textLen: text.length,
20
+ textPreview: text.slice(0, 120),
21
+ };
22
+ }
@@ -0,0 +1,91 @@
1
+ import type { BncrRoute, OutboxEntry } from './types.ts';
2
+
3
+ export function buildFileTransferOutboxEntry(args: {
4
+ createMessageId: () => string;
5
+ now: () => number;
6
+ normalizeAccountId: (accountId?: string | null) => string;
7
+ pushEvent: string;
8
+ accountId: string;
9
+ sessionKey: string;
10
+ route: BncrRoute;
11
+ mediaUrl: string;
12
+ mediaLocalRoots?: readonly string[];
13
+ text: string;
14
+ asVoice?: boolean;
15
+ audioAsVoice?: boolean;
16
+ kind?: 'tool' | 'block' | 'final';
17
+ replyToId?: string;
18
+ }): OutboxEntry {
19
+ const messageId = args.createMessageId();
20
+ const createdAt = args.now();
21
+ return {
22
+ messageId,
23
+ accountId: args.normalizeAccountId(args.accountId),
24
+ sessionKey: args.sessionKey,
25
+ route: args.route,
26
+ payload: {
27
+ type: 'message.outbound',
28
+ sessionKey: args.sessionKey,
29
+ _meta: {
30
+ kind: 'file-transfer',
31
+ mediaUrl: args.mediaUrl,
32
+ mediaLocalRoots: args.mediaLocalRoots ? Array.from(args.mediaLocalRoots) : undefined,
33
+ text: args.text,
34
+ asVoice: args.asVoice === true,
35
+ audioAsVoice: args.audioAsVoice === true,
36
+ finalEvent: args.pushEvent,
37
+ replyToId: args.replyToId,
38
+ messageKind: args.kind,
39
+ },
40
+ },
41
+ createdAt,
42
+ retryCount: 0,
43
+ nextAttemptAt: createdAt,
44
+ };
45
+ }
46
+
47
+ export function buildTextOutboxEntry(args: {
48
+ createMessageId: () => string;
49
+ now: () => number;
50
+ normalizeAccountId: (accountId?: string | null) => string;
51
+ normalizeReplyToId: (value?: string | null) => string;
52
+ accountId: string;
53
+ sessionKey: string;
54
+ route: BncrRoute;
55
+ text: string;
56
+ kind?: 'tool' | 'block' | 'final';
57
+ replyToId?: string;
58
+ }): OutboxEntry {
59
+ const messageId = args.createMessageId();
60
+ const createdAt = args.now();
61
+ const frame = {
62
+ type: 'message.outbound',
63
+ messageId,
64
+ idempotencyKey: messageId,
65
+ sessionKey: args.sessionKey,
66
+ replyToId: args.normalizeReplyToId(args.replyToId) || undefined,
67
+ message: {
68
+ platform: args.route.platform,
69
+ groupId: args.route.groupId,
70
+ userId: args.route.userId,
71
+ type: 'text',
72
+ kind: args.kind,
73
+ msg: args.text,
74
+ path: '',
75
+ base64: '',
76
+ fileName: '',
77
+ },
78
+ ts: createdAt,
79
+ };
80
+
81
+ return {
82
+ messageId,
83
+ accountId: args.normalizeAccountId(args.accountId),
84
+ sessionKey: args.sessionKey,
85
+ route: args.route,
86
+ payload: frame,
87
+ createdAt,
88
+ retryCount: 0,
89
+ nextAttemptAt: createdAt,
90
+ };
91
+ }
@@ -0,0 +1,31 @@
1
+ import type { BncrConnection, OutboxEntry } from './types.ts';
2
+ import { buildPushOkArgs } from './outbox-push-args.ts';
3
+
4
+ export function buildFileTransferPushSuccessArgs(args: {
5
+ entry: OutboxEntry;
6
+ connIds: Iterable<string>;
7
+ owner: BncrConnection | null;
8
+ }) {
9
+ return {
10
+ entry: args.entry,
11
+ connIds: args.connIds,
12
+ ownerConnId: args.owner?.connId,
13
+ ownerClientId: args.owner?.clientId,
14
+ clearLastError: true,
15
+ };
16
+ }
17
+
18
+ export function buildFileTransferPushOkArgs(args: {
19
+ entry: OutboxEntry;
20
+ connIds: Iterable<string>;
21
+ recentInboundReachable: boolean;
22
+ event: string;
23
+ }) {
24
+ return buildPushOkArgs({
25
+ entry: args.entry,
26
+ connIds: args.connIds,
27
+ recentInboundReachable: args.recentInboundReachable,
28
+ event: args.event,
29
+ kind: 'file-transfer',
30
+ });
31
+ }
@@ -0,0 +1,25 @@
1
+ import type { OutboxEntry } from './types.ts';
2
+ import { buildPushFailureArgs } from './outbox-push-args.ts';
3
+
4
+ export function resolveFileTransferFailureState(args: {
5
+ entry: OutboxEntry;
6
+ error: unknown;
7
+ isRetryableFileTransferError: (error: unknown) => boolean;
8
+ }) {
9
+ const retryable = args.isRetryableFileTransferError(args.error);
10
+ return {
11
+ retryable,
12
+ deadLetterReason: args.entry.lastError || 'file-transfer-failed',
13
+ };
14
+ }
15
+
16
+ export function buildFileTransferPushFailureArgs(args: {
17
+ entry: OutboxEntry;
18
+ retryable: boolean;
19
+ }) {
20
+ return buildPushFailureArgs({
21
+ entry: args.entry,
22
+ retryable: args.retryable,
23
+ kind: 'file-transfer',
24
+ });
25
+ }