@xmoxmo/bncr 0.2.4 → 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.
- package/README.md +67 -4
- package/package.json +1 -1
- package/src/channel.ts +2274 -1548
- package/src/core/connection-capability.ts +70 -0
- package/src/core/connection-reachability.ts +141 -0
- package/src/core/diagnostics.ts +49 -0
- package/src/core/downlink-health.ts +56 -0
- package/src/core/extended-diagnostics.ts +65 -0
- package/src/core/lease-state.ts +94 -0
- package/src/core/outbox-enqueue.ts +22 -0
- package/src/core/outbox-entry-builders.ts +91 -0
- package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
- package/src/core/outbox-file-transfer-failure.ts +25 -0
- package/src/core/outbox-file-transfer-guards.ts +66 -0
- package/src/core/outbox-file-transfer-prep.ts +31 -0
- package/src/core/outbox-file-transfer-success.ts +34 -0
- package/src/core/outbox-push-args.ts +67 -0
- package/src/core/outbox-queue.ts +69 -0
- package/src/core/outbox-summary.ts +14 -0
- package/src/core/outbox-text-push-failure.ts +10 -0
- package/src/core/outbox-text-push-guards.ts +51 -0
- package/src/core/outbox-text-push-prep.ts +36 -0
- package/src/core/outbox-text-push-success.ts +62 -0
- package/src/core/register-trace.ts +110 -0
- package/src/core/status.ts +52 -0
- package/src/messaging/inbound/dispatch.ts +86 -48
- package/src/messaging/outbound/diagnostics.ts +246 -0
- package/src/messaging/outbound/media-dedupe.ts +51 -0
- package/src/messaging/outbound/queue-selectors.ts +186 -0
- package/src/messaging/outbound/reasons.ts +48 -0
- package/src/messaging/outbound/reply-enqueue.ts +329 -0
- package/src/messaging/outbound/retry-policy.ts +133 -0
- package/src/messaging/outbound/session-route.ts +34 -5
|
@@ -9,77 +9,32 @@ import { buildBncrReplyConfig } from './reply-config.ts';
|
|
|
9
9
|
|
|
10
10
|
type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
async function prepareBncrInboundSessionContext(args: {
|
|
13
13
|
api: any;
|
|
14
|
-
channelId: string;
|
|
15
14
|
cfg: any;
|
|
15
|
+
channelId: string;
|
|
16
16
|
parsed: ParsedInbound;
|
|
17
17
|
canonicalAgentId: string;
|
|
18
18
|
rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
|
|
19
|
-
enqueueFromReply: (args: {
|
|
20
|
-
accountId: string;
|
|
21
|
-
sessionKey: string;
|
|
22
|
-
route: any;
|
|
23
|
-
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
24
|
-
mediaLocalRoots?: readonly string[];
|
|
25
|
-
}) => Promise<void>;
|
|
26
|
-
setInboundActivity: (accountId: string, at: number) => void;
|
|
27
|
-
scheduleSave: () => void;
|
|
28
|
-
logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
|
|
29
19
|
}) {
|
|
30
|
-
const {
|
|
31
|
-
api,
|
|
32
|
-
channelId,
|
|
33
|
-
cfg,
|
|
34
|
-
parsed,
|
|
35
|
-
canonicalAgentId,
|
|
36
|
-
rememberSessionRoute,
|
|
37
|
-
enqueueFromReply,
|
|
38
|
-
setInboundActivity,
|
|
39
|
-
scheduleSave,
|
|
40
|
-
logger,
|
|
41
|
-
} = params;
|
|
20
|
+
const { api, cfg, channelId, parsed, canonicalAgentId, rememberSessionRoute } = args;
|
|
42
21
|
const {
|
|
43
22
|
accountId,
|
|
44
23
|
route,
|
|
45
24
|
peer,
|
|
46
25
|
sessionKeyfromroute,
|
|
47
|
-
clientId,
|
|
48
26
|
text,
|
|
49
27
|
msgType,
|
|
50
28
|
mediaBase64,
|
|
51
29
|
mediaPathFromTransfer,
|
|
52
30
|
mimeType,
|
|
53
31
|
fileName,
|
|
54
|
-
msgId,
|
|
55
32
|
extracted,
|
|
56
33
|
platform,
|
|
57
34
|
groupId,
|
|
58
35
|
userId,
|
|
59
36
|
} = parsed;
|
|
60
37
|
|
|
61
|
-
const nativeCommand = await handleBncrNativeCommand({
|
|
62
|
-
api,
|
|
63
|
-
channelId,
|
|
64
|
-
cfg,
|
|
65
|
-
parsed,
|
|
66
|
-
canonicalAgentId,
|
|
67
|
-
rememberSessionRoute,
|
|
68
|
-
enqueueFromReply,
|
|
69
|
-
logger,
|
|
70
|
-
});
|
|
71
|
-
if (nativeCommand.handled) {
|
|
72
|
-
const inboundAt = Date.now();
|
|
73
|
-
setInboundActivity(accountId, inboundAt);
|
|
74
|
-
scheduleSave();
|
|
75
|
-
return {
|
|
76
|
-
accountId,
|
|
77
|
-
sessionKey: nativeCommand.sessionKey,
|
|
78
|
-
taskKey: extracted.taskKey ?? null,
|
|
79
|
-
msgId: msgId ?? null,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
38
|
const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
|
|
84
39
|
cfg,
|
|
85
40
|
channel: channelId,
|
|
@@ -132,6 +87,89 @@ export async function dispatchBncrInbound(params: {
|
|
|
132
87
|
});
|
|
133
88
|
|
|
134
89
|
const displayTo = formatDisplayScope(route);
|
|
90
|
+
return {
|
|
91
|
+
resolvedRoute,
|
|
92
|
+
baseSessionKey,
|
|
93
|
+
taskSessionKey,
|
|
94
|
+
sessionKey,
|
|
95
|
+
storePath,
|
|
96
|
+
mediaPath,
|
|
97
|
+
rawBody,
|
|
98
|
+
body,
|
|
99
|
+
displayTo,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function dispatchBncrInbound(params: {
|
|
104
|
+
api: any;
|
|
105
|
+
channelId: string;
|
|
106
|
+
cfg: any;
|
|
107
|
+
parsed: ParsedInbound;
|
|
108
|
+
canonicalAgentId: string;
|
|
109
|
+
rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
|
|
110
|
+
enqueueFromReply: (args: {
|
|
111
|
+
accountId: string;
|
|
112
|
+
sessionKey: string;
|
|
113
|
+
route: any;
|
|
114
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
115
|
+
mediaLocalRoots?: readonly string[];
|
|
116
|
+
}) => Promise<void>;
|
|
117
|
+
setInboundActivity: (accountId: string, at: number) => void;
|
|
118
|
+
scheduleSave: () => void;
|
|
119
|
+
logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
|
|
120
|
+
}) {
|
|
121
|
+
const {
|
|
122
|
+
api,
|
|
123
|
+
channelId,
|
|
124
|
+
cfg,
|
|
125
|
+
parsed,
|
|
126
|
+
canonicalAgentId,
|
|
127
|
+
rememberSessionRoute,
|
|
128
|
+
enqueueFromReply,
|
|
129
|
+
setInboundActivity,
|
|
130
|
+
scheduleSave,
|
|
131
|
+
logger,
|
|
132
|
+
} = params;
|
|
133
|
+
const { accountId, route, clientId, msgId, extracted, mimeType, peer } = parsed;
|
|
134
|
+
|
|
135
|
+
const nativeCommand = await handleBncrNativeCommand({
|
|
136
|
+
api,
|
|
137
|
+
channelId,
|
|
138
|
+
cfg,
|
|
139
|
+
parsed,
|
|
140
|
+
canonicalAgentId,
|
|
141
|
+
rememberSessionRoute,
|
|
142
|
+
enqueueFromReply,
|
|
143
|
+
logger,
|
|
144
|
+
});
|
|
145
|
+
if (nativeCommand.handled) {
|
|
146
|
+
const inboundAt = Date.now();
|
|
147
|
+
setInboundActivity(accountId, inboundAt);
|
|
148
|
+
scheduleSave();
|
|
149
|
+
return {
|
|
150
|
+
accountId,
|
|
151
|
+
sessionKey: nativeCommand.sessionKey,
|
|
152
|
+
taskKey: extracted.taskKey ?? null,
|
|
153
|
+
msgId: msgId ?? null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const {
|
|
158
|
+
resolvedRoute,
|
|
159
|
+
sessionKey,
|
|
160
|
+
storePath,
|
|
161
|
+
mediaPath,
|
|
162
|
+
rawBody,
|
|
163
|
+
body,
|
|
164
|
+
displayTo,
|
|
165
|
+
} = await prepareBncrInboundSessionContext({
|
|
166
|
+
api,
|
|
167
|
+
cfg,
|
|
168
|
+
channelId,
|
|
169
|
+
parsed,
|
|
170
|
+
canonicalAgentId,
|
|
171
|
+
rememberSessionRoute,
|
|
172
|
+
});
|
|
135
173
|
if (!clientId) {
|
|
136
174
|
emitBncrLogLine('warn', '[bncr] inbound missing clientId for chat identity');
|
|
137
175
|
return {
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type OutboundScheduleSource,
|
|
3
|
+
OUTBOUND_TERMINAL_REASON,
|
|
4
|
+
} from './reasons.ts';
|
|
5
|
+
import type { RetryRerouteDecision } from './retry-policy.ts';
|
|
6
|
+
|
|
7
|
+
export function buildOutboxScheduleDebugInfo(args: {
|
|
8
|
+
bridgeId: string;
|
|
9
|
+
accountId?: string | null;
|
|
10
|
+
// Account-local aggregated next delay after considering currently visible entries.
|
|
11
|
+
localNextDelay?: number | null;
|
|
12
|
+
// Bridge-global aggregated next delay after merging account-local scheduling decisions.
|
|
13
|
+
globalNextDelay?: number | null;
|
|
14
|
+
// Immediate wait observed for this specific scheduling event.
|
|
15
|
+
wait?: number | null;
|
|
16
|
+
source: OutboundScheduleSource;
|
|
17
|
+
messageId?: string;
|
|
18
|
+
}) {
|
|
19
|
+
return {
|
|
20
|
+
bridge: args.bridgeId,
|
|
21
|
+
...(args.accountId ? { accountId: args.accountId } : {}),
|
|
22
|
+
...(args.messageId ? { messageId: args.messageId } : {}),
|
|
23
|
+
source: args.source,
|
|
24
|
+
...(typeof args.wait === 'number' ? { wait: args.wait } : {}),
|
|
25
|
+
...(typeof args.localNextDelay === 'number' ? { localNextDelay: args.localNextDelay } : {}),
|
|
26
|
+
...(typeof args.globalNextDelay === 'number' ? { globalNextDelay: args.globalNextDelay } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildOutboxPushSkipDebugInfo(args: {
|
|
31
|
+
messageId: string;
|
|
32
|
+
accountId: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
recentInboundReachable?: boolean;
|
|
35
|
+
kind?: string;
|
|
36
|
+
}) {
|
|
37
|
+
return {
|
|
38
|
+
messageId: args.messageId,
|
|
39
|
+
accountId: args.accountId,
|
|
40
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
41
|
+
reason: args.reason,
|
|
42
|
+
...(typeof args.recentInboundReachable === 'boolean'
|
|
43
|
+
? { recentInboundReachable: args.recentInboundReachable }
|
|
44
|
+
: {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildOutboxRouteSelectDebugInfo(args: {
|
|
49
|
+
messageId: string;
|
|
50
|
+
accountId: string;
|
|
51
|
+
routeReason: string;
|
|
52
|
+
connIds: Iterable<string>;
|
|
53
|
+
ownerConnId?: string;
|
|
54
|
+
ownerClientId?: string;
|
|
55
|
+
recentInboundReachable: boolean;
|
|
56
|
+
event: string;
|
|
57
|
+
kind?: string;
|
|
58
|
+
}) {
|
|
59
|
+
return {
|
|
60
|
+
messageId: args.messageId,
|
|
61
|
+
accountId: args.accountId,
|
|
62
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
63
|
+
routeReason: args.routeReason,
|
|
64
|
+
connIds: Array.from(args.connIds),
|
|
65
|
+
ownerConnId: args.ownerConnId || '',
|
|
66
|
+
ownerClientId: args.ownerClientId || '',
|
|
67
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
68
|
+
event: args.event,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildOutboxPushOkDebugInfo(args: {
|
|
73
|
+
messageId: string;
|
|
74
|
+
accountId: string;
|
|
75
|
+
connIds: Iterable<string>;
|
|
76
|
+
ownerConnId?: string;
|
|
77
|
+
ownerClientId?: string;
|
|
78
|
+
recentInboundReachable: boolean;
|
|
79
|
+
event: string;
|
|
80
|
+
kind?: string;
|
|
81
|
+
}) {
|
|
82
|
+
return {
|
|
83
|
+
messageId: args.messageId,
|
|
84
|
+
accountId: args.accountId,
|
|
85
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
86
|
+
connIds: Array.from(args.connIds),
|
|
87
|
+
ownerConnId: args.ownerConnId || '',
|
|
88
|
+
ownerClientId: args.ownerClientId || '',
|
|
89
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
90
|
+
event: args.event,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildFlushDebugInfo(args: {
|
|
95
|
+
bridgeId: string;
|
|
96
|
+
accountId: string | null;
|
|
97
|
+
targetAccounts: string[];
|
|
98
|
+
outboxSize: number;
|
|
99
|
+
trigger: string;
|
|
100
|
+
reason?: string;
|
|
101
|
+
}) {
|
|
102
|
+
return {
|
|
103
|
+
bridge: args.bridgeId,
|
|
104
|
+
accountId: args.accountId,
|
|
105
|
+
targetAccounts: [...args.targetAccounts],
|
|
106
|
+
outboxSize: args.outboxSize,
|
|
107
|
+
trigger: args.trigger,
|
|
108
|
+
reason: args.reason,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildOutboxAckDebugInfo(args: {
|
|
113
|
+
messageId: string;
|
|
114
|
+
accountId: string;
|
|
115
|
+
requireAck: boolean;
|
|
116
|
+
ackResult: 'acked' | 'timeout';
|
|
117
|
+
onlineNow: boolean;
|
|
118
|
+
recentInboundReachable: boolean;
|
|
119
|
+
connIds?: Iterable<string>;
|
|
120
|
+
ownerConnId?: string;
|
|
121
|
+
ownerClientId?: string;
|
|
122
|
+
kind?: string;
|
|
123
|
+
event?: string;
|
|
124
|
+
}) {
|
|
125
|
+
return {
|
|
126
|
+
messageId: args.messageId,
|
|
127
|
+
accountId: args.accountId,
|
|
128
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
129
|
+
requireAck: args.requireAck,
|
|
130
|
+
ackResult: args.ackResult,
|
|
131
|
+
onlineNow: args.onlineNow,
|
|
132
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
133
|
+
...(args.connIds ? { connIds: Array.from(args.connIds) } : {}),
|
|
134
|
+
...(args.ownerConnId ? { ownerConnId: args.ownerConnId } : {}),
|
|
135
|
+
...(args.ownerClientId ? { ownerClientId: args.ownerClientId } : {}),
|
|
136
|
+
...(args.event ? { event: args.event } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildRetryRerouteDebugInfo(args: {
|
|
141
|
+
messageId: string;
|
|
142
|
+
accountId: string;
|
|
143
|
+
currentConnId: string;
|
|
144
|
+
decision: RetryRerouteDecision;
|
|
145
|
+
availableConnIds: string[];
|
|
146
|
+
}) {
|
|
147
|
+
if (args.decision.kind !== 'retry') {
|
|
148
|
+
return {
|
|
149
|
+
messageId: args.messageId,
|
|
150
|
+
accountId: args.accountId,
|
|
151
|
+
currentConnId: args.currentConnId,
|
|
152
|
+
availableConnIds: [...args.availableConnIds],
|
|
153
|
+
kind: args.decision.kind,
|
|
154
|
+
terminalReason: args.decision.terminalReason,
|
|
155
|
+
nextRetryCount: args.decision.nextRetryCount,
|
|
156
|
+
lastAttemptAt: args.decision.lastAttemptAt,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
messageId: args.messageId,
|
|
162
|
+
accountId: args.accountId,
|
|
163
|
+
currentConnId: args.currentConnId,
|
|
164
|
+
attemptedConnIds: [...args.decision.attemptedConnIds],
|
|
165
|
+
availableConnIds: [...args.availableConnIds],
|
|
166
|
+
revalidatedConnIds: [...args.decision.revalidatedConnIds],
|
|
167
|
+
hasUntriedAlternative: args.decision.hasUntriedAlternative,
|
|
168
|
+
shouldFastReroute: args.decision.shouldFastReroute,
|
|
169
|
+
routeAttemptRound: args.decision.routeAttemptRound,
|
|
170
|
+
nextAttemptAt: args.decision.nextAttemptAt,
|
|
171
|
+
fastReroutePending: args.decision.fastReroutePending,
|
|
172
|
+
nextRetryCount: args.decision.nextRetryCount,
|
|
173
|
+
lastAttemptAt: args.decision.lastAttemptAt,
|
|
174
|
+
lastError: args.decision.lastError,
|
|
175
|
+
kind: args.decision.kind,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildPushFailureDebugInfo(args: {
|
|
180
|
+
messageId: string;
|
|
181
|
+
accountId: string;
|
|
182
|
+
retryCount: number;
|
|
183
|
+
lastError?: string;
|
|
184
|
+
retryable?: boolean;
|
|
185
|
+
kind?: string;
|
|
186
|
+
}) {
|
|
187
|
+
return {
|
|
188
|
+
messageId: args.messageId,
|
|
189
|
+
accountId: args.accountId,
|
|
190
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
191
|
+
...(typeof args.retryable === 'boolean' ? { retryable: args.retryable } : {}),
|
|
192
|
+
retryCount: args.retryCount,
|
|
193
|
+
error:
|
|
194
|
+
(typeof args.lastError === 'string' && args.lastError) ||
|
|
195
|
+
OUTBOUND_TERMINAL_REASON.PUSH_RETRY,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildEnqueueFromReplyDebugInfo(args: {
|
|
200
|
+
accountId: string;
|
|
201
|
+
sessionKey: string;
|
|
202
|
+
route: { platform?: string; groupId?: string; userId?: string } | null | undefined;
|
|
203
|
+
payload: {
|
|
204
|
+
text: string;
|
|
205
|
+
mediaUrl: string;
|
|
206
|
+
mediaUrls?: string[];
|
|
207
|
+
asVoice: boolean;
|
|
208
|
+
audioAsVoice: boolean;
|
|
209
|
+
kind?: 'tool' | 'block' | 'final';
|
|
210
|
+
replyToId: string;
|
|
211
|
+
};
|
|
212
|
+
}) {
|
|
213
|
+
return {
|
|
214
|
+
accountId: args.accountId,
|
|
215
|
+
sessionKey: args.sessionKey,
|
|
216
|
+
route: {
|
|
217
|
+
platform: args.route?.platform,
|
|
218
|
+
groupId: args.route?.groupId,
|
|
219
|
+
userId: args.route?.userId,
|
|
220
|
+
},
|
|
221
|
+
payload: {
|
|
222
|
+
text: args.payload.text,
|
|
223
|
+
mediaUrl: args.payload.mediaUrl,
|
|
224
|
+
mediaUrls: args.payload.mediaUrls,
|
|
225
|
+
asVoice: args.payload.asVoice,
|
|
226
|
+
audioAsVoice: args.payload.audioAsVoice,
|
|
227
|
+
kind: args.payload.kind,
|
|
228
|
+
replyToId: args.payload.replyToId,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function buildReplyMediaFallbackDebugInfo(args: {
|
|
234
|
+
sessionKey: string;
|
|
235
|
+
mediaUrl: string;
|
|
236
|
+
replyToId: string;
|
|
237
|
+
fallback: { text: string; reason: string };
|
|
238
|
+
}) {
|
|
239
|
+
return {
|
|
240
|
+
sessionKey: args.sessionKey,
|
|
241
|
+
mediaUrl: args.mediaUrl,
|
|
242
|
+
replyToId: args.replyToId || undefined,
|
|
243
|
+
fallbackText: args.fallback.text,
|
|
244
|
+
reason: args.fallback.reason,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type MediaDedupeCacheEntry = {
|
|
2
|
+
mediaUrl: string;
|
|
3
|
+
text: string;
|
|
4
|
+
replyToId: string;
|
|
5
|
+
createdAt: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function asString(v: unknown, fallback = ''): string {
|
|
9
|
+
if (typeof v === 'string') return v;
|
|
10
|
+
if (v == null) return fallback;
|
|
11
|
+
return String(v);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeReplyToId(value: unknown): string {
|
|
15
|
+
return asString(value || '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeMessageText(value: unknown): string {
|
|
19
|
+
return asString(value || '').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shouldTreatReplyToAsSame(
|
|
23
|
+
currentReplyToId: string,
|
|
24
|
+
previousReplyToId: string,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!currentReplyToId || !previousReplyToId) return true;
|
|
27
|
+
return currentReplyToId === previousReplyToId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildMediaTextFallback(params: {
|
|
31
|
+
currentText: string;
|
|
32
|
+
previousText: string;
|
|
33
|
+
currentReplyToId: string;
|
|
34
|
+
previousReplyToId: string;
|
|
35
|
+
}): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
|
|
36
|
+
if (!shouldTreatReplyToAsSame(params.currentReplyToId, params.previousReplyToId)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (params.currentText && params.currentText !== params.previousText) {
|
|
41
|
+
return {
|
|
42
|
+
text: params.currentText,
|
|
43
|
+
reason: 'text-changed-downgrade',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
text: '✅已发送',
|
|
49
|
+
reason: 'same-text-sent-checkmark',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { BncrConnection, OutboxEntry } from '../../core/types.ts';
|
|
2
|
+
|
|
3
|
+
export type OutboxRouteSelection = {
|
|
4
|
+
connIds: string[];
|
|
5
|
+
routeReason:
|
|
6
|
+
| 'owner'
|
|
7
|
+
| 'active-connections-unattempted-first'
|
|
8
|
+
| 'active-connections-revalidated'
|
|
9
|
+
| 'active-connections-all-visible'
|
|
10
|
+
| 'recent-inbound-fallback'
|
|
11
|
+
| 'none';
|
|
12
|
+
recentInboundReachable: boolean;
|
|
13
|
+
ownerConnId?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type OutboxFileTransferRouteSelection = {
|
|
17
|
+
connIds: string[];
|
|
18
|
+
routeReason:
|
|
19
|
+
| 'owner'
|
|
20
|
+
| 'active-connections'
|
|
21
|
+
| 'active-connections-reused'
|
|
22
|
+
| 'recent-inbound-fallback'
|
|
23
|
+
| 'none';
|
|
24
|
+
recentInboundReachable: boolean;
|
|
25
|
+
ownerConnId?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function computeOutboxRetryWait(nextAttemptAt: number, nowMs: number): number {
|
|
29
|
+
return Math.max(0, Number(nextAttemptAt || 0) - nowMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay: number | null): number | null {
|
|
33
|
+
if (candidateDelay == null) return currentDelay;
|
|
34
|
+
return currentDelay == null ? candidateDelay : Math.min(currentDelay, candidateDelay);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function clampOutboxDrainDelay(delayMs: number): number {
|
|
38
|
+
return Math.max(0, Math.min(Number(delayMs || 0), 30_000));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function selectOutboxTargetAccounts(args: {
|
|
42
|
+
accountId?: string | null;
|
|
43
|
+
outboxEntries: Iterable<OutboxEntry>;
|
|
44
|
+
normalizeAccountId: (accountId: string) => string;
|
|
45
|
+
}): string[] {
|
|
46
|
+
const filterAcc = args.accountId ? args.normalizeAccountId(args.accountId) : null;
|
|
47
|
+
if (filterAcc) return [filterAcc];
|
|
48
|
+
return Array.from(
|
|
49
|
+
new Set(Array.from(args.outboxEntries).map((entry) => args.normalizeAccountId(entry.accountId))),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function listAccountOutboxEntries(args: {
|
|
54
|
+
accountId: string;
|
|
55
|
+
outboxEntries: Iterable<OutboxEntry>;
|
|
56
|
+
normalizeAccountId: (accountId: string) => string;
|
|
57
|
+
}): OutboxEntry[] {
|
|
58
|
+
return Array.from(args.outboxEntries)
|
|
59
|
+
.filter((entry) => args.normalizeAccountId(entry.accountId) === args.accountId)
|
|
60
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function findDueOutboxEntry(entries: OutboxEntry[], nowMs: number): OutboxEntry | null {
|
|
64
|
+
return entries.find((item) => item.nextAttemptAt <= nowMs) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function computeNextOutboxDelay(entries: OutboxEntry[], nowMs: number): number | null {
|
|
68
|
+
if (!entries.length) return null;
|
|
69
|
+
const due = findDueOutboxEntry(entries, nowMs);
|
|
70
|
+
if (due) return 0;
|
|
71
|
+
return Math.max(0, entries[0].nextAttemptAt - nowMs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildOutboxOnlineDebugInfo(args: {
|
|
75
|
+
bridgeId: string;
|
|
76
|
+
accountId: string;
|
|
77
|
+
online: boolean;
|
|
78
|
+
recentInboundReachable: boolean;
|
|
79
|
+
connections: Iterable<BncrConnection>;
|
|
80
|
+
}) {
|
|
81
|
+
return {
|
|
82
|
+
bridge: args.bridgeId,
|
|
83
|
+
accountId: args.accountId,
|
|
84
|
+
online: args.online,
|
|
85
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
86
|
+
connections: Array.from(args.connections).map((c) => ({
|
|
87
|
+
accountId: c.accountId,
|
|
88
|
+
connId: c.connId,
|
|
89
|
+
clientId: c.clientId,
|
|
90
|
+
lastSeenAt: c.lastSeenAt,
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function selectOutboxFileTransferRouteCandidates(args: {
|
|
96
|
+
routeCandidates: Iterable<string>;
|
|
97
|
+
attemptedConnIds: Iterable<string>;
|
|
98
|
+
recentInboundConnIds: Iterable<string>;
|
|
99
|
+
ownerConnId?: string;
|
|
100
|
+
recentInboundReachable: boolean;
|
|
101
|
+
isRevalidatedAttemptedConn: (connId: string) => boolean;
|
|
102
|
+
}): OutboxFileTransferRouteSelection {
|
|
103
|
+
const routeCandidates = Array.from(args.routeCandidates);
|
|
104
|
+
const attemptedConnIds = new Set(Array.from(args.attemptedConnIds));
|
|
105
|
+
const filteredCandidates = routeCandidates.filter(
|
|
106
|
+
(connId) => !attemptedConnIds.has(connId) || args.isRevalidatedAttemptedConn(connId),
|
|
107
|
+
);
|
|
108
|
+
const ownerConnId =
|
|
109
|
+
args.ownerConnId && !attemptedConnIds.has(args.ownerConnId) ? args.ownerConnId : undefined;
|
|
110
|
+
let connIds = ownerConnId ? [ownerConnId] : filteredCandidates.length > 0 ? filteredCandidates : routeCandidates;
|
|
111
|
+
let routeReason: OutboxFileTransferRouteSelection['routeReason'] = ownerConnId
|
|
112
|
+
? 'owner'
|
|
113
|
+
: connIds.length > 0
|
|
114
|
+
? filteredCandidates.length > 0
|
|
115
|
+
? 'active-connections'
|
|
116
|
+
: 'active-connections-reused'
|
|
117
|
+
: args.recentInboundReachable
|
|
118
|
+
? 'recent-inbound-fallback'
|
|
119
|
+
: 'none';
|
|
120
|
+
|
|
121
|
+
if (!connIds.length && args.recentInboundReachable) {
|
|
122
|
+
const recentInboundConnIds = Array.from(args.recentInboundConnIds);
|
|
123
|
+
const filteredRecentInboundConnIds = recentInboundConnIds.filter(
|
|
124
|
+
(connId) => !attemptedConnIds.has(connId),
|
|
125
|
+
);
|
|
126
|
+
connIds = filteredRecentInboundConnIds.length > 0 ? filteredRecentInboundConnIds : recentInboundConnIds;
|
|
127
|
+
routeReason = connIds.length > 0 ? 'recent-inbound-fallback' : 'none';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
connIds,
|
|
132
|
+
routeReason,
|
|
133
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
134
|
+
...(ownerConnId ? { ownerConnId } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function selectOutboxRouteCandidates(args: {
|
|
139
|
+
routeCandidates: Iterable<string>;
|
|
140
|
+
attemptedConnIds: Iterable<string>;
|
|
141
|
+
recentInboundConnIds: Iterable<string>;
|
|
142
|
+
ownerConnId?: string;
|
|
143
|
+
recentInboundReachable: boolean;
|
|
144
|
+
isRevalidatedAttemptedConn: (connId: string) => boolean;
|
|
145
|
+
}): OutboxRouteSelection {
|
|
146
|
+
const routeCandidates = Array.from(args.routeCandidates);
|
|
147
|
+
const attemptedConnIds = new Set(Array.from(args.attemptedConnIds));
|
|
148
|
+
const unattemptedCandidates = routeCandidates.filter((connId) => !attemptedConnIds.has(connId));
|
|
149
|
+
const revalidatedCandidates = routeCandidates.filter(
|
|
150
|
+
(connId) => attemptedConnIds.has(connId) && args.isRevalidatedAttemptedConn(connId),
|
|
151
|
+
);
|
|
152
|
+
const preferredCandidates = unattemptedCandidates.length > 0 ? unattemptedCandidates : routeCandidates;
|
|
153
|
+
const ownerConnId =
|
|
154
|
+
args.ownerConnId && preferredCandidates.includes(args.ownerConnId) ? args.ownerConnId : undefined;
|
|
155
|
+
let connIds = ownerConnId ? [ownerConnId] : preferredCandidates;
|
|
156
|
+
let routeReason: OutboxRouteSelection['routeReason'] = ownerConnId
|
|
157
|
+
? 'owner'
|
|
158
|
+
: connIds.length > 0
|
|
159
|
+
? unattemptedCandidates.length > 0
|
|
160
|
+
? 'active-connections-unattempted-first'
|
|
161
|
+
: revalidatedCandidates.length > 0
|
|
162
|
+
? 'active-connections-revalidated'
|
|
163
|
+
: 'active-connections-all-visible'
|
|
164
|
+
: args.recentInboundReachable
|
|
165
|
+
? 'recent-inbound-fallback'
|
|
166
|
+
: 'none';
|
|
167
|
+
|
|
168
|
+
if (!connIds.length && args.recentInboundReachable) {
|
|
169
|
+
const recentInboundConnIds = Array.from(args.recentInboundConnIds);
|
|
170
|
+
const unattemptedRecentInboundConnIds = recentInboundConnIds.filter(
|
|
171
|
+
(connId) => !attemptedConnIds.has(connId),
|
|
172
|
+
);
|
|
173
|
+
connIds =
|
|
174
|
+
unattemptedRecentInboundConnIds.length > 0
|
|
175
|
+
? unattemptedRecentInboundConnIds
|
|
176
|
+
: recentInboundConnIds;
|
|
177
|
+
routeReason = connIds.length > 0 ? 'recent-inbound-fallback' : 'none';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
connIds,
|
|
182
|
+
routeReason,
|
|
183
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
184
|
+
...(ownerConnId ? { ownerConnId } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const OUTBOUND_DEGRADE_REASON = {
|
|
2
|
+
ACK_TIMEOUT: 'ack-timeout',
|
|
3
|
+
PUSH_UNCONFIRMED: 'push-unconfirmed',
|
|
4
|
+
} as const;
|
|
5
|
+
|
|
6
|
+
export const OUTBOUND_TERMINAL_REASON = {
|
|
7
|
+
PUSH_ACK_TIMEOUT: 'push-ack-timeout',
|
|
8
|
+
PUSH_DELIVERY_UNCONFIRMED: 'push-delivery-unconfirmed',
|
|
9
|
+
PUSH_RETRY_LIMIT: 'push-retry-limit',
|
|
10
|
+
PUSH_RETRY: 'push-retry',
|
|
11
|
+
FILE_ACK_TIMEOUT: 'file-ack-timeout',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export const OUTBOUND_FLUSH_REASON = {
|
|
15
|
+
SCHEDULED_DRAIN: 'scheduled-drain',
|
|
16
|
+
WS_ONLINE: 'ws-online',
|
|
17
|
+
MESSAGE_ACKED: 'message-acked',
|
|
18
|
+
ACTIVITY_HEARTBEAT: 'activity-heartbeat',
|
|
19
|
+
INBOUND_ACCEPTED: 'inbound-accepted',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const OUTBOUND_FLUSH_TRIGGER = {
|
|
23
|
+
TIMER: 'timer',
|
|
24
|
+
CONNECT: 'connect',
|
|
25
|
+
ACK_OK: 'ack-ok',
|
|
26
|
+
ACTIVITY: 'activity',
|
|
27
|
+
INBOUND: 'inbound',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
// Scheduling debug sources describe where a next-drain wait came from.
|
|
31
|
+
// They are observability taxonomy only; do not derive runtime behavior from them.
|
|
32
|
+
export const OUTBOUND_SCHEDULE_SOURCE = {
|
|
33
|
+
// Single pending timer was armed by schedulePushDrain(...).
|
|
34
|
+
SCHEDULE_PUSH_DRAIN: 'schedule-push-drain',
|
|
35
|
+
// Current account has queued work, but nothing is due yet.
|
|
36
|
+
ACCOUNT_NO_DUE_ENTRY: 'account-no-due-entry',
|
|
37
|
+
// Ack-timeout / reroute path kept entry in outbox and scheduled a retry.
|
|
38
|
+
RETRY_REROUTE_WAIT: 'retry-reroute-wait',
|
|
39
|
+
// Direct push failure kept entry in outbox and scheduled backoff.
|
|
40
|
+
PUSH_FAIL_WAIT: 'push-fail-wait',
|
|
41
|
+
// Account-local next delay was merged into bridge-global next delay.
|
|
42
|
+
ACCOUNT_NEXT_DELAY_MERGE: 'account-next-delay-merge',
|
|
43
|
+
// flushPushQueue(...) finished and armed the next bridge-level drain.
|
|
44
|
+
FLUSH_NEXT_DRAIN: 'flush-next-drain',
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export type OutboundScheduleSource =
|
|
48
|
+
(typeof OUTBOUND_SCHEDULE_SOURCE)[keyof typeof OUTBOUND_SCHEDULE_SOURCE];
|