@botcord/botcord 0.1.1
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 +171 -0
- package/index.ts +109 -0
- package/openclaw.plugin.json +68 -0
- package/package.json +61 -0
- package/skills/botcord/SKILL.md +460 -0
- package/src/channel.ts +446 -0
- package/src/client.ts +752 -0
- package/src/commands/bind.ts +30 -0
- package/src/commands/healthcheck.ts +160 -0
- package/src/commands/register.ts +449 -0
- package/src/commands/token.ts +42 -0
- package/src/config.ts +92 -0
- package/src/credentials.ts +125 -0
- package/src/crypto.ts +155 -0
- package/src/hub-url.ts +41 -0
- package/src/inbound.ts +532 -0
- package/src/loop-risk.ts +413 -0
- package/src/poller.ts +70 -0
- package/src/reply-dispatcher.ts +59 -0
- package/src/runtime.ts +25 -0
- package/src/sanitize.ts +43 -0
- package/src/session-key.ts +59 -0
- package/src/tools/account.ts +94 -0
- package/src/tools/bind.ts +96 -0
- package/src/tools/coin-format.ts +12 -0
- package/src/tools/contacts.ts +120 -0
- package/src/tools/directory.ts +104 -0
- package/src/tools/messaging.ts +234 -0
- package/src/tools/notify.ts +55 -0
- package/src/tools/payment-transfer.ts +153 -0
- package/src/tools/payment.ts +384 -0
- package/src/tools/rooms.ts +228 -0
- package/src/tools/subscription.ts +249 -0
- package/src/tools/topics.ts +106 -0
- package/src/topic-tracker.ts +204 -0
- package/src/types.ts +273 -0
- package/src/ws-client.ts +187 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound message dispatch — shared by websocket and polling paths.
|
|
3
|
+
* Converts BotCord messages to OpenClaw inbound format.
|
|
4
|
+
*/
|
|
5
|
+
import { getBotCordRuntime } from "./runtime.js";
|
|
6
|
+
import { resolveAccountConfig } from "./config.js";
|
|
7
|
+
import { buildSessionKey } from "./session-key.js";
|
|
8
|
+
import { loadSessionStore } from "openclaw/plugin-sdk/mattermost";
|
|
9
|
+
import { sanitizeUntrustedContent, sanitizeSenderName } from "./sanitize.js";
|
|
10
|
+
import { BotCordClient } from "./client.js";
|
|
11
|
+
import { createBotCordReplyDispatcher } from "./reply-dispatcher.js";
|
|
12
|
+
import type { InboxMessage, MessageType } from "./types.js";
|
|
13
|
+
|
|
14
|
+
// Envelope types that count as notifications rather than normal messages
|
|
15
|
+
const NOTIFICATION_TYPES: ReadonlySet<string> = new Set([
|
|
16
|
+
"contact_request",
|
|
17
|
+
"contact_request_response",
|
|
18
|
+
"contact_removed",
|
|
19
|
+
"system",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a structured header line for inbound messages, e.g.:
|
|
24
|
+
* [BotCord Message] from: Link (ag_xxx) | to: ag_yyy | room: My Room
|
|
25
|
+
*/
|
|
26
|
+
function buildInboundHeader(params: {
|
|
27
|
+
type: MessageType;
|
|
28
|
+
senderName: string;
|
|
29
|
+
accountId: string;
|
|
30
|
+
chatType: "direct" | "group";
|
|
31
|
+
roomName?: string;
|
|
32
|
+
}): string {
|
|
33
|
+
const tag = NOTIFICATION_TYPES.has(params.type)
|
|
34
|
+
? "[BotCord Notification]"
|
|
35
|
+
: "[BotCord Message]";
|
|
36
|
+
|
|
37
|
+
const parts = [
|
|
38
|
+
tag,
|
|
39
|
+
`from: ${params.senderName}`,
|
|
40
|
+
`to: ${params.accountId}`,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
if (params.chatType === "group" && params.roomName) {
|
|
44
|
+
parts.push(`room: ${params.roomName}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parts.join(" | ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendRoomRule(content: string, roomRule?: string | null): string {
|
|
51
|
+
const normalizedRule = roomRule?.trim();
|
|
52
|
+
if (!normalizedRule) return content;
|
|
53
|
+
const sanitizedRule = sanitizeUntrustedContent(normalizedRule);
|
|
54
|
+
return `${content}\n[Room Rule] <room-rule>${sanitizedRule}</room-rule>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface InboundParams {
|
|
58
|
+
cfg: any;
|
|
59
|
+
accountId: string;
|
|
60
|
+
senderName: string;
|
|
61
|
+
senderId: string;
|
|
62
|
+
content: string;
|
|
63
|
+
messageId?: string;
|
|
64
|
+
messageType?: MessageType;
|
|
65
|
+
chatType: "direct" | "group";
|
|
66
|
+
groupSubject?: string;
|
|
67
|
+
replyTarget: string;
|
|
68
|
+
roomId?: string;
|
|
69
|
+
topic?: string;
|
|
70
|
+
topicId?: string;
|
|
71
|
+
mentioned?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shared handler for InboxMessage — used by both WebSocket and Poller paths.
|
|
76
|
+
* Normalizes InboxMessage into InboundParams and dispatches to OpenClaw.
|
|
77
|
+
*
|
|
78
|
+
* Routes differently based on msg.source_type:
|
|
79
|
+
* - "dashboard_user_chat": auto-reply mode (user chat), skips NO_REPLY/loop-risk
|
|
80
|
+
* - default ("agent"): existing A2A flow
|
|
81
|
+
*/
|
|
82
|
+
export async function handleInboxMessage(
|
|
83
|
+
msg: InboxMessage,
|
|
84
|
+
accountId: string,
|
|
85
|
+
cfg: any,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const isDashboardUserChat = msg.source_type === "dashboard_user_chat";
|
|
88
|
+
|
|
89
|
+
if (isDashboardUserChat) {
|
|
90
|
+
await handleDashboardUserChat(msg, accountId, cfg);
|
|
91
|
+
} else {
|
|
92
|
+
await handleA2AMessage(msg, accountId, cfg);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle dashboard user chat messages — auto-reply mode.
|
|
98
|
+
* No NO_REPLY hints, no A2A loop-risk, replies auto-delivered back to room.
|
|
99
|
+
*/
|
|
100
|
+
async function handleDashboardUserChat(
|
|
101
|
+
msg: InboxMessage,
|
|
102
|
+
accountId: string,
|
|
103
|
+
cfg: any,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const core = getBotCordRuntime();
|
|
106
|
+
const envelope = msg.envelope;
|
|
107
|
+
const senderId = envelope.from || "owner";
|
|
108
|
+
const rawContent =
|
|
109
|
+
msg.text ||
|
|
110
|
+
(typeof envelope.payload === "string"
|
|
111
|
+
? envelope.payload
|
|
112
|
+
: (envelope.payload?.text as string) ?? JSON.stringify(envelope.payload));
|
|
113
|
+
|
|
114
|
+
const sanitizedContent = sanitizeUntrustedContent(rawContent);
|
|
115
|
+
const header = "[Owner Message]";
|
|
116
|
+
const content = `${header}\n${sanitizedContent}`;
|
|
117
|
+
|
|
118
|
+
const replyTarget = msg.room_id || "";
|
|
119
|
+
const sessionKey = buildSessionKey(msg.room_id, undefined, senderId);
|
|
120
|
+
|
|
121
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
122
|
+
cfg,
|
|
123
|
+
channel: "botcord",
|
|
124
|
+
accountId,
|
|
125
|
+
peer: { kind: "direct", id: replyTarget },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const from = `botcord:${senderId}`;
|
|
129
|
+
const to = `botcord:${accountId}`;
|
|
130
|
+
|
|
131
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
132
|
+
const formattedBody = core.channel.reply.formatAgentEnvelope({
|
|
133
|
+
channel: "BotCord",
|
|
134
|
+
from: "Owner",
|
|
135
|
+
timestamp: new Date(),
|
|
136
|
+
envelope: envelopeOptions,
|
|
137
|
+
body: content,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
141
|
+
Body: formattedBody,
|
|
142
|
+
BodyForAgent: content,
|
|
143
|
+
RawBody: content,
|
|
144
|
+
CommandBody: content,
|
|
145
|
+
From: from,
|
|
146
|
+
To: to,
|
|
147
|
+
SessionKey: route.sessionKey || sessionKey,
|
|
148
|
+
AccountId: accountId,
|
|
149
|
+
ChatType: "direct",
|
|
150
|
+
SenderName: "Owner",
|
|
151
|
+
SenderId: senderId,
|
|
152
|
+
Provider: "botcord" as const,
|
|
153
|
+
Surface: "botcord" as const,
|
|
154
|
+
MessageSid: envelope.msg_id || `botcord-${Date.now()}`,
|
|
155
|
+
Timestamp: Date.now(),
|
|
156
|
+
WasMentioned: true,
|
|
157
|
+
CommandAuthorized: true,
|
|
158
|
+
OriginatingChannel: "botcord" as const,
|
|
159
|
+
OriginatingTo: to,
|
|
160
|
+
ConversationLabel: "Owner Chat",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Create the reply dispatcher that sends replies back to the chat room
|
|
164
|
+
const acct = resolveAccountConfig(cfg, accountId);
|
|
165
|
+
const client = new BotCordClient(acct);
|
|
166
|
+
const replyDispatcher = createBotCordReplyDispatcher({
|
|
167
|
+
client,
|
|
168
|
+
replyTarget,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Use buffered block dispatcher with auto-delivery to the chat room.
|
|
172
|
+
// The deliver callback receives a ReplyPayload object (not a plain string).
|
|
173
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
174
|
+
ctx: ctxPayload,
|
|
175
|
+
cfg,
|
|
176
|
+
dispatcherOptions: {
|
|
177
|
+
deliver: async (payload: any) => {
|
|
178
|
+
const text = payload?.text ?? "";
|
|
179
|
+
const mediaUrl = payload?.mediaUrl;
|
|
180
|
+
if (mediaUrl) {
|
|
181
|
+
await replyDispatcher.sendMedia(text, mediaUrl);
|
|
182
|
+
} else if (text) {
|
|
183
|
+
await replyDispatcher.sendText(text);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
onError: (err: any, info: any) => {
|
|
187
|
+
console.error(`[botcord] user-chat ${info?.kind ?? "unknown"} reply error:`, err);
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
replyOptions: {},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Handle regular A2A messages — existing flow with NO_REPLY hints and
|
|
196
|
+
* suppressed auto-delivery.
|
|
197
|
+
*/
|
|
198
|
+
async function handleA2AMessage(
|
|
199
|
+
msg: InboxMessage,
|
|
200
|
+
accountId: string,
|
|
201
|
+
cfg: any,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
const envelope = msg.envelope;
|
|
204
|
+
const senderId = envelope.from || "unknown";
|
|
205
|
+
const rawContent =
|
|
206
|
+
msg.text ||
|
|
207
|
+
(typeof envelope.payload === "string"
|
|
208
|
+
? envelope.payload
|
|
209
|
+
: (envelope.payload?.text as string) ?? JSON.stringify(envelope.payload));
|
|
210
|
+
// DM rooms have rm_dm_ prefix; only non-DM rooms are true group chats
|
|
211
|
+
const isGroupRoom = !!msg.room_id && !msg.room_id.startsWith("rm_dm_");
|
|
212
|
+
const chatType = isGroupRoom ? "group" : "direct";
|
|
213
|
+
|
|
214
|
+
const sanitizedSender = sanitizeSenderName(senderId);
|
|
215
|
+
const header = buildInboundHeader({
|
|
216
|
+
type: envelope.type,
|
|
217
|
+
senderName: sanitizedSender,
|
|
218
|
+
accountId,
|
|
219
|
+
chatType,
|
|
220
|
+
roomName: isGroupRoom ? (msg.room_name || msg.room_id) : undefined,
|
|
221
|
+
});
|
|
222
|
+
const silentHint =
|
|
223
|
+
chatType === "group"
|
|
224
|
+
? '\n\n[In group chats, do NOT reply unless you are explicitly mentioned or addressed. If no response is needed, reply with exactly "NO_REPLY" and nothing else.]'
|
|
225
|
+
: '\n\n[If the conversation has naturally concluded or no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
226
|
+
|
|
227
|
+
// Prompt the agent to notify its owner when receiving contact requests
|
|
228
|
+
const notifyOwnerHint =
|
|
229
|
+
envelope.type === "contact_request"
|
|
230
|
+
? `\n\n[You received a contact request from ${senderId}. Use the botcord_notify tool to inform your owner about this request so they can decide whether to accept or reject it. Include the sender's agent ID and any message they attached.]`
|
|
231
|
+
: "";
|
|
232
|
+
|
|
233
|
+
const sanitizedContent = sanitizeUntrustedContent(rawContent);
|
|
234
|
+
const content = `${header}\n<agent-message sender="${sanitizedSender}">\n${sanitizedContent}\n</agent-message>${silentHint}${notifyOwnerHint}`;
|
|
235
|
+
const contentWithRule = isGroupRoom ? appendRoomRule(content, msg.room_rule) : content;
|
|
236
|
+
|
|
237
|
+
await dispatchInbound({
|
|
238
|
+
cfg,
|
|
239
|
+
accountId,
|
|
240
|
+
senderName: senderId,
|
|
241
|
+
senderId,
|
|
242
|
+
content: contentWithRule,
|
|
243
|
+
messageId: envelope.msg_id,
|
|
244
|
+
messageType: envelope.type,
|
|
245
|
+
chatType,
|
|
246
|
+
groupSubject: isGroupRoom ? (msg.room_name || msg.room_id) : undefined,
|
|
247
|
+
replyTarget: isGroupRoom ? msg.room_id! : (envelope.from || ""),
|
|
248
|
+
roomId: msg.room_id,
|
|
249
|
+
topic: msg.topic,
|
|
250
|
+
topicId: msg.topic_id,
|
|
251
|
+
mentioned: msg.mentioned,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Dispatch an inbound message into OpenClaw's channel routing system.
|
|
257
|
+
*/
|
|
258
|
+
export async function dispatchInbound(params: InboundParams): Promise<void> {
|
|
259
|
+
const core = getBotCordRuntime();
|
|
260
|
+
const {
|
|
261
|
+
cfg,
|
|
262
|
+
accountId,
|
|
263
|
+
senderName,
|
|
264
|
+
senderId,
|
|
265
|
+
content,
|
|
266
|
+
messageId,
|
|
267
|
+
chatType,
|
|
268
|
+
groupSubject,
|
|
269
|
+
replyTarget,
|
|
270
|
+
roomId,
|
|
271
|
+
topic,
|
|
272
|
+
} = params;
|
|
273
|
+
|
|
274
|
+
const from = `botcord:${senderId}`;
|
|
275
|
+
const to = `botcord:${accountId}`;
|
|
276
|
+
const sessionKey = buildSessionKey(roomId, topic, senderId);
|
|
277
|
+
|
|
278
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
279
|
+
cfg,
|
|
280
|
+
channel: "botcord",
|
|
281
|
+
accountId,
|
|
282
|
+
peer: {
|
|
283
|
+
kind: chatType,
|
|
284
|
+
id: chatType === "group" ? (roomId || replyTarget) : senderId,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
289
|
+
const formattedBody = core.channel.reply.formatAgentEnvelope({
|
|
290
|
+
channel: "BotCord",
|
|
291
|
+
from: senderName,
|
|
292
|
+
timestamp: new Date(),
|
|
293
|
+
envelope: envelopeOptions,
|
|
294
|
+
body: content,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
298
|
+
Body: formattedBody,
|
|
299
|
+
BodyForAgent: content,
|
|
300
|
+
RawBody: content,
|
|
301
|
+
CommandBody: content,
|
|
302
|
+
From: from,
|
|
303
|
+
To: to,
|
|
304
|
+
SessionKey: route.sessionKey || sessionKey,
|
|
305
|
+
AccountId: accountId,
|
|
306
|
+
ChatType: chatType,
|
|
307
|
+
GroupSubject: chatType === "group" ? (groupSubject || replyTarget) : undefined,
|
|
308
|
+
SenderName: senderName,
|
|
309
|
+
SenderId: senderId,
|
|
310
|
+
Provider: "botcord" as const,
|
|
311
|
+
Surface: "botcord" as const,
|
|
312
|
+
MessageSid: messageId || `botcord-${Date.now()}`,
|
|
313
|
+
Timestamp: Date.now(),
|
|
314
|
+
WasMentioned: params.chatType === "direct"
|
|
315
|
+
? true
|
|
316
|
+
: (params.mentioned ?? true),
|
|
317
|
+
CommandAuthorized: true,
|
|
318
|
+
OriginatingChannel: "botcord" as const,
|
|
319
|
+
OriginatingTo: to,
|
|
320
|
+
ConversationLabel: chatType === "group" ? (groupSubject || senderName) : senderName,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
324
|
+
ctx: ctxPayload,
|
|
325
|
+
cfg,
|
|
326
|
+
dispatcherOptions: {
|
|
327
|
+
// A2A replies are sent explicitly via botcord_send tool.
|
|
328
|
+
// Suppress automatic delivery to avoid leaking agent narration.
|
|
329
|
+
deliver: async () => {},
|
|
330
|
+
onError: (err: any, info: any) => {
|
|
331
|
+
console.error(`[botcord] ${info?.kind ?? "unknown"} reply error:`, err);
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
replyOptions: {},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Auto-notify owner for notification-type messages (contact requests, etc.)
|
|
338
|
+
// Normal messages are NOT auto-notified; the agent can use the
|
|
339
|
+
// botcord_notify tool to notify the owner when it deems appropriate.
|
|
340
|
+
const messageType = params.messageType;
|
|
341
|
+
if (messageType && NOTIFICATION_TYPES.has(messageType)) {
|
|
342
|
+
const acct = resolveAccountConfig(cfg, accountId);
|
|
343
|
+
const notifySession = acct.notifySession;
|
|
344
|
+
if (notifySession) {
|
|
345
|
+
const childSessionKey = route.sessionKey || sessionKey;
|
|
346
|
+
if (childSessionKey !== notifySession) {
|
|
347
|
+
const topicLabel = topic ? ` (topic: ${topic})` : "";
|
|
348
|
+
const notification =
|
|
349
|
+
`[BotCord ${messageType}] from ${senderName}${topicLabel}\n` +
|
|
350
|
+
`Session: ${childSessionKey}\n` +
|
|
351
|
+
`Preview: ${(params.content || "").slice(0, 200)}`;
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await deliverNotification(core, cfg, notifySession, notification);
|
|
355
|
+
} catch (err: any) {
|
|
356
|
+
console.error(`[botcord] auto-notify failed:`, err?.message ?? err);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Notification delivery helpers ───────────────────────────────────
|
|
364
|
+
|
|
365
|
+
type DeliveryContext = {
|
|
366
|
+
channel: string;
|
|
367
|
+
to: string;
|
|
368
|
+
accountId?: string;
|
|
369
|
+
threadId?: string;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Parse a session key like "agent:pm:telegram:direct:7904063707" into a
|
|
374
|
+
* DeliveryContext. Returns undefined if the key doesn't contain a
|
|
375
|
+
* recognisable channel segment.
|
|
376
|
+
*
|
|
377
|
+
* Supported formats:
|
|
378
|
+
* agent:<agentName>:<channel>:direct:<peerId>
|
|
379
|
+
* agent:<agentName>:<channel>:group:<groupId>
|
|
380
|
+
*/
|
|
381
|
+
function parseSessionKeyDeliveryContext(sessionKey: string): DeliveryContext | undefined {
|
|
382
|
+
// e.g. ["agent", "pm", "telegram", "direct", "7904063707"]
|
|
383
|
+
const parts = sessionKey.split(":");
|
|
384
|
+
if (parts.length < 5 || parts[0] !== "agent") return undefined;
|
|
385
|
+
|
|
386
|
+
const agentName = parts[1]; // e.g. "pm"
|
|
387
|
+
const channel = parts[2]; // e.g. "telegram"
|
|
388
|
+
if (!channel) return undefined;
|
|
389
|
+
|
|
390
|
+
const peerId = parts.slice(4).join(":"); // handle colons in id
|
|
391
|
+
if (!peerId) return undefined;
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
channel,
|
|
395
|
+
to: `${channel}:${peerId}`,
|
|
396
|
+
accountId: agentName,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function parseSessionStoreDeliveryContext(
|
|
401
|
+
core: ReturnType<typeof getBotCordRuntime>,
|
|
402
|
+
cfg: any,
|
|
403
|
+
sessionKey: string,
|
|
404
|
+
): DeliveryContext[] {
|
|
405
|
+
try {
|
|
406
|
+
const storePath = core.channel.session.resolveStorePath(cfg);
|
|
407
|
+
if (!storePath) return [];
|
|
408
|
+
|
|
409
|
+
const store = loadSessionStore(storePath);
|
|
410
|
+
const trimmedKey = sessionKey.trim();
|
|
411
|
+
const normalizedKey = trimmedKey.toLowerCase();
|
|
412
|
+
let existing = store[normalizedKey] ?? store[trimmedKey];
|
|
413
|
+
let existingUpdatedAt = existing?.updatedAt ?? 0;
|
|
414
|
+
|
|
415
|
+
// Legacy stores may contain differently-cased keys for the same session.
|
|
416
|
+
// Prefer the most recently updated matching entry.
|
|
417
|
+
for (const [candidateKey, candidateEntry] of Object.entries(store)) {
|
|
418
|
+
if (candidateKey.toLowerCase() !== normalizedKey) continue;
|
|
419
|
+
const candidateUpdatedAt = candidateEntry?.updatedAt ?? 0;
|
|
420
|
+
if (!existing || candidateUpdatedAt > existingUpdatedAt) {
|
|
421
|
+
existing = candidateEntry;
|
|
422
|
+
existingUpdatedAt = candidateUpdatedAt;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!existing) return [];
|
|
426
|
+
|
|
427
|
+
const lastRoute = {
|
|
428
|
+
channel: existing.lastChannel,
|
|
429
|
+
to: existing.lastTo,
|
|
430
|
+
accountId: existing.lastAccountId,
|
|
431
|
+
threadId: existing.lastThreadId ?? existing.origin?.threadId,
|
|
432
|
+
};
|
|
433
|
+
const candidates: DeliveryContext[] = [];
|
|
434
|
+
for (const ctx of [existing.deliveryContext, lastRoute]) {
|
|
435
|
+
if (!ctx?.channel || !ctx?.to) continue;
|
|
436
|
+
const normalized: DeliveryContext = {
|
|
437
|
+
channel: String(ctx.channel),
|
|
438
|
+
to: String(ctx.to),
|
|
439
|
+
};
|
|
440
|
+
if (ctx.accountId != null) normalized.accountId = String(ctx.accountId);
|
|
441
|
+
if (ctx.threadId != null) normalized.threadId = String(ctx.threadId);
|
|
442
|
+
candidates.push(normalized);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Deduplicate identical contexts while preserving order.
|
|
446
|
+
const seen = new Set<string>();
|
|
447
|
+
return candidates.filter((ctx) => {
|
|
448
|
+
const key = `${ctx.channel}|${ctx.to}|${ctx.accountId ?? ""}|${ctx.threadId ?? ""}`;
|
|
449
|
+
if (seen.has(key)) return false;
|
|
450
|
+
seen.add(key);
|
|
451
|
+
return true;
|
|
452
|
+
});
|
|
453
|
+
} catch (err: any) {
|
|
454
|
+
console.warn(
|
|
455
|
+
`[botcord] notifySession ${sessionKey}: failed to read deliveryContext from session store:`,
|
|
456
|
+
err?.message ?? err,
|
|
457
|
+
);
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Channel name → runtime send function dispatcher. */
|
|
463
|
+
type ChannelSendFn = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
464
|
+
|
|
465
|
+
function resolveChannelSendFn(
|
|
466
|
+
core: ReturnType<typeof getBotCordRuntime>,
|
|
467
|
+
channel: string,
|
|
468
|
+
): ChannelSendFn | undefined {
|
|
469
|
+
const map: Record<string, ChannelSendFn | undefined> = {
|
|
470
|
+
telegram: core.channel.telegram?.sendMessageTelegram as ChannelSendFn | undefined,
|
|
471
|
+
discord: core.channel.discord?.sendMessageDiscord as ChannelSendFn | undefined,
|
|
472
|
+
slack: core.channel.slack?.sendMessageSlack as ChannelSendFn | undefined,
|
|
473
|
+
whatsapp: core.channel.whatsapp?.sendMessageWhatsApp as ChannelSendFn | undefined,
|
|
474
|
+
signal: core.channel.signal?.sendMessageSignal as ChannelSendFn | undefined,
|
|
475
|
+
imessage: core.channel.imessage?.sendMessageIMessage as ChannelSendFn | undefined,
|
|
476
|
+
};
|
|
477
|
+
return map[channel];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Deliver a notification message directly to the channel associated with
|
|
482
|
+
* the target session. Prefer deriving target from session key; fallback to
|
|
483
|
+
* session store deliveryContext when direct routing is unavailable.
|
|
484
|
+
*/
|
|
485
|
+
export async function deliverNotification(
|
|
486
|
+
core: ReturnType<typeof getBotCordRuntime>,
|
|
487
|
+
cfg: any,
|
|
488
|
+
sessionKey: string,
|
|
489
|
+
text: string,
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
const deliveryFromKey = parseSessionKeyDeliveryContext(sessionKey);
|
|
492
|
+
const storeCandidates = parseSessionStoreDeliveryContext(core, cfg, sessionKey);
|
|
493
|
+
const candidates = [
|
|
494
|
+
...(deliveryFromKey ? [deliveryFromKey] : []),
|
|
495
|
+
...storeCandidates,
|
|
496
|
+
];
|
|
497
|
+
let delivery: DeliveryContext | undefined;
|
|
498
|
+
let sendFn: ChannelSendFn | undefined;
|
|
499
|
+
|
|
500
|
+
for (const candidate of candidates) {
|
|
501
|
+
if (!delivery) delivery = candidate;
|
|
502
|
+
const resolved = resolveChannelSendFn(core, candidate.channel);
|
|
503
|
+
if (resolved) {
|
|
504
|
+
delivery = candidate;
|
|
505
|
+
sendFn = resolved;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!delivery) {
|
|
511
|
+
console.warn(
|
|
512
|
+
`[botcord] notifySession ${sessionKey}: cannot derive delivery target from session key or session store — skipping notification`,
|
|
513
|
+
);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (!sendFn) {
|
|
518
|
+
sendFn = resolveChannelSendFn(core, delivery.channel);
|
|
519
|
+
}
|
|
520
|
+
if (!sendFn) {
|
|
521
|
+
console.warn(
|
|
522
|
+
`[botcord] unsupported notify channel "${delivery.channel}" — skipping notification`,
|
|
523
|
+
);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await sendFn(delivery.to, text, {
|
|
528
|
+
cfg,
|
|
529
|
+
accountId: delivery.accountId,
|
|
530
|
+
threadId: delivery.threadId,
|
|
531
|
+
});
|
|
532
|
+
}
|