@xmoxmo/bncr 0.2.5 → 0.2.7
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 +9 -3
- package/index.ts +30 -15
- package/package.json +4 -3
- package/scripts/check-pack.mjs +61 -0
- package/scripts/selfcheck.mjs +10 -0
- package/src/channel.ts +892 -255
- package/src/core/connection-reachability.ts +41 -14
- package/src/core/diagnostics.ts +7 -2
- package/src/core/downlink-health.ts +7 -2
- package/src/core/outbox-entry-builders.ts +3 -2
- package/src/core/policy.ts +9 -0
- package/src/core/register-trace.ts +6 -1
- package/src/core/status.ts +7 -2
- package/src/core/targets.ts +10 -1
- package/src/core/types.ts +1 -0
- package/src/messaging/inbound/commands.ts +330 -77
- package/src/messaging/inbound/context-facts.ts +200 -0
- package/src/messaging/inbound/dispatch.ts +429 -119
- package/src/messaging/inbound/gate.ts +66 -26
- package/src/messaging/inbound/parse.ts +8 -0
- package/src/messaging/inbound/runtime-compat.ts +39 -0
- package/src/messaging/inbound/session-label.ts +115 -0
- package/src/messaging/outbound/diagnostics.ts +16 -0
- package/src/messaging/outbound/durable-message-adapter.ts +107 -0
- package/src/messaging/outbound/durable-queue-adapter.ts +157 -0
- package/src/messaging/outbound/media.ts +3 -1
- package/src/messaging/outbound/queue-selectors.ts +7 -2
- package/src/messaging/outbound/reasons.ts +4 -0
- package/src/messaging/outbound/reply-enqueue.ts +2 -2
- package/src/messaging/outbound/reply-target-policy.ts +13 -0
- package/src/messaging/outbound/retry-policy.ts +12 -3
- package/src/messaging/outbound/send.ts +6 -0
- package/src/messaging/outbound/session-route.ts +2 -2
- package/src/openclaw/config-runtime.ts +52 -0
- package/src/openclaw/inbound-session-runtime.ts +94 -0
- package/src/openclaw/ingress-runtime.ts +35 -0
- package/src/openclaw/media-runtime.ts +73 -0
- package/src/openclaw/reply-runtime.ts +104 -0
- package/src/openclaw/routing-runtime.ts +48 -0
- package/src/openclaw/sdk-helpers.ts +20 -0
- package/src/openclaw/session-route-runtime.ts +15 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { normalizeAccountId } from '../../core/accounts.ts';
|
|
2
2
|
import { resolveBncrChannelPolicy } from '../../core/policy.ts';
|
|
3
3
|
import { buildDisplayScopeCandidates } from '../../core/targets.ts';
|
|
4
|
+
import {
|
|
5
|
+
defineOpenClawStableChannelIngressIdentity,
|
|
6
|
+
resolveOpenClawChannelMessageIngress,
|
|
7
|
+
} from '../../openclaw/ingress-runtime.ts';
|
|
4
8
|
|
|
5
9
|
export type BncrGateResult = { allowed: true } | { allowed: false; reason: string };
|
|
6
10
|
|
|
@@ -10,13 +14,40 @@ function asString(v: unknown, fallback = ''): string {
|
|
|
10
14
|
return String(v);
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
const bncrIngressIdentity = defineOpenClawStableChannelIngressIdentity({
|
|
18
|
+
key: 'displayScope',
|
|
19
|
+
kind: 'plugin:bncr-display-scope',
|
|
20
|
+
normalize: (value: string) => asString(value).trim() || null,
|
|
21
|
+
sensitivity: 'pii',
|
|
22
|
+
entryIdPrefix: 'bncr-allow',
|
|
23
|
+
aliases: [
|
|
24
|
+
{
|
|
25
|
+
key: 'routeKey',
|
|
26
|
+
kind: 'plugin:bncr-route-key',
|
|
27
|
+
normalize: (value: string) => asString(value).trim() || null,
|
|
28
|
+
sensitivity: 'pii',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function gateReasonFromIngress(reasonCode?: string): string {
|
|
34
|
+
switch (reasonCode) {
|
|
35
|
+
case 'dm_policy_disabled':
|
|
36
|
+
return 'dm disabled';
|
|
37
|
+
case 'dm_policy_not_allowlisted':
|
|
38
|
+
case 'dm_policy_pairing_required':
|
|
39
|
+
return 'dm allowlist blocked';
|
|
40
|
+
case 'group_policy_disabled':
|
|
41
|
+
return 'group disabled';
|
|
42
|
+
case 'group_policy_not_allowlisted':
|
|
43
|
+
case 'group_policy_empty_allowlist':
|
|
44
|
+
return 'group allowlist blocked';
|
|
45
|
+
default:
|
|
46
|
+
return reasonCode || 'ingress blocked';
|
|
47
|
+
}
|
|
17
48
|
}
|
|
18
49
|
|
|
19
|
-
export function checkBncrMessageGate(params: {
|
|
50
|
+
export async function checkBncrMessageGate(params: {
|
|
20
51
|
parsed: any;
|
|
21
52
|
cfg: any;
|
|
22
53
|
account: { accountId: string; enabled?: boolean };
|
|
@@ -34,31 +65,40 @@ export function checkBncrMessageGate(params: {
|
|
|
34
65
|
const route = parsed?.route;
|
|
35
66
|
const isGroup = asString(route?.groupId || '0') !== '0';
|
|
36
67
|
|
|
37
|
-
if (!isGroup && policy.dmPolicy === 'disabled') {
|
|
38
|
-
return { allowed: false, reason: 'dm disabled' };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (isGroup && policy.groupPolicy === 'disabled') {
|
|
42
|
-
return { allowed: false, reason: 'group disabled' };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
68
|
const candidates = buildDisplayScopeCandidates(route);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!matchesAllowList(policy.allowFrom, candidates)) {
|
|
49
|
-
return { allowed: false, reason: 'dm allowlist blocked' };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (isGroup && policy.groupPolicy === 'allowlist') {
|
|
54
|
-
if (!matchesAllowList(policy.groupAllowFrom, candidates)) {
|
|
55
|
-
return { allowed: false, reason: 'group allowlist blocked' };
|
|
56
|
-
}
|
|
57
|
-
}
|
|
69
|
+
const displayScope = candidates[0] || '';
|
|
70
|
+
const routeKey = candidates.find((candidate) => candidate !== displayScope) || displayScope;
|
|
58
71
|
|
|
59
72
|
// requireMention 默认值为 false。
|
|
60
73
|
// 设计目标:当它未来真正生效时,含义是“群消息只有在明确提到机器人时才允许进入处理链”。
|
|
61
74
|
// 但当前 parse 层尚未稳定提取 mentions,上游客户端也未统一透传 mention 信号,
|
|
62
75
|
// 因此现阶段即使配置为 true,也仍不做实际拦截,避免出现半实现状态。
|
|
63
|
-
|
|
76
|
+
const resolved = await resolveOpenClawChannelMessageIngress({
|
|
77
|
+
channelId: 'bncr',
|
|
78
|
+
accountId,
|
|
79
|
+
identity: bncrIngressIdentity,
|
|
80
|
+
subject: {
|
|
81
|
+
stableId: displayScope,
|
|
82
|
+
aliases: { routeKey },
|
|
83
|
+
},
|
|
84
|
+
conversation: {
|
|
85
|
+
kind: isGroup ? 'group' : 'direct',
|
|
86
|
+
id: isGroup ? asString(route?.groupId) : asString(route?.userId || displayScope),
|
|
87
|
+
},
|
|
88
|
+
event: { kind: 'message', authMode: 'inbound', mayPair: !isGroup },
|
|
89
|
+
policy: {
|
|
90
|
+
dmPolicy: policy.dmPolicy,
|
|
91
|
+
groupPolicy: policy.groupPolicy,
|
|
92
|
+
groupAllowFromFallbackToAllowFrom: false,
|
|
93
|
+
},
|
|
94
|
+
allowFrom: policy.dmPolicy === 'open' ? ['*', ...policy.allowFrom] : policy.allowFrom,
|
|
95
|
+
groupAllowFrom: policy.groupAllowFrom,
|
|
96
|
+
accessGroups: cfg?.accessGroups,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (resolved.ingress.admission === 'dispatch' || resolved.ingress.admission === 'observe') {
|
|
100
|
+
return { allowed: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { allowed: false, reason: gateReasonFromIngress(resolved.ingress.reasonCode) };
|
|
64
104
|
}
|
|
@@ -36,6 +36,10 @@ export function inboundDedupKey(params: {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
|
|
39
|
+
// Compatibility boundary: bncr currently records and dispatches all conversations as direct
|
|
40
|
+
// sessions even when the display scope contains a group id. Do not change this to true
|
|
41
|
+
// group semantics without updating session routing, reply target policy, and requireMention
|
|
42
|
+
// behavior together.
|
|
39
43
|
return 'direct';
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -45,6 +49,9 @@ export function parseBncrInboundParams(params: any) {
|
|
|
45
49
|
const groupId = asString(params?.groupId || '0').trim() || '0';
|
|
46
50
|
const userId = asString(params?.userId || '').trim();
|
|
47
51
|
const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
|
|
52
|
+
const providedOriginatingTo = asString(
|
|
53
|
+
params?.originatingTo || params?.providedOriginatingTo || params?.to || '',
|
|
54
|
+
).trim() || undefined;
|
|
48
55
|
const clientId = asString(params?.clientId || '').trim() || undefined;
|
|
49
56
|
|
|
50
57
|
const route: BncrRoute = {
|
|
@@ -84,6 +91,7 @@ export function parseBncrInboundParams(params: any) {
|
|
|
84
91
|
groupId,
|
|
85
92
|
userId,
|
|
86
93
|
sessionKeyfromroute,
|
|
94
|
+
providedOriginatingTo,
|
|
87
95
|
clientId,
|
|
88
96
|
route,
|
|
89
97
|
text,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { emitBncrLogLine } from '../../core/logging.ts';
|
|
2
|
+
|
|
3
|
+
type ChannelRuntimeCompat = {
|
|
4
|
+
buildContext: (...args: any[]) => any;
|
|
5
|
+
run: (...args: any[]) => Promise<any> | any;
|
|
6
|
+
runPreparedReply?: (...args: any[]) => Promise<any> | any;
|
|
7
|
+
dispatchReply?: (...args: any[]) => Promise<any> | any;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
let warnedLegacyTurnRuntime = false;
|
|
11
|
+
|
|
12
|
+
export function resolveBncrChannelInboundRuntime(api: any): ChannelRuntimeCompat {
|
|
13
|
+
const channelRuntime = api?.runtime?.channel;
|
|
14
|
+
const inboundRuntime = channelRuntime?.inbound;
|
|
15
|
+
if (inboundRuntime?.buildContext && inboundRuntime?.run) {
|
|
16
|
+
return inboundRuntime;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const legacyTurnRuntime = channelRuntime?.turn;
|
|
20
|
+
if (legacyTurnRuntime?.buildContext && legacyTurnRuntime?.run) {
|
|
21
|
+
if (!warnedLegacyTurnRuntime) {
|
|
22
|
+
warnedLegacyTurnRuntime = true;
|
|
23
|
+
emitBncrLogLine(
|
|
24
|
+
'warn',
|
|
25
|
+
'[bncr] using legacy runtime.channel.turn compatibility path; upgrade path prefers runtime.channel.inbound',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
buildContext: legacyTurnRuntime.buildContext,
|
|
30
|
+
run: legacyTurnRuntime.run,
|
|
31
|
+
runPreparedReply: legacyTurnRuntime.runPrepared,
|
|
32
|
+
dispatchReply: legacyTurnRuntime.dispatchAssembled ?? legacyTurnRuntime.runAssembled,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error(
|
|
37
|
+
'OpenClaw channel inbound runtime is unavailable: expected runtime.channel.inbound.* or legacy runtime.channel.turn.*',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { emitBncrLogLine } from '../../core/logging.ts';
|
|
2
|
+
import {
|
|
3
|
+
recordBncrSessionMetaFromInbound,
|
|
4
|
+
updateBncrSessionStoreEntry,
|
|
5
|
+
} from '../../openclaw/inbound-session-runtime.ts';
|
|
6
|
+
|
|
7
|
+
type RecordInboundSessionFn = (args: any) => Promise<unknown> | unknown;
|
|
8
|
+
|
|
9
|
+
export function buildBncrInboundSessionIdentityPatch(args: {
|
|
10
|
+
channelId: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
chatType: 'direct' | 'group';
|
|
13
|
+
displayTo: string;
|
|
14
|
+
senderId: string;
|
|
15
|
+
}) {
|
|
16
|
+
const { channelId, accountId, chatType, displayTo, senderId } = args;
|
|
17
|
+
return {
|
|
18
|
+
label: displayTo,
|
|
19
|
+
channel: channelId,
|
|
20
|
+
chatType,
|
|
21
|
+
origin: {
|
|
22
|
+
label: displayTo,
|
|
23
|
+
provider: channelId,
|
|
24
|
+
surface: channelId,
|
|
25
|
+
chatType,
|
|
26
|
+
from: senderId,
|
|
27
|
+
to: displayTo,
|
|
28
|
+
accountId,
|
|
29
|
+
},
|
|
30
|
+
deliveryContext: {
|
|
31
|
+
channel: channelId,
|
|
32
|
+
to: displayTo,
|
|
33
|
+
accountId,
|
|
34
|
+
},
|
|
35
|
+
route: {
|
|
36
|
+
channel: channelId,
|
|
37
|
+
accountId,
|
|
38
|
+
target: { to: displayTo },
|
|
39
|
+
},
|
|
40
|
+
lastTo: displayTo,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeNonEmptyString(value: unknown): string | null {
|
|
45
|
+
const normalized = String(value ?? '').trim();
|
|
46
|
+
return normalized || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function correctBncrInboundSessionLabel(args: {
|
|
50
|
+
storePath: string;
|
|
51
|
+
sessionKey: string;
|
|
52
|
+
expectedLabel: string;
|
|
53
|
+
}) {
|
|
54
|
+
const storePath = normalizeNonEmptyString(args.storePath);
|
|
55
|
+
const sessionKey = normalizeNonEmptyString(args.sessionKey);
|
|
56
|
+
const expectedLabel = normalizeNonEmptyString(args.expectedLabel);
|
|
57
|
+
if (!storePath || !sessionKey || !expectedLabel) return;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await updateBncrSessionStoreEntry({
|
|
61
|
+
storePath,
|
|
62
|
+
sessionKey,
|
|
63
|
+
update: (entry: any) => {
|
|
64
|
+
if (entry?.label === expectedLabel) return null;
|
|
65
|
+
return { label: expectedLabel };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
emitBncrLogLine('warn', `[bncr] inbound session label correction failed: ${String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function recordAndPatchBncrInboundSessionEntry(args: {
|
|
74
|
+
storePath: string;
|
|
75
|
+
sessionKey: string;
|
|
76
|
+
ctx?: Record<string, unknown>;
|
|
77
|
+
patch: Record<string, unknown>;
|
|
78
|
+
}) {
|
|
79
|
+
const storePath = normalizeNonEmptyString(args.storePath);
|
|
80
|
+
const sessionKey = normalizeNonEmptyString(args.sessionKey);
|
|
81
|
+
if (!storePath || !sessionKey) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (args.ctx) {
|
|
85
|
+
await recordBncrSessionMetaFromInbound({
|
|
86
|
+
storePath,
|
|
87
|
+
sessionKey,
|
|
88
|
+
ctx: args.ctx as any,
|
|
89
|
+
createIfMissing: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await updateBncrSessionStoreEntry({
|
|
93
|
+
storePath,
|
|
94
|
+
sessionKey,
|
|
95
|
+
update: () => args.patch,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
emitBncrLogLine('warn', `[bncr] inbound session patch failed: ${String(err)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function wrapBncrInboundRecordSessionLabelCorrection(args: {
|
|
103
|
+
recordInboundSession: RecordInboundSessionFn;
|
|
104
|
+
expectedLabel: string;
|
|
105
|
+
}): RecordInboundSessionFn {
|
|
106
|
+
return async (recordArgs: any) => {
|
|
107
|
+
const result = await args.recordInboundSession(recordArgs);
|
|
108
|
+
await correctBncrInboundSessionLabel({
|
|
109
|
+
storePath: recordArgs?.storePath,
|
|
110
|
+
sessionKey: recordArgs?.sessionKey,
|
|
111
|
+
expectedLabel: args.expectedLabel,
|
|
112
|
+
});
|
|
113
|
+
return result;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -119,15 +119,31 @@ export function buildOutboxAckDebugInfo(args: {
|
|
|
119
119
|
connIds?: Iterable<string>;
|
|
120
120
|
ownerConnId?: string;
|
|
121
121
|
ownerClientId?: string;
|
|
122
|
+
sessionKey?: string;
|
|
123
|
+
to?: string;
|
|
124
|
+
ackStage?: string;
|
|
125
|
+
ackOutcome?: string;
|
|
126
|
+
reason?: string;
|
|
122
127
|
kind?: string;
|
|
123
128
|
event?: string;
|
|
129
|
+
ackTimeoutMs?: number;
|
|
130
|
+
adaptiveAckTimeoutEnabled?: boolean;
|
|
124
131
|
}) {
|
|
125
132
|
return {
|
|
126
133
|
messageId: args.messageId,
|
|
127
134
|
accountId: args.accountId,
|
|
135
|
+
...(args.sessionKey ? { sessionKey: args.sessionKey } : {}),
|
|
136
|
+
...(args.to ? { to: args.to } : {}),
|
|
128
137
|
...(args.kind ? { kind: args.kind } : {}),
|
|
129
138
|
requireAck: args.requireAck,
|
|
130
139
|
ackResult: args.ackResult,
|
|
140
|
+
ackStage: args.ackStage || 'message',
|
|
141
|
+
ackOutcome: args.ackOutcome || args.ackResult,
|
|
142
|
+
...(args.reason ? { reason: args.reason } : {}),
|
|
143
|
+
...(typeof args.ackTimeoutMs === 'number' ? { ackTimeoutMs: args.ackTimeoutMs } : {}),
|
|
144
|
+
...(typeof args.adaptiveAckTimeoutEnabled === 'boolean'
|
|
145
|
+
? { adaptiveAckTimeoutEnabled: args.adaptiveAckTimeoutEnabled }
|
|
146
|
+
: {}),
|
|
131
147
|
onlineNow: args.onlineNow,
|
|
132
148
|
recentInboundReachable: args.recentInboundReachable,
|
|
133
149
|
...(args.connIds ? { connIds: Array.from(args.connIds) } : {}),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { defineChannelMessageAdapter } from 'openclaw/plugin-sdk/channel-outbound';
|
|
2
|
+
import type {
|
|
3
|
+
ChannelMessageAdapterShape,
|
|
4
|
+
ChannelMessageSendMediaContext,
|
|
5
|
+
ChannelMessageSendPayloadContext,
|
|
6
|
+
ChannelMessageSendResult,
|
|
7
|
+
ChannelMessageSendTextContext,
|
|
8
|
+
} from 'openclaw/plugin-sdk/channel-outbound';
|
|
9
|
+
|
|
10
|
+
import { buildFileTransferOutboxEntry, buildTextOutboxEntry } from '../../core/outbox-entry-builders.ts';
|
|
11
|
+
import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
|
|
12
|
+
import { buildBncrDurableQueuedResult } from './durable-queue-adapter.ts';
|
|
13
|
+
|
|
14
|
+
export type BncrDurableMessageQueuedAdapterDeps<TConfig = unknown> = {
|
|
15
|
+
enqueueText: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
|
|
16
|
+
enqueueMedia?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
|
|
17
|
+
enqueuePayload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BncrDurableMessageQueuedAdapterBuilderDeps<TConfig = unknown> = {
|
|
22
|
+
createMessageId: () => string;
|
|
23
|
+
now: () => number;
|
|
24
|
+
normalizeAccountId: (accountId?: string | null) => string;
|
|
25
|
+
normalizeReplyToId: (value?: string | null) => string;
|
|
26
|
+
resolveTarget: (ctx: ChannelMessageSendTextContext<TConfig>) => {
|
|
27
|
+
route: BncrRoute;
|
|
28
|
+
sessionKey: string;
|
|
29
|
+
accountId?: string | null;
|
|
30
|
+
};
|
|
31
|
+
filePushEvent?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// This adapter intentionally models only the OpenClaw -> bncr-plugin handoff.
|
|
35
|
+
// Once a message is accepted into bncr's own outbox, OpenClaw should stop managing it;
|
|
36
|
+
// client/platform ACK, retry, and deadLetter remain owned by the bncr service framework.
|
|
37
|
+
export function createBncrDurableMessageQueuedAdapter<TConfig = unknown>(
|
|
38
|
+
deps: BncrDurableMessageQueuedAdapterDeps<TConfig>,
|
|
39
|
+
): ChannelMessageAdapterShape<TConfig, ChannelMessageSendResult> {
|
|
40
|
+
return defineChannelMessageAdapter({
|
|
41
|
+
id: 'bncr-queued-outbox',
|
|
42
|
+
receive: {
|
|
43
|
+
defaultAckPolicy: 'manual',
|
|
44
|
+
supportedAckPolicies: ['manual'],
|
|
45
|
+
},
|
|
46
|
+
send: {
|
|
47
|
+
text: async (ctx) => toChannelMessageSendResult(await deps.enqueueText(ctx), deps.now),
|
|
48
|
+
media: deps.enqueueMedia
|
|
49
|
+
? async (ctx) => toChannelMessageSendResult(await deps.enqueueMedia?.(ctx), deps.now)
|
|
50
|
+
: undefined,
|
|
51
|
+
payload: deps.enqueuePayload
|
|
52
|
+
? async (ctx) => toChannelMessageSendResult(await deps.enqueuePayload?.(ctx), deps.now)
|
|
53
|
+
: undefined,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createBncrDurableMessageQueuedAdapterFromBuilders<TConfig = unknown>(
|
|
59
|
+
deps: BncrDurableMessageQueuedAdapterBuilderDeps<TConfig>,
|
|
60
|
+
): ChannelMessageAdapterShape<TConfig, ChannelMessageSendResult> {
|
|
61
|
+
return createBncrDurableMessageQueuedAdapter<TConfig>({
|
|
62
|
+
now: deps.now,
|
|
63
|
+
enqueueText: (ctx) => {
|
|
64
|
+
const resolved = deps.resolveTarget(ctx);
|
|
65
|
+
return buildTextOutboxEntry({
|
|
66
|
+
createMessageId: deps.createMessageId,
|
|
67
|
+
now: deps.now,
|
|
68
|
+
normalizeAccountId: deps.normalizeAccountId,
|
|
69
|
+
normalizeReplyToId: deps.normalizeReplyToId,
|
|
70
|
+
accountId: resolved.accountId ?? ctx.accountId ?? undefined,
|
|
71
|
+
sessionKey: resolved.sessionKey,
|
|
72
|
+
route: resolved.route,
|
|
73
|
+
text: ctx.text,
|
|
74
|
+
kind: 'final',
|
|
75
|
+
replyToId: ctx.replyToId ?? undefined,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
enqueueMedia: (ctx) => {
|
|
79
|
+
const resolved = deps.resolveTarget(ctx);
|
|
80
|
+
return buildFileTransferOutboxEntry({
|
|
81
|
+
createMessageId: deps.createMessageId,
|
|
82
|
+
now: deps.now,
|
|
83
|
+
normalizeAccountId: deps.normalizeAccountId,
|
|
84
|
+
pushEvent: deps.filePushEvent ?? 'bncr.file.push',
|
|
85
|
+
accountId: resolved.accountId ?? ctx.accountId ?? undefined,
|
|
86
|
+
sessionKey: resolved.sessionKey,
|
|
87
|
+
route: resolved.route,
|
|
88
|
+
mediaUrl: ctx.mediaUrl,
|
|
89
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
90
|
+
text: ctx.text,
|
|
91
|
+
asVoice: ctx.audioAsVoice,
|
|
92
|
+
audioAsVoice: ctx.audioAsVoice,
|
|
93
|
+
kind: 'final',
|
|
94
|
+
replyToId: ctx.replyToId ?? undefined,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toChannelMessageSendResult(entry: OutboxEntry | undefined, now?: () => number): ChannelMessageSendResult {
|
|
101
|
+
if (!entry) throw new Error('bncr durable message adapter did not receive an outbox entry');
|
|
102
|
+
const queued = buildBncrDurableQueuedResult({ entry, sentAt: now?.() });
|
|
103
|
+
return {
|
|
104
|
+
receipt: queued.receipt as any,
|
|
105
|
+
messageId: queued.receipt.primaryPlatformMessageId,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
|
|
2
|
+
import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
|
|
3
|
+
|
|
4
|
+
export type BncrDurableQueuedReceipt = {
|
|
5
|
+
primaryPlatformMessageId: string;
|
|
6
|
+
platformMessageIds: string[];
|
|
7
|
+
parts: Array<{
|
|
8
|
+
platformMessageId: string;
|
|
9
|
+
kind: 'text' | 'media' | 'voice' | 'unknown';
|
|
10
|
+
index: number;
|
|
11
|
+
threadId?: string;
|
|
12
|
+
replyToId?: string;
|
|
13
|
+
raw: {
|
|
14
|
+
channel: 'bncr';
|
|
15
|
+
channelId: 'bncr';
|
|
16
|
+
messageId: string;
|
|
17
|
+
chatId: string;
|
|
18
|
+
conversationId: string;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
meta: BncrDurableQueuedReceiptMeta;
|
|
21
|
+
};
|
|
22
|
+
}>;
|
|
23
|
+
threadId?: string;
|
|
24
|
+
replyToId?: string;
|
|
25
|
+
sentAt: number;
|
|
26
|
+
raw: Array<{
|
|
27
|
+
channel: 'bncr';
|
|
28
|
+
channelId: 'bncr';
|
|
29
|
+
messageId: string;
|
|
30
|
+
chatId: string;
|
|
31
|
+
conversationId: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
meta: BncrDurableQueuedReceiptMeta;
|
|
34
|
+
}>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type BncrDurableQueuedReceiptMeta = {
|
|
38
|
+
status: 'accepted';
|
|
39
|
+
deliveryStage: 'queued';
|
|
40
|
+
queue: 'bncr.outbox';
|
|
41
|
+
finalAckManagedBy: 'bncr-outbox';
|
|
42
|
+
ackSemantics: 'plugin-accepted-not-client-acked';
|
|
43
|
+
accountId: string;
|
|
44
|
+
sessionKey: string;
|
|
45
|
+
route: BncrRoute;
|
|
46
|
+
outboxPayloadType?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type BncrDurableQueuedResult = {
|
|
50
|
+
status: 'sent';
|
|
51
|
+
results: Array<{
|
|
52
|
+
channel: 'bncr';
|
|
53
|
+
channelId: 'bncr';
|
|
54
|
+
messageId: string;
|
|
55
|
+
chatId: string;
|
|
56
|
+
conversationId: string;
|
|
57
|
+
timestamp: number;
|
|
58
|
+
meta: BncrDurableQueuedReceiptMeta;
|
|
59
|
+
}>;
|
|
60
|
+
receipt: BncrDurableQueuedReceipt;
|
|
61
|
+
payloadOutcomes: Array<{
|
|
62
|
+
index: number;
|
|
63
|
+
status: 'sent';
|
|
64
|
+
results: BncrDurableQueuedResult['results'];
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function buildBncrDurableQueuedResult(args: {
|
|
69
|
+
entry: OutboxEntry;
|
|
70
|
+
index?: number;
|
|
71
|
+
threadId?: string;
|
|
72
|
+
replyToId?: string;
|
|
73
|
+
sentAt?: number;
|
|
74
|
+
}): BncrDurableQueuedResult {
|
|
75
|
+
const sentAt = Number.isFinite(args.sentAt) ? Number(args.sentAt) : args.entry.createdAt;
|
|
76
|
+
const platformMessageId = args.entry.messageId;
|
|
77
|
+
const replyToId = normalizeOutboundReplyToId({ replyToId: args.replyToId ?? extractReplyToId(args.entry) }) || undefined;
|
|
78
|
+
const chatId = formatQueuedReceiptChatId(args.entry.route);
|
|
79
|
+
const meta: BncrDurableQueuedReceiptMeta = {
|
|
80
|
+
status: 'accepted',
|
|
81
|
+
deliveryStage: 'queued',
|
|
82
|
+
queue: 'bncr.outbox',
|
|
83
|
+
finalAckManagedBy: 'bncr-outbox',
|
|
84
|
+
ackSemantics: 'plugin-accepted-not-client-acked',
|
|
85
|
+
accountId: args.entry.accountId,
|
|
86
|
+
sessionKey: args.entry.sessionKey,
|
|
87
|
+
route: args.entry.route,
|
|
88
|
+
outboxPayloadType: extractPayloadType(args.entry),
|
|
89
|
+
};
|
|
90
|
+
const result = {
|
|
91
|
+
channel: 'bncr' as const,
|
|
92
|
+
channelId: 'bncr' as const,
|
|
93
|
+
messageId: platformMessageId,
|
|
94
|
+
chatId,
|
|
95
|
+
conversationId: args.entry.sessionKey,
|
|
96
|
+
timestamp: sentAt,
|
|
97
|
+
meta,
|
|
98
|
+
};
|
|
99
|
+
const receipt: BncrDurableQueuedReceipt = {
|
|
100
|
+
primaryPlatformMessageId: platformMessageId,
|
|
101
|
+
platformMessageIds: [platformMessageId],
|
|
102
|
+
parts: [
|
|
103
|
+
{
|
|
104
|
+
platformMessageId,
|
|
105
|
+
kind: inferReceiptKind(args.entry),
|
|
106
|
+
index: args.index ?? 0,
|
|
107
|
+
threadId: args.threadId,
|
|
108
|
+
replyToId,
|
|
109
|
+
raw: result,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
threadId: args.threadId,
|
|
113
|
+
replyToId,
|
|
114
|
+
sentAt,
|
|
115
|
+
raw: [result],
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
status: 'sent',
|
|
119
|
+
results: [result],
|
|
120
|
+
receipt,
|
|
121
|
+
payloadOutcomes: [
|
|
122
|
+
{
|
|
123
|
+
index: args.index ?? 0,
|
|
124
|
+
status: 'sent',
|
|
125
|
+
results: [result],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractPayloadType(entry: OutboxEntry): string | undefined {
|
|
132
|
+
const payload = entry.payload as any;
|
|
133
|
+
return typeof payload?.type === 'string' ? payload.type : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractReplyToId(entry: OutboxEntry): string | undefined {
|
|
137
|
+
const payload = entry.payload as any;
|
|
138
|
+
const metaReply = payload?._meta?.replyToId;
|
|
139
|
+
const replyToId = payload?.replyToId ?? metaReply;
|
|
140
|
+
return typeof replyToId === 'string' ? replyToId : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function inferReceiptKind(entry: OutboxEntry): 'text' | 'media' | 'voice' | 'unknown' {
|
|
144
|
+
const payload = entry.payload as any;
|
|
145
|
+
if (payload?._meta?.kind === 'file-transfer') {
|
|
146
|
+
if (payload?._meta?.asVoice === true || payload?._meta?.audioAsVoice === true) return 'voice';
|
|
147
|
+
return 'media';
|
|
148
|
+
}
|
|
149
|
+
if (payload?.message?.type === 'text') return 'text';
|
|
150
|
+
return 'unknown';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatQueuedReceiptChatId(route: BncrRoute): string {
|
|
154
|
+
const platform = route.platform || 'unknown';
|
|
155
|
+
if (route.groupId) return `Bncr:${platform}:${route.groupId}:${route.userId}`;
|
|
156
|
+
return `Bncr:${platform}:${route.userId}`;
|
|
157
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
|
|
2
|
+
|
|
1
3
|
function asString(v: unknown, fallback = ''): string {
|
|
2
4
|
if (typeof v === 'string') return v;
|
|
3
5
|
if (v == null) return fallback;
|
|
@@ -64,7 +66,7 @@ export function buildBncrMediaOutboundFrame(params: {
|
|
|
64
66
|
messageId: params.messageId,
|
|
65
67
|
idempotencyKey: params.messageId,
|
|
66
68
|
sessionKey: params.sessionKey,
|
|
67
|
-
replyToId:
|
|
69
|
+
replyToId: normalizeOutboundReplyToId({ kind: params.kind, replyToId: params.replyToId }) || undefined,
|
|
68
70
|
message: {
|
|
69
71
|
platform: params.route.platform,
|
|
70
72
|
groupId: params.route.groupId,
|
|
@@ -25,8 +25,13 @@ export type OutboxFileTransferRouteSelection = {
|
|
|
25
25
|
ownerConnId?: string;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
function finiteNumberOr(value: unknown, fallback: number): number {
|
|
29
|
+
const n = Number(value);
|
|
30
|
+
return Number.isFinite(n) ? n : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
export function computeOutboxRetryWait(nextAttemptAt: number, nowMs: number): number {
|
|
29
|
-
return Math.max(0,
|
|
34
|
+
return Math.max(0, finiteNumberOr(nextAttemptAt, 0) - finiteNumberOr(nowMs, 0));
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay: number | null): number | null {
|
|
@@ -35,7 +40,7 @@ export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export function clampOutboxDrainDelay(delayMs: number): number {
|
|
38
|
-
return Math.max(0, Math.min(
|
|
43
|
+
return Math.max(0, Math.min(finiteNumberOr(delayMs, 0), 30_000));
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function selectOutboxTargetAccounts(args: {
|
|
@@ -38,6 +38,10 @@ export const OUTBOUND_SCHEDULE_SOURCE = {
|
|
|
38
38
|
RETRY_REROUTE_WAIT: 'retry-reroute-wait',
|
|
39
39
|
// Direct push failure kept entry in outbox and scheduled backoff.
|
|
40
40
|
PUSH_FAIL_WAIT: 'push-fail-wait',
|
|
41
|
+
// Per-account flush processed its single-run item budget and yielded to the next drain.
|
|
42
|
+
ACCOUNT_BUDGET_YIELD: 'account-budget-yield',
|
|
43
|
+
// Per-account flush spent its single-run time budget and yielded to the next drain.
|
|
44
|
+
ACCOUNT_TIME_BUDGET_YIELD: 'account-time-budget-yield',
|
|
41
45
|
// Account-local next delay was merged into bridge-global next delay.
|
|
42
46
|
ACCOUNT_NEXT_DELAY_MERGE: 'account-next-delay-merge',
|
|
43
47
|
// flushPushQueue(...) finished and armed the next bridge-level drain.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
|
|
2
2
|
import { buildReplyMediaFallbackDebugInfo } from './diagnostics.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
|
|
4
4
|
|
|
5
5
|
export type ReplyPayloadInput = {
|
|
6
6
|
text?: string;
|
|
@@ -324,6 +324,6 @@ export function normalizeReplyPayload(
|
|
|
324
324
|
asVoice: payload?.asVoice === true,
|
|
325
325
|
audioAsVoice: payload?.audioAsVoice === true,
|
|
326
326
|
kind: payload?.kind,
|
|
327
|
-
replyToId:
|
|
327
|
+
replyToId: normalizeOutboundReplyToId({ kind: payload?.kind, replyToId: payload?.replyToId }),
|
|
328
328
|
};
|
|
329
329
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { normalizeReplyToId } from './media-dedupe.ts';
|
|
2
|
+
|
|
3
|
+
export type OutboundReplyKind = 'tool' | 'block' | 'final';
|
|
4
|
+
|
|
5
|
+
const STRIP_TOOL_REPLY_TO_ID = true;
|
|
6
|
+
|
|
7
|
+
export function normalizeOutboundReplyToId(params: {
|
|
8
|
+
kind?: OutboundReplyKind;
|
|
9
|
+
replyToId?: string | null;
|
|
10
|
+
}) {
|
|
11
|
+
if (params.kind === 'tool' && STRIP_TOOL_REPLY_TO_ID) return '';
|
|
12
|
+
return normalizeReplyToId(params.replyToId);
|
|
13
|
+
}
|