@xmoxmo/bncr 0.0.4 → 0.0.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/LICENSE +21 -0
- package/README.md +97 -27
- package/index.ts +2 -2
- package/openclaw.plugin.json +33 -2
- package/package.json +7 -2
- package/scripts/selfcheck.mjs +38 -0
- package/src/channel.ts +226 -734
- package/src/core/accounts.ts +50 -0
- package/src/core/config-schema.ts +42 -0
- package/src/core/permissions.ts +29 -0
- package/src/core/policy.ts +27 -0
- package/src/core/probe.ts +45 -0
- package/src/core/status.ts +145 -0
- package/src/core/targets.ts +243 -0
- package/src/core/types.ts +59 -0
- package/src/messaging/inbound/commands.ts +136 -0
- package/src/messaging/inbound/dispatch.ts +178 -0
- package/src/messaging/inbound/gate.ts +66 -0
- package/src/messaging/inbound/parse.ts +97 -0
- package/src/messaging/outbound/actions.ts +42 -0
- package/src/messaging/outbound/media.ts +53 -0
- package/src/messaging/outbound/send.ts +67 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { formatDisplayScope, normalizeInboundSessionKey, withTaskSessionKey } from '../../core/targets.js';
|
|
2
|
+
|
|
3
|
+
type ParsedInbound = ReturnType<typeof import('./parse.js')['parseBncrInboundParams']>;
|
|
4
|
+
|
|
5
|
+
type NativeCommand = {
|
|
6
|
+
command: string;
|
|
7
|
+
raw: string;
|
|
8
|
+
body: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function parseBncrNativeCommand(text: string): NativeCommand | null {
|
|
12
|
+
const raw = String(text || '').trim();
|
|
13
|
+
if (!raw.startsWith('/')) return null;
|
|
14
|
+
const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/i);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
|
|
17
|
+
const command = String(match[1] || '').trim().toLowerCase();
|
|
18
|
+
if (!command) return null;
|
|
19
|
+
|
|
20
|
+
const rest = String(match[2] || '').trim();
|
|
21
|
+
const body = command === 'help'
|
|
22
|
+
? ['/commands', rest].filter(Boolean).join(' ')
|
|
23
|
+
: raw;
|
|
24
|
+
return { command, raw, body };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function handleBncrNativeCommand(params: {
|
|
28
|
+
api: any;
|
|
29
|
+
channelId: string;
|
|
30
|
+
cfg: any;
|
|
31
|
+
parsed: ParsedInbound;
|
|
32
|
+
rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
|
|
33
|
+
enqueueFromReply: (args: {
|
|
34
|
+
accountId: string;
|
|
35
|
+
sessionKey: string;
|
|
36
|
+
route: any;
|
|
37
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
38
|
+
mediaLocalRoots?: readonly string[];
|
|
39
|
+
}) => Promise<void>;
|
|
40
|
+
logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
|
|
41
|
+
}): Promise<{ handled: false } | { handled: true; command: string; sessionKey: string }> {
|
|
42
|
+
const { api, channelId, cfg, parsed, rememberSessionRoute, enqueueFromReply, logger } = params;
|
|
43
|
+
const { accountId, route, peer, sessionKeyfromroute, clientId, extracted, msgId } = parsed;
|
|
44
|
+
const command = parseBncrNativeCommand(extracted.text);
|
|
45
|
+
if (!command) return { handled: false };
|
|
46
|
+
|
|
47
|
+
const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
|
|
48
|
+
cfg,
|
|
49
|
+
channel: channelId,
|
|
50
|
+
accountId,
|
|
51
|
+
peer,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route) || resolvedRoute.sessionKey;
|
|
55
|
+
const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
|
|
56
|
+
const sessionKey = taskSessionKey || baseSessionKey;
|
|
57
|
+
rememberSessionRoute(baseSessionKey, accountId, route);
|
|
58
|
+
if (taskSessionKey && taskSessionKey !== baseSessionKey) rememberSessionRoute(taskSessionKey, accountId, route);
|
|
59
|
+
|
|
60
|
+
const displayTo = formatDisplayScope(route);
|
|
61
|
+
const body = command.body;
|
|
62
|
+
if (!clientId) {
|
|
63
|
+
logger?.warn?.('bncr: missing clientId for inbound command identity');
|
|
64
|
+
return { handled: false };
|
|
65
|
+
}
|
|
66
|
+
const senderIdForContext = clientId;
|
|
67
|
+
const senderDisplayName = 'bncr-client';
|
|
68
|
+
const storePath = api.runtime.channel.session.resolveStorePath(cfg?.session?.store, {
|
|
69
|
+
agentId: resolvedRoute.agentId,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
|
|
73
|
+
Body: body,
|
|
74
|
+
BodyForAgent: body,
|
|
75
|
+
RawBody: body,
|
|
76
|
+
CommandBody: body,
|
|
77
|
+
BodyForCommands: body,
|
|
78
|
+
From: senderIdForContext,
|
|
79
|
+
To: displayTo,
|
|
80
|
+
SessionKey: sessionKey,
|
|
81
|
+
CommandTargetSessionKey: sessionKey,
|
|
82
|
+
CommandSource: 'native',
|
|
83
|
+
CommandAuthorized: true,
|
|
84
|
+
AccountId: accountId,
|
|
85
|
+
ChatType: peer.kind,
|
|
86
|
+
ConversationLabel: displayTo,
|
|
87
|
+
SenderId: senderIdForContext,
|
|
88
|
+
SenderName: senderDisplayName,
|
|
89
|
+
SenderUsername: senderDisplayName,
|
|
90
|
+
Provider: channelId,
|
|
91
|
+
Surface: channelId,
|
|
92
|
+
WasMentioned: true,
|
|
93
|
+
MessageSid: msgId,
|
|
94
|
+
Timestamp: Date.now(),
|
|
95
|
+
OriginatingChannel: channelId,
|
|
96
|
+
OriginatingTo: displayTo,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await api.runtime.channel.session.recordInboundSession({
|
|
100
|
+
storePath,
|
|
101
|
+
sessionKey,
|
|
102
|
+
ctx: ctxPayload,
|
|
103
|
+
onRecordError: (err: unknown) => {
|
|
104
|
+
logger?.warn?.(`bncr: record native command session failed: ${String(err)}`);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let responded = false;
|
|
109
|
+
await api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
110
|
+
ctx: ctxPayload,
|
|
111
|
+
cfg,
|
|
112
|
+
replyOptions: {
|
|
113
|
+
disableBlockStreaming: true,
|
|
114
|
+
},
|
|
115
|
+
dispatcherOptions: {
|
|
116
|
+
deliver: async (payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] }, info?: { kind?: 'tool' | 'block' | 'final' }) => {
|
|
117
|
+
if (info?.kind && info.kind !== 'final') return;
|
|
118
|
+
const hasPayload = Boolean(payload?.text || payload?.mediaUrl || (Array.isArray(payload?.mediaUrls) && payload.mediaUrls.length > 0));
|
|
119
|
+
if (!hasPayload) return;
|
|
120
|
+
responded = true;
|
|
121
|
+
await enqueueFromReply({
|
|
122
|
+
accountId,
|
|
123
|
+
sessionKey,
|
|
124
|
+
route,
|
|
125
|
+
payload,
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!responded) {
|
|
132
|
+
return { handled: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { handled: true, command: command.command, sessionKey };
|
|
136
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { formatDisplayScope, normalizeInboundSessionKey, withTaskSessionKey } from '../../core/targets.js';
|
|
3
|
+
import { handleBncrNativeCommand } from './commands.js';
|
|
4
|
+
|
|
5
|
+
type ParsedInbound = ReturnType<typeof import('./parse.js')['parseBncrInboundParams']>;
|
|
6
|
+
|
|
7
|
+
export async function dispatchBncrInbound(params: {
|
|
8
|
+
api: any;
|
|
9
|
+
channelId: string;
|
|
10
|
+
cfg: any;
|
|
11
|
+
parsed: ParsedInbound;
|
|
12
|
+
rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
|
|
13
|
+
enqueueFromReply: (args: {
|
|
14
|
+
accountId: string;
|
|
15
|
+
sessionKey: string;
|
|
16
|
+
route: any;
|
|
17
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
18
|
+
mediaLocalRoots?: readonly string[];
|
|
19
|
+
}) => Promise<void>;
|
|
20
|
+
setInboundActivity: (accountId: string, at: number) => void;
|
|
21
|
+
scheduleSave: () => void;
|
|
22
|
+
logger?: { warn?: (msg: string) => void; error?: (msg: string) => void };
|
|
23
|
+
}) {
|
|
24
|
+
const { api, channelId, cfg, parsed, rememberSessionRoute, enqueueFromReply, setInboundActivity, scheduleSave, logger } = params;
|
|
25
|
+
const { accountId, route, peer, sessionKeyfromroute, clientId, text, msgType, mediaBase64, mediaPathFromTransfer, mimeType, fileName, msgId, extracted, platform, groupId, userId } = parsed;
|
|
26
|
+
|
|
27
|
+
const nativeCommand = await handleBncrNativeCommand({
|
|
28
|
+
api,
|
|
29
|
+
channelId,
|
|
30
|
+
cfg,
|
|
31
|
+
parsed,
|
|
32
|
+
rememberSessionRoute,
|
|
33
|
+
enqueueFromReply,
|
|
34
|
+
logger,
|
|
35
|
+
});
|
|
36
|
+
if (nativeCommand.handled) {
|
|
37
|
+
const inboundAt = Date.now();
|
|
38
|
+
setInboundActivity(accountId, inboundAt);
|
|
39
|
+
scheduleSave();
|
|
40
|
+
return {
|
|
41
|
+
accountId,
|
|
42
|
+
sessionKey: nativeCommand.sessionKey,
|
|
43
|
+
taskKey: extracted.taskKey ?? null,
|
|
44
|
+
msgId: msgId ?? null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
|
|
49
|
+
cfg,
|
|
50
|
+
channel: channelId,
|
|
51
|
+
accountId,
|
|
52
|
+
peer,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route) || resolvedRoute.sessionKey;
|
|
56
|
+
const agentText = extracted.text;
|
|
57
|
+
const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
|
|
58
|
+
const sessionKey = taskSessionKey || baseSessionKey;
|
|
59
|
+
|
|
60
|
+
rememberSessionRoute(baseSessionKey, accountId, route);
|
|
61
|
+
if (taskSessionKey && taskSessionKey !== baseSessionKey) {
|
|
62
|
+
rememberSessionRoute(taskSessionKey, accountId, route);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const storePath = api.runtime.channel.session.resolveStorePath(cfg?.session?.store, {
|
|
66
|
+
agentId: resolvedRoute.agentId,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let mediaPath: string | undefined;
|
|
70
|
+
if (mediaBase64) {
|
|
71
|
+
const mediaBuf = Buffer.from(mediaBase64, 'base64');
|
|
72
|
+
const saved = await api.runtime.channel.media.saveMediaBuffer(
|
|
73
|
+
mediaBuf,
|
|
74
|
+
mimeType,
|
|
75
|
+
'inbound',
|
|
76
|
+
30 * 1024 * 1024,
|
|
77
|
+
fileName,
|
|
78
|
+
);
|
|
79
|
+
mediaPath = saved.path;
|
|
80
|
+
} else if (mediaPathFromTransfer && fs.existsSync(mediaPathFromTransfer)) {
|
|
81
|
+
mediaPath = mediaPathFromTransfer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
|
|
85
|
+
const body = api.runtime.channel.reply.formatAgentEnvelope({
|
|
86
|
+
channel: 'Bncr',
|
|
87
|
+
from: `${platform}:${groupId}:${userId}`,
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
previousTimestamp: api.runtime.channel.session.readSessionUpdatedAt({
|
|
90
|
+
storePath,
|
|
91
|
+
sessionKey,
|
|
92
|
+
}),
|
|
93
|
+
envelope: api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
94
|
+
body: rawBody,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const displayTo = formatDisplayScope(route);
|
|
98
|
+
if (!clientId) {
|
|
99
|
+
logger?.warn?.('bncr: missing clientId for inbound chat identity');
|
|
100
|
+
return {
|
|
101
|
+
accountId,
|
|
102
|
+
sessionKey,
|
|
103
|
+
taskKey: extracted.taskKey ?? null,
|
|
104
|
+
msgId: msgId ?? null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const senderIdForContext = clientId;
|
|
108
|
+
const senderDisplayName = 'bncr-client';
|
|
109
|
+
const ctxPayload = api.runtime.channel.reply.finalizeInboundContext({
|
|
110
|
+
Body: body,
|
|
111
|
+
BodyForAgent: rawBody,
|
|
112
|
+
RawBody: rawBody,
|
|
113
|
+
CommandBody: rawBody,
|
|
114
|
+
MediaPath: mediaPath,
|
|
115
|
+
MediaType: mimeType,
|
|
116
|
+
From: senderIdForContext,
|
|
117
|
+
To: displayTo,
|
|
118
|
+
SessionKey: sessionKey,
|
|
119
|
+
AccountId: accountId,
|
|
120
|
+
ChatType: peer.kind,
|
|
121
|
+
ConversationLabel: displayTo,
|
|
122
|
+
SenderId: senderIdForContext,
|
|
123
|
+
SenderName: senderDisplayName,
|
|
124
|
+
SenderUsername: senderDisplayName,
|
|
125
|
+
Provider: channelId,
|
|
126
|
+
Surface: channelId,
|
|
127
|
+
MessageSid: msgId,
|
|
128
|
+
Timestamp: Date.now(),
|
|
129
|
+
OriginatingChannel: channelId,
|
|
130
|
+
OriginatingTo: displayTo,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await api.runtime.channel.session.recordInboundSession({
|
|
134
|
+
storePath,
|
|
135
|
+
sessionKey,
|
|
136
|
+
ctx: ctxPayload,
|
|
137
|
+
onRecordError: (err: unknown) => {
|
|
138
|
+
logger?.warn?.(`bncr: record session failed: ${String(err)}`);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const inboundAt = Date.now();
|
|
143
|
+
setInboundActivity(accountId, inboundAt);
|
|
144
|
+
scheduleSave();
|
|
145
|
+
|
|
146
|
+
await api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
147
|
+
ctx: ctxPayload,
|
|
148
|
+
cfg,
|
|
149
|
+
replyOptions: {
|
|
150
|
+
disableBlockStreaming: true,
|
|
151
|
+
},
|
|
152
|
+
dispatcherOptions: {
|
|
153
|
+
deliver: async (
|
|
154
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] },
|
|
155
|
+
info?: { kind?: 'tool' | 'block' | 'final' },
|
|
156
|
+
) => {
|
|
157
|
+
if (info?.kind && info.kind !== 'final') return;
|
|
158
|
+
|
|
159
|
+
await enqueueFromReply({
|
|
160
|
+
accountId,
|
|
161
|
+
sessionKey,
|
|
162
|
+
route,
|
|
163
|
+
payload,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
onError: (err: unknown) => {
|
|
167
|
+
logger?.error?.(`bncr reply failed: ${String(err)}`);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
accountId,
|
|
174
|
+
sessionKey,
|
|
175
|
+
taskKey: extracted.taskKey ?? null,
|
|
176
|
+
msgId: msgId ?? null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { normalizeAccountId } from '../../core/accounts.js';
|
|
2
|
+
import { resolveBncrChannelPolicy } from '../../core/policy.js';
|
|
3
|
+
import { buildDisplayScopeCandidates } from '../../core/targets.js';
|
|
4
|
+
|
|
5
|
+
export type BncrGateResult =
|
|
6
|
+
| { allowed: true }
|
|
7
|
+
| { allowed: false; reason: string };
|
|
8
|
+
|
|
9
|
+
function asString(v: unknown, fallback = ''): string {
|
|
10
|
+
if (typeof v === 'string') return v;
|
|
11
|
+
if (v == null) return fallback;
|
|
12
|
+
return String(v);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function matchesAllowList(list: string[], candidates: string[]): boolean {
|
|
16
|
+
if (!list.length) return false;
|
|
17
|
+
const normalized = new Set(list.map((x) => asString(x).trim()).filter(Boolean));
|
|
18
|
+
return candidates.some((x) => normalized.has(asString(x).trim()));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkBncrMessageGate(params: {
|
|
22
|
+
parsed: any;
|
|
23
|
+
cfg: any;
|
|
24
|
+
account: { accountId: string; enabled?: boolean };
|
|
25
|
+
}): BncrGateResult {
|
|
26
|
+
const { parsed, cfg, account } = params;
|
|
27
|
+
const accountId = normalizeAccountId(account?.accountId);
|
|
28
|
+
const channelCfg = cfg?.channels?.bncr || {};
|
|
29
|
+
const accountCfg = channelCfg?.accounts?.[accountId] || {};
|
|
30
|
+
const policy = resolveBncrChannelPolicy(channelCfg);
|
|
31
|
+
|
|
32
|
+
if (policy.enabled === false || account?.enabled === false || accountCfg?.enabled === false) {
|
|
33
|
+
return { allowed: false, reason: 'account disabled' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const route = parsed?.route;
|
|
37
|
+
const isGroup = asString(route?.groupId || '0') !== '0';
|
|
38
|
+
|
|
39
|
+
if (!isGroup && policy.dmPolicy === 'disabled') {
|
|
40
|
+
return { allowed: false, reason: 'dm disabled' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isGroup && policy.groupPolicy === 'disabled') {
|
|
44
|
+
return { allowed: false, reason: 'group disabled' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const candidates = buildDisplayScopeCandidates(route);
|
|
48
|
+
|
|
49
|
+
if (!isGroup && policy.dmPolicy === 'allowlist') {
|
|
50
|
+
if (!matchesAllowList(policy.allowFrom, candidates)) {
|
|
51
|
+
return { allowed: false, reason: 'dm allowlist blocked' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isGroup && policy.groupPolicy === 'allowlist') {
|
|
56
|
+
if (!matchesAllowList(policy.groupAllowFrom, candidates)) {
|
|
57
|
+
return { allowed: false, reason: 'group allowlist blocked' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// requireMention 默认值为 false。
|
|
62
|
+
// 设计目标:当它未来真正生效时,含义是“群消息只有在明确提到机器人时才允许进入处理链”。
|
|
63
|
+
// 但当前 parse 层尚未稳定提取 mentions,上游客户端也未统一透传 mention 信号,
|
|
64
|
+
// 因此现阶段即使配置为 true,也仍不做实际拦截,避免出现半实现状态。
|
|
65
|
+
return { allowed: true };
|
|
66
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { normalizeAccountId } from '../../core/accounts.js';
|
|
3
|
+
import type { BncrRoute } from '../../core/types.js';
|
|
4
|
+
import { extractInlineTaskKey } from '../../core/targets.js';
|
|
5
|
+
|
|
6
|
+
function asString(v: unknown, fallback = ''): string {
|
|
7
|
+
if (typeof v === 'string') return v;
|
|
8
|
+
if (v == null) return fallback;
|
|
9
|
+
return String(v);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function inboundDedupKey(params: {
|
|
13
|
+
accountId: string;
|
|
14
|
+
platform: string;
|
|
15
|
+
groupId: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
msgId?: string;
|
|
18
|
+
text?: string;
|
|
19
|
+
mediaBase64?: string;
|
|
20
|
+
}): string {
|
|
21
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
22
|
+
const platform = asString(params.platform).trim().toLowerCase();
|
|
23
|
+
const groupId = asString(params.groupId).trim();
|
|
24
|
+
const userId = asString(params.userId).trim();
|
|
25
|
+
const msgId = asString(params.msgId || '').trim();
|
|
26
|
+
|
|
27
|
+
if (msgId) return `${accountId}|${platform}|${groupId}|${userId}|msg:${msgId}`;
|
|
28
|
+
|
|
29
|
+
const text = asString(params.text || '').trim();
|
|
30
|
+
const media = asString(params.mediaBase64 || '');
|
|
31
|
+
const digest = createHash('sha1').update(`${text}\n${media.slice(0, 256)}`).digest('hex').slice(0, 16);
|
|
32
|
+
return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
|
|
36
|
+
return 'direct';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseBncrInboundParams(params: any) {
|
|
40
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
41
|
+
const platform = asString(params?.platform || '').trim();
|
|
42
|
+
const groupId = asString(params?.groupId || '0').trim() || '0';
|
|
43
|
+
const userId = asString(params?.userId || '').trim();
|
|
44
|
+
const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
|
|
45
|
+
const clientId = asString(params?.clientId || '').trim() || undefined;
|
|
46
|
+
|
|
47
|
+
const route: BncrRoute = {
|
|
48
|
+
platform,
|
|
49
|
+
groupId,
|
|
50
|
+
userId,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const text = asString(params?.msg || '');
|
|
54
|
+
const msgType = asString(params?.type || 'text') || 'text';
|
|
55
|
+
const mediaBase64 = asString(params?.base64 || '');
|
|
56
|
+
const mediaPathFromTransfer = asString(params?.path || '').trim();
|
|
57
|
+
const mimeType = asString(params?.mimeType || '').trim() || undefined;
|
|
58
|
+
const fileName = asString(params?.fileName || '').trim() || undefined;
|
|
59
|
+
const msgId = asString(params?.msgId || '').trim() || undefined;
|
|
60
|
+
|
|
61
|
+
const dedupKey = inboundDedupKey({
|
|
62
|
+
accountId,
|
|
63
|
+
platform,
|
|
64
|
+
groupId,
|
|
65
|
+
userId,
|
|
66
|
+
msgId,
|
|
67
|
+
text,
|
|
68
|
+
mediaBase64,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const peer = {
|
|
72
|
+
kind: resolveChatType(route),
|
|
73
|
+
id: route.groupId === '0' ? route.userId : route.groupId,
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
const extracted = extractInlineTaskKey(text);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
accountId,
|
|
80
|
+
platform,
|
|
81
|
+
groupId,
|
|
82
|
+
userId,
|
|
83
|
+
sessionKeyfromroute,
|
|
84
|
+
clientId,
|
|
85
|
+
route,
|
|
86
|
+
text,
|
|
87
|
+
msgType,
|
|
88
|
+
mediaBase64,
|
|
89
|
+
mediaPathFromTransfer,
|
|
90
|
+
mimeType,
|
|
91
|
+
fileName,
|
|
92
|
+
msgId,
|
|
93
|
+
dedupKey,
|
|
94
|
+
peer,
|
|
95
|
+
extracted,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export async function sendBncrReplyAction(params: {
|
|
2
|
+
accountId: string;
|
|
3
|
+
to: string;
|
|
4
|
+
text: string;
|
|
5
|
+
replyToMessageId?: string;
|
|
6
|
+
sendText: (params: {
|
|
7
|
+
accountId: string;
|
|
8
|
+
to: string;
|
|
9
|
+
text: string;
|
|
10
|
+
replyToMessageId?: string;
|
|
11
|
+
}) => Promise<any>;
|
|
12
|
+
}) {
|
|
13
|
+
return params.sendText({
|
|
14
|
+
accountId: params.accountId,
|
|
15
|
+
to: params.to,
|
|
16
|
+
text: params.text,
|
|
17
|
+
replyToMessageId: params.replyToMessageId,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function deleteBncrMessageAction(_params: {
|
|
22
|
+
accountId: string;
|
|
23
|
+
targetMessageId: string;
|
|
24
|
+
}) {
|
|
25
|
+
return { ok: false, unsupported: true, reason: 'delete not implemented yet' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function reactBncrMessageAction(_params: {
|
|
29
|
+
accountId: string;
|
|
30
|
+
targetMessageId: string;
|
|
31
|
+
emoji: string;
|
|
32
|
+
}) {
|
|
33
|
+
return { ok: false, unsupported: true, reason: 'react not implemented yet' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function editBncrMessageAction(_params: {
|
|
37
|
+
accountId: string;
|
|
38
|
+
targetMessageId: string;
|
|
39
|
+
text: string;
|
|
40
|
+
}) {
|
|
41
|
+
return { ok: false, unsupported: true, reason: 'edit not implemented yet' };
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function asString(v: unknown, fallback = ''): string {
|
|
2
|
+
if (typeof v === 'string') return v;
|
|
3
|
+
if (v == null) return fallback;
|
|
4
|
+
return String(v);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string; hasPayload?: boolean }): 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file' {
|
|
8
|
+
const hinted = asString(params.hintedType || '').toLowerCase();
|
|
9
|
+
const hasPayload = !!params.hasPayload;
|
|
10
|
+
const mt = asString(params.mimeType || '').toLowerCase();
|
|
11
|
+
const major = mt.split('/')[0] || '';
|
|
12
|
+
const isStandard = hinted === 'text' || hinted === 'image' || hinted === 'video' || hinted === 'voice' || hinted === 'audio' || hinted === 'file';
|
|
13
|
+
|
|
14
|
+
if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
|
|
15
|
+
if (isStandard) return hinted as any;
|
|
16
|
+
if (major === 'text' || major === 'image' || major === 'video' || major === 'audio') return major as any;
|
|
17
|
+
return 'file';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildBncrMediaOutboundFrame(params: {
|
|
21
|
+
messageId: string;
|
|
22
|
+
sessionKey: string;
|
|
23
|
+
route: { platform: string; groupId: string; userId: string };
|
|
24
|
+
media: { mode: 'base64' | 'chunk'; mimeType?: string; fileName?: string; mediaBase64?: string; path?: string };
|
|
25
|
+
mediaUrl: string;
|
|
26
|
+
mediaMsg: string;
|
|
27
|
+
fileName: string;
|
|
28
|
+
now: number;
|
|
29
|
+
}) {
|
|
30
|
+
return {
|
|
31
|
+
type: 'message.outbound',
|
|
32
|
+
messageId: params.messageId,
|
|
33
|
+
idempotencyKey: params.messageId,
|
|
34
|
+
sessionKey: params.sessionKey,
|
|
35
|
+
message: {
|
|
36
|
+
platform: params.route.platform,
|
|
37
|
+
groupId: params.route.groupId,
|
|
38
|
+
userId: params.route.userId,
|
|
39
|
+
type: resolveBncrOutboundMessageType({
|
|
40
|
+
mimeType: params.media.mimeType,
|
|
41
|
+
fileName: params.media.fileName,
|
|
42
|
+
hasPayload: !!(params.media.path || params.media.mediaBase64),
|
|
43
|
+
}),
|
|
44
|
+
mimeType: params.media.mimeType || '',
|
|
45
|
+
msg: params.mediaMsg,
|
|
46
|
+
path: params.media.path || params.mediaUrl,
|
|
47
|
+
base64: params.media.mediaBase64 || '',
|
|
48
|
+
fileName: params.fileName,
|
|
49
|
+
transferMode: params.media.mode,
|
|
50
|
+
},
|
|
51
|
+
ts: params.now,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export async function sendBncrText(params: {
|
|
2
|
+
channelId: string;
|
|
3
|
+
accountId: string;
|
|
4
|
+
to: string;
|
|
5
|
+
text: string;
|
|
6
|
+
mediaLocalRoots?: readonly string[];
|
|
7
|
+
resolveVerifiedTarget: (to: string, accountId: string) => { sessionKey: string; route: any; displayScope: string };
|
|
8
|
+
rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
|
|
9
|
+
enqueueFromReply: (args: {
|
|
10
|
+
accountId: string;
|
|
11
|
+
sessionKey: string;
|
|
12
|
+
route: any;
|
|
13
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
14
|
+
mediaLocalRoots?: readonly string[];
|
|
15
|
+
}) => Promise<void>;
|
|
16
|
+
createMessageId: () => string;
|
|
17
|
+
}) {
|
|
18
|
+
const verified = params.resolveVerifiedTarget(params.to, params.accountId);
|
|
19
|
+
params.rememberSessionRoute(verified.sessionKey, params.accountId, verified.route);
|
|
20
|
+
|
|
21
|
+
await params.enqueueFromReply({
|
|
22
|
+
accountId: params.accountId,
|
|
23
|
+
sessionKey: verified.sessionKey,
|
|
24
|
+
route: verified.route,
|
|
25
|
+
payload: {
|
|
26
|
+
text: params.text,
|
|
27
|
+
},
|
|
28
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { channel: params.channelId, messageId: params.createMessageId(), chatId: verified.sessionKey };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function sendBncrMedia(params: {
|
|
35
|
+
channelId: string;
|
|
36
|
+
accountId: string;
|
|
37
|
+
to: string;
|
|
38
|
+
text?: string;
|
|
39
|
+
mediaUrl?: string;
|
|
40
|
+
mediaLocalRoots?: readonly string[];
|
|
41
|
+
resolveVerifiedTarget: (to: string, accountId: string) => { sessionKey: string; route: any; displayScope: string };
|
|
42
|
+
rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
|
|
43
|
+
enqueueFromReply: (args: {
|
|
44
|
+
accountId: string;
|
|
45
|
+
sessionKey: string;
|
|
46
|
+
route: any;
|
|
47
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
48
|
+
mediaLocalRoots?: readonly string[];
|
|
49
|
+
}) => Promise<void>;
|
|
50
|
+
createMessageId: () => string;
|
|
51
|
+
}) {
|
|
52
|
+
const verified = params.resolveVerifiedTarget(params.to, params.accountId);
|
|
53
|
+
params.rememberSessionRoute(verified.sessionKey, params.accountId, verified.route);
|
|
54
|
+
|
|
55
|
+
await params.enqueueFromReply({
|
|
56
|
+
accountId: params.accountId,
|
|
57
|
+
sessionKey: verified.sessionKey,
|
|
58
|
+
route: verified.route,
|
|
59
|
+
payload: {
|
|
60
|
+
text: params.text || '',
|
|
61
|
+
mediaUrl: params.mediaUrl || '',
|
|
62
|
+
},
|
|
63
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { channel: params.channelId, messageId: params.createMessageId(), chatId: verified.sessionKey };
|
|
67
|
+
}
|