@botcord/openclaw-plugin 0.0.2
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 +161 -0
- package/index.ts +64 -0
- package/openclaw.plugin.json +55 -0
- package/package.json +61 -0
- package/skills/botcord/SKILL.md +322 -0
- package/src/channel.ts +446 -0
- package/src/client.ts +616 -0
- package/src/commands/healthcheck.ts +143 -0
- package/src/commands/register.ts +302 -0
- package/src/commands/token.ts +43 -0
- package/src/config.ts +92 -0
- package/src/credentials.ts +113 -0
- package/src/crypto.ts +155 -0
- package/src/inbound.ts +305 -0
- package/src/poller.ts +70 -0
- package/src/runtime.ts +25 -0
- package/src/session-key.ts +59 -0
- package/src/tools/account.ts +94 -0
- package/src/tools/contacts.ts +120 -0
- package/src/tools/directory.ts +89 -0
- package/src/tools/messaging.ts +234 -0
- package/src/tools/notify.ts +55 -0
- package/src/tools/rooms.ts +177 -0
- package/src/tools/topics.ts +106 -0
- package/src/tools/wallet.ts +208 -0
- package/src/topic-tracker.ts +204 -0
- package/src/types.ts +203 -0
- package/src/ws-client.ts +187 -0
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 signing for BotCord protocol.
|
|
3
|
+
* Zero npm dependencies — uses Node.js built-in crypto module.
|
|
4
|
+
* Ported from botcord-skill/skill/botcord-crypto.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
createHash,
|
|
8
|
+
createPublicKey,
|
|
9
|
+
createPrivateKey,
|
|
10
|
+
generateKeyPairSync,
|
|
11
|
+
sign,
|
|
12
|
+
randomUUID,
|
|
13
|
+
} from "node:crypto";
|
|
14
|
+
import type { BotCordMessageEnvelope, BotCordSignature, MessageType } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// ── JCS (RFC 8785) canonicalization ─────────────────────────────
|
|
17
|
+
export function jcsCanonicalize(value: unknown): string | undefined {
|
|
18
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
19
|
+
if (typeof value === "number") {
|
|
20
|
+
if (Object.is(value, -0)) return "0";
|
|
21
|
+
return JSON.stringify(value);
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return "[" + value.map((v) => jcsCanonicalize(v)).join(",") + "]";
|
|
26
|
+
if (typeof value === "object") {
|
|
27
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
for (const k of keys) {
|
|
30
|
+
const v = (value as Record<string, unknown>)[k];
|
|
31
|
+
if (v === undefined) continue;
|
|
32
|
+
parts.push(JSON.stringify(k) + ":" + jcsCanonicalize(v));
|
|
33
|
+
}
|
|
34
|
+
return "{" + parts.join(",") + "}";
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Build Node.js KeyObject from raw 32-byte seed ───────────────
|
|
40
|
+
function privateKeyFromSeed(seed: Buffer): ReturnType<typeof createPrivateKey> {
|
|
41
|
+
const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
42
|
+
return createPrivateKey({
|
|
43
|
+
key: Buffer.concat([prefix, seed]),
|
|
44
|
+
format: "der",
|
|
45
|
+
type: "pkcs8",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Payload hash ────────────────────────────────────────────────
|
|
50
|
+
export function computePayloadHash(payload: Record<string, unknown>): string {
|
|
51
|
+
const canonical = jcsCanonicalize(payload)!;
|
|
52
|
+
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
53
|
+
return `sha256:${digest}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Sign challenge ──────────────────────────────────────────────
|
|
57
|
+
export function signChallenge(privateKeyB64: string, challengeB64: string): string {
|
|
58
|
+
const pk = privateKeyFromSeed(Buffer.from(privateKeyB64, "base64"));
|
|
59
|
+
const sig = sign(null, Buffer.from(challengeB64, "base64"), pk);
|
|
60
|
+
return sig.toString("base64");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function derivePublicKey(privateKeyB64: string): string {
|
|
64
|
+
const privateKey = privateKeyFromSeed(Buffer.from(privateKeyB64, "base64"));
|
|
65
|
+
const publicKey = createPublicKey(privateKey);
|
|
66
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
67
|
+
return Buffer.from(pubDer.subarray(-32)).toString("base64");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Build and sign a full message envelope ──────────────────────
|
|
71
|
+
export function buildSignedEnvelope(params: {
|
|
72
|
+
from: string;
|
|
73
|
+
to: string;
|
|
74
|
+
type: MessageType;
|
|
75
|
+
payload: Record<string, unknown>;
|
|
76
|
+
privateKey: string; // base64 Ed25519 seed
|
|
77
|
+
keyId: string;
|
|
78
|
+
replyTo?: string | null;
|
|
79
|
+
ttlSec?: number;
|
|
80
|
+
topic?: string | null;
|
|
81
|
+
goal?: string | null;
|
|
82
|
+
}): BotCordMessageEnvelope {
|
|
83
|
+
const {
|
|
84
|
+
from,
|
|
85
|
+
to,
|
|
86
|
+
type,
|
|
87
|
+
payload,
|
|
88
|
+
privateKey,
|
|
89
|
+
keyId,
|
|
90
|
+
replyTo = null,
|
|
91
|
+
ttlSec = 3600,
|
|
92
|
+
topic = null,
|
|
93
|
+
goal = null,
|
|
94
|
+
} = params;
|
|
95
|
+
|
|
96
|
+
const msgId = randomUUID();
|
|
97
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
98
|
+
const payloadHash = computePayloadHash(payload);
|
|
99
|
+
|
|
100
|
+
// Build signing input (newline-joined fields)
|
|
101
|
+
const parts = [
|
|
102
|
+
"a2a/0.1",
|
|
103
|
+
msgId,
|
|
104
|
+
String(ts),
|
|
105
|
+
from,
|
|
106
|
+
to,
|
|
107
|
+
String(type),
|
|
108
|
+
replyTo || "",
|
|
109
|
+
String(ttlSec),
|
|
110
|
+
payloadHash,
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const pk = privateKeyFromSeed(Buffer.from(privateKey, "base64"));
|
|
114
|
+
const sigValue = sign(null, Buffer.from(parts.join("\n")), pk);
|
|
115
|
+
|
|
116
|
+
const sig: BotCordSignature = {
|
|
117
|
+
alg: "ed25519",
|
|
118
|
+
key_id: keyId,
|
|
119
|
+
value: sigValue.toString("base64"),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
v: "a2a/0.1",
|
|
124
|
+
msg_id: msgId,
|
|
125
|
+
ts,
|
|
126
|
+
from,
|
|
127
|
+
to,
|
|
128
|
+
type,
|
|
129
|
+
reply_to: replyTo,
|
|
130
|
+
ttl_sec: ttlSec,
|
|
131
|
+
topic,
|
|
132
|
+
goal,
|
|
133
|
+
payload,
|
|
134
|
+
payload_hash: payloadHash,
|
|
135
|
+
sig,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Keygen ──────────────────────────────────────────────────────
|
|
140
|
+
export function generateKeypair(): {
|
|
141
|
+
privateKey: string;
|
|
142
|
+
publicKey: string;
|
|
143
|
+
pubkeyFormatted: string;
|
|
144
|
+
} {
|
|
145
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
146
|
+
const privDer = privateKey.export({ type: "pkcs8", format: "der" });
|
|
147
|
+
const privB64 = Buffer.from(privDer.subarray(-32)).toString("base64");
|
|
148
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
149
|
+
const pubB64 = Buffer.from(pubDer.subarray(-32)).toString("base64");
|
|
150
|
+
return {
|
|
151
|
+
privateKey: privB64,
|
|
152
|
+
publicKey: pubB64,
|
|
153
|
+
pubkeyFormatted: `ed25519:${pubB64}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound message dispatch — shared by websocket and polling paths.
|
|
3
|
+
* Converts BotCord messages to OpenClaw inbound format.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { getBotCordRuntime } from "./runtime.js";
|
|
7
|
+
import { resolveAccountConfig } from "./config.js";
|
|
8
|
+
import { buildSessionKey } from "./session-key.js";
|
|
9
|
+
import type { InboxMessage, MessageType } from "./types.js";
|
|
10
|
+
|
|
11
|
+
// Envelope types that count as notifications rather than normal messages
|
|
12
|
+
const NOTIFICATION_TYPES: ReadonlySet<string> = new Set([
|
|
13
|
+
"contact_request",
|
|
14
|
+
"contact_request_response",
|
|
15
|
+
"contact_removed",
|
|
16
|
+
"system",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a structured header line for inbound messages, e.g.:
|
|
21
|
+
* [BotCord Message] from: Link (ag_xxx) | to: ag_yyy | room: My Room
|
|
22
|
+
*/
|
|
23
|
+
function buildInboundHeader(params: {
|
|
24
|
+
type: MessageType;
|
|
25
|
+
senderName: string;
|
|
26
|
+
accountId: string;
|
|
27
|
+
chatType: "direct" | "group";
|
|
28
|
+
roomName?: string;
|
|
29
|
+
}): string {
|
|
30
|
+
const tag = NOTIFICATION_TYPES.has(params.type)
|
|
31
|
+
? "[BotCord Notification]"
|
|
32
|
+
: "[BotCord Message]";
|
|
33
|
+
|
|
34
|
+
const parts = [
|
|
35
|
+
tag,
|
|
36
|
+
`from: ${params.senderName}`,
|
|
37
|
+
`to: ${params.accountId}`,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
if (params.chatType === "group" && params.roomName) {
|
|
41
|
+
parts.push(`room: ${params.roomName}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return parts.join(" | ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InboundParams {
|
|
48
|
+
cfg: any;
|
|
49
|
+
accountId: string;
|
|
50
|
+
senderName: string;
|
|
51
|
+
senderId: string;
|
|
52
|
+
content: string;
|
|
53
|
+
messageId?: string;
|
|
54
|
+
messageType?: MessageType;
|
|
55
|
+
chatType: "direct" | "group";
|
|
56
|
+
groupSubject?: string;
|
|
57
|
+
replyTarget: string;
|
|
58
|
+
roomId?: string;
|
|
59
|
+
topic?: string;
|
|
60
|
+
topicId?: string;
|
|
61
|
+
mentioned?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shared handler for InboxMessage — used by both WebSocket and Poller paths.
|
|
66
|
+
* Normalizes InboxMessage into InboundParams and dispatches to OpenClaw.
|
|
67
|
+
*/
|
|
68
|
+
export async function handleInboxMessage(
|
|
69
|
+
msg: InboxMessage,
|
|
70
|
+
accountId: string,
|
|
71
|
+
cfg: any,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const envelope = msg.envelope;
|
|
74
|
+
const senderId = envelope.from || "unknown";
|
|
75
|
+
const rawContent =
|
|
76
|
+
msg.text ||
|
|
77
|
+
(typeof envelope.payload === "string"
|
|
78
|
+
? envelope.payload
|
|
79
|
+
: envelope.payload?.text ?? JSON.stringify(envelope.payload));
|
|
80
|
+
// DM rooms have rm_dm_ prefix; only non-DM rooms are true group chats
|
|
81
|
+
const isGroupRoom = !!msg.room_id && !msg.room_id.startsWith("rm_dm_");
|
|
82
|
+
const chatType = isGroupRoom ? "group" : "direct";
|
|
83
|
+
|
|
84
|
+
const header = buildInboundHeader({
|
|
85
|
+
type: envelope.type,
|
|
86
|
+
senderName: senderId,
|
|
87
|
+
accountId,
|
|
88
|
+
chatType,
|
|
89
|
+
roomName: isGroupRoom ? (msg.room_name || msg.room_id) : undefined,
|
|
90
|
+
});
|
|
91
|
+
const silentHint =
|
|
92
|
+
chatType === "group"
|
|
93
|
+
? '\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.]'
|
|
94
|
+
: '\n\n[If the conversation has naturally concluded or no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
95
|
+
const content = `${header}\n${rawContent}${silentHint}`;
|
|
96
|
+
|
|
97
|
+
await dispatchInbound({
|
|
98
|
+
cfg,
|
|
99
|
+
accountId,
|
|
100
|
+
senderName: senderId,
|
|
101
|
+
senderId,
|
|
102
|
+
content: content as string,
|
|
103
|
+
messageId: envelope.msg_id,
|
|
104
|
+
messageType: envelope.type,
|
|
105
|
+
chatType,
|
|
106
|
+
groupSubject: isGroupRoom ? (msg.room_name || msg.room_id) : undefined,
|
|
107
|
+
replyTarget: isGroupRoom ? msg.room_id! : (envelope.from || ""),
|
|
108
|
+
roomId: msg.room_id,
|
|
109
|
+
topic: msg.topic,
|
|
110
|
+
topicId: msg.topic_id,
|
|
111
|
+
mentioned: msg.mentioned,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Dispatch an inbound message into OpenClaw's channel routing system.
|
|
117
|
+
*/
|
|
118
|
+
export async function dispatchInbound(params: InboundParams): Promise<void> {
|
|
119
|
+
const core = getBotCordRuntime();
|
|
120
|
+
const {
|
|
121
|
+
cfg,
|
|
122
|
+
accountId,
|
|
123
|
+
senderName,
|
|
124
|
+
senderId,
|
|
125
|
+
content,
|
|
126
|
+
messageId,
|
|
127
|
+
chatType,
|
|
128
|
+
groupSubject,
|
|
129
|
+
replyTarget,
|
|
130
|
+
roomId,
|
|
131
|
+
topic,
|
|
132
|
+
} = params;
|
|
133
|
+
|
|
134
|
+
const from = `botcord:${senderId}`;
|
|
135
|
+
const to = `botcord:${accountId}`;
|
|
136
|
+
const sessionKey = buildSessionKey(roomId, topic, senderId);
|
|
137
|
+
|
|
138
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
139
|
+
cfg,
|
|
140
|
+
channel: "botcord",
|
|
141
|
+
accountId,
|
|
142
|
+
peer: {
|
|
143
|
+
kind: chatType,
|
|
144
|
+
id: chatType === "group" ? (roomId || replyTarget) : senderId,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
149
|
+
const formattedBody = core.channel.reply.formatAgentEnvelope({
|
|
150
|
+
channel: "BotCord",
|
|
151
|
+
from: senderName,
|
|
152
|
+
timestamp: new Date(),
|
|
153
|
+
envelope: envelopeOptions,
|
|
154
|
+
body: content,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
158
|
+
Body: formattedBody,
|
|
159
|
+
BodyForAgent: content,
|
|
160
|
+
RawBody: content,
|
|
161
|
+
CommandBody: content,
|
|
162
|
+
From: from,
|
|
163
|
+
To: to,
|
|
164
|
+
SessionKey: route.sessionKey || sessionKey,
|
|
165
|
+
AccountId: accountId,
|
|
166
|
+
ChatType: chatType,
|
|
167
|
+
GroupSubject: chatType === "group" ? (groupSubject || replyTarget) : undefined,
|
|
168
|
+
SenderName: senderName,
|
|
169
|
+
SenderId: senderId,
|
|
170
|
+
Provider: "botcord" as const,
|
|
171
|
+
Surface: "botcord" as const,
|
|
172
|
+
MessageSid: messageId || `botcord-${Date.now()}`,
|
|
173
|
+
Timestamp: Date.now(),
|
|
174
|
+
WasMentioned: params.chatType === "direct"
|
|
175
|
+
? true
|
|
176
|
+
: (params.mentioned ?? true),
|
|
177
|
+
CommandAuthorized: true,
|
|
178
|
+
OriginatingChannel: "botcord" as const,
|
|
179
|
+
OriginatingTo: to,
|
|
180
|
+
ConversationLabel: chatType === "group" ? (groupSubject || senderName) : senderName,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
184
|
+
ctx: ctxPayload,
|
|
185
|
+
cfg,
|
|
186
|
+
dispatcherOptions: {
|
|
187
|
+
// A2A replies are sent explicitly via botcord_send tool.
|
|
188
|
+
// Suppress automatic delivery to avoid leaking agent narration.
|
|
189
|
+
deliver: async () => {},
|
|
190
|
+
onError: (err: any, info: any) => {
|
|
191
|
+
console.error(`[botcord] ${info?.kind ?? "unknown"} reply error:`, err);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
replyOptions: {},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Auto-notify owner for notification-type messages (contact requests, etc.)
|
|
198
|
+
// Normal messages are NOT auto-notified; the agent can use the
|
|
199
|
+
// botcord_notify tool to notify the owner when it deems appropriate.
|
|
200
|
+
const messageType = params.messageType;
|
|
201
|
+
if (messageType && NOTIFICATION_TYPES.has(messageType)) {
|
|
202
|
+
const acct = resolveAccountConfig(cfg, accountId);
|
|
203
|
+
const notifySession = acct.notifySession;
|
|
204
|
+
if (notifySession) {
|
|
205
|
+
const childSessionKey = route.sessionKey || sessionKey;
|
|
206
|
+
if (childSessionKey !== notifySession) {
|
|
207
|
+
const topicLabel = topic ? ` (topic: ${topic})` : "";
|
|
208
|
+
const notification =
|
|
209
|
+
`[BotCord ${messageType}] from ${senderName}${topicLabel}\n` +
|
|
210
|
+
`Session: ${childSessionKey}\n` +
|
|
211
|
+
`Preview: ${(params.content || "").slice(0, 200)}`;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await deliverNotification(core, cfg, notifySession, notification);
|
|
215
|
+
} catch (err: any) {
|
|
216
|
+
console.error(`[botcord] auto-notify failed:`, err?.message ?? err);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Notification delivery helpers ───────────────────────────────────
|
|
224
|
+
|
|
225
|
+
type DeliveryContext = {
|
|
226
|
+
channel: string;
|
|
227
|
+
to: string;
|
|
228
|
+
accountId?: string;
|
|
229
|
+
threadId?: string;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read deliveryContext for a session key from the session store on disk.
|
|
234
|
+
* Returns undefined when the session has no recorded delivery route.
|
|
235
|
+
*/
|
|
236
|
+
async function resolveSessionDeliveryContext(
|
|
237
|
+
core: ReturnType<typeof getBotCordRuntime>,
|
|
238
|
+
cfg: any,
|
|
239
|
+
sessionKey: string,
|
|
240
|
+
): Promise<DeliveryContext | undefined> {
|
|
241
|
+
try {
|
|
242
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store);
|
|
243
|
+
const raw = await readFile(storePath, "utf-8");
|
|
244
|
+
const store: Record<string, { deliveryContext?: DeliveryContext }> = JSON.parse(raw);
|
|
245
|
+
const entry = store[sessionKey];
|
|
246
|
+
if (entry?.deliveryContext?.channel && entry.deliveryContext.to) {
|
|
247
|
+
return entry.deliveryContext;
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// best-effort: store may not exist yet
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Channel name → runtime send function dispatcher. */
|
|
256
|
+
type ChannelSendFn = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
257
|
+
|
|
258
|
+
function resolveChannelSendFn(
|
|
259
|
+
core: ReturnType<typeof getBotCordRuntime>,
|
|
260
|
+
channel: string,
|
|
261
|
+
): ChannelSendFn | undefined {
|
|
262
|
+
const map: Record<string, ChannelSendFn | undefined> = {
|
|
263
|
+
telegram: core.channel.telegram?.sendMessageTelegram as ChannelSendFn | undefined,
|
|
264
|
+
discord: core.channel.discord?.sendMessageDiscord as ChannelSendFn | undefined,
|
|
265
|
+
slack: core.channel.slack?.sendMessageSlack as ChannelSendFn | undefined,
|
|
266
|
+
whatsapp: core.channel.whatsapp?.sendMessageWhatsApp as ChannelSendFn | undefined,
|
|
267
|
+
signal: core.channel.signal?.sendMessageSignal as ChannelSendFn | undefined,
|
|
268
|
+
imessage: core.channel.imessage?.sendMessageIMessage as ChannelSendFn | undefined,
|
|
269
|
+
};
|
|
270
|
+
return map[channel];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Deliver a notification message directly to the channel associated with
|
|
275
|
+
* the target session (looked up via deliveryContext in the session store).
|
|
276
|
+
* Does not trigger an agent turn — just sends the text.
|
|
277
|
+
*/
|
|
278
|
+
export async function deliverNotification(
|
|
279
|
+
core: ReturnType<typeof getBotCordRuntime>,
|
|
280
|
+
cfg: any,
|
|
281
|
+
sessionKey: string,
|
|
282
|
+
text: string,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
const delivery = await resolveSessionDeliveryContext(core, cfg, sessionKey);
|
|
285
|
+
if (!delivery) {
|
|
286
|
+
console.warn(
|
|
287
|
+
`[botcord] notifySession ${sessionKey} has no deliveryContext — skipping notification`,
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const sendFn = resolveChannelSendFn(core, delivery.channel);
|
|
293
|
+
if (!sendFn) {
|
|
294
|
+
console.warn(
|
|
295
|
+
`[botcord] unsupported notify channel "${delivery.channel}" — skipping notification`,
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await sendFn(delivery.to, text, {
|
|
301
|
+
cfg,
|
|
302
|
+
accountId: delivery.accountId,
|
|
303
|
+
threadId: delivery.threadId,
|
|
304
|
+
});
|
|
305
|
+
}
|
package/src/poller.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background inbox polling for BotCord.
|
|
3
|
+
* Used when websocket delivery is unavailable.
|
|
4
|
+
*/
|
|
5
|
+
import { BotCordClient } from "./client.js";
|
|
6
|
+
import { handleInboxMessage } from "./inbound.js";
|
|
7
|
+
import { displayPrefix } from "./config.js";
|
|
8
|
+
|
|
9
|
+
interface PollerOptions {
|
|
10
|
+
client: BotCordClient;
|
|
11
|
+
accountId: string;
|
|
12
|
+
cfg: any;
|
|
13
|
+
intervalMs: number;
|
|
14
|
+
abortSignal?: AbortSignal;
|
|
15
|
+
log?: { info: (msg: string) => void; error: (msg: string) => void; warn: (msg: string) => void };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const activePollers = new Map<string, { stop: () => void }>();
|
|
19
|
+
|
|
20
|
+
export function startPoller(opts: PollerOptions): { stop: () => void } {
|
|
21
|
+
const { client, accountId, cfg, intervalMs, abortSignal, log } = opts;
|
|
22
|
+
const dp = displayPrefix(accountId, cfg);
|
|
23
|
+
let running = true;
|
|
24
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
|
|
26
|
+
async function poll() {
|
|
27
|
+
if (!running || abortSignal?.aborted) return;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const resp = await client.pollInbox({ limit: 20, ack: true });
|
|
31
|
+
const messages = resp.messages || [];
|
|
32
|
+
|
|
33
|
+
for (const msg of messages) {
|
|
34
|
+
try {
|
|
35
|
+
await handleInboxMessage(msg, accountId, cfg);
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
log?.error(`[${dp}] failed to dispatch message ${msg.hub_msg_id}: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
if (!running) return;
|
|
42
|
+
log?.error(`[${dp}] poll error: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (running && !abortSignal?.aborted) {
|
|
46
|
+
timeoutId = setTimeout(poll, intervalMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stop() {
|
|
51
|
+
running = false;
|
|
52
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
53
|
+
activePollers.delete(accountId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Start first poll
|
|
57
|
+
timeoutId = setTimeout(poll, 500);
|
|
58
|
+
|
|
59
|
+
const entry = { stop };
|
|
60
|
+
activePollers.set(accountId, entry);
|
|
61
|
+
|
|
62
|
+
abortSignal?.addEventListener("abort", stop, { once: true });
|
|
63
|
+
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function stopPoller(accountId: string): void {
|
|
68
|
+
const poller = activePollers.get(accountId);
|
|
69
|
+
if (poller) poller.stop();
|
|
70
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin runtime store — holds a reference to OpenClaw's PluginRuntime
|
|
3
|
+
* and a config getter for tools/hooks that need the full app config.
|
|
4
|
+
*/
|
|
5
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
6
|
+
|
|
7
|
+
let runtime: PluginRuntime | null = null;
|
|
8
|
+
let configGetter: (() => any) | null = null;
|
|
9
|
+
|
|
10
|
+
export function setBotCordRuntime(rt: PluginRuntime): void {
|
|
11
|
+
runtime = rt;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getBotCordRuntime(): PluginRuntime {
|
|
15
|
+
if (!runtime) throw new Error("BotCord runtime not initialized");
|
|
16
|
+
return runtime;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setConfigGetter(fn: () => any): void {
|
|
20
|
+
configGetter = fn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getConfig(): any {
|
|
24
|
+
return configGetter?.() ?? null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic session key derivation.
|
|
3
|
+
* Must match hub/forward.py build_session_key() exactly.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// UUID v5 namespace — must match hub/constants.py SESSION_KEY_NAMESPACE
|
|
8
|
+
const SESSION_KEY_NAMESPACE = "d4e8f2a1-3b6c-4d5e-9f0a-1b2c3d4e5f6a";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* RFC 4122 UUID v5 (SHA-1 based, deterministic).
|
|
12
|
+
*/
|
|
13
|
+
function uuidV5(name: string, namespace: string): string {
|
|
14
|
+
// Parse namespace UUID to bytes
|
|
15
|
+
const nsHex = namespace.replace(/-/g, "");
|
|
16
|
+
const nsBytes = Buffer.from(nsHex, "hex");
|
|
17
|
+
|
|
18
|
+
const hash = createHash("sha1")
|
|
19
|
+
.update(nsBytes)
|
|
20
|
+
.update(Buffer.from(name, "utf-8"))
|
|
21
|
+
.digest();
|
|
22
|
+
|
|
23
|
+
// Set version (5) and variant (RFC 4122)
|
|
24
|
+
hash[6] = (hash[6] & 0x0f) | 0x50;
|
|
25
|
+
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
26
|
+
|
|
27
|
+
const hex = hash.subarray(0, 16).toString("hex");
|
|
28
|
+
return [
|
|
29
|
+
hex.slice(0, 8),
|
|
30
|
+
hex.slice(8, 12),
|
|
31
|
+
hex.slice(12, 16),
|
|
32
|
+
hex.slice(16, 20),
|
|
33
|
+
hex.slice(20, 32),
|
|
34
|
+
].join("-");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Derive a deterministic sessionKey from room_id, optional topic, and senderId.
|
|
39
|
+
* Same inputs always produce the same key.
|
|
40
|
+
*
|
|
41
|
+
* - Group room: seed from room_id (+ optional topic)
|
|
42
|
+
* - DM with room_id (rm_dm_*): seed from room_id (already unique per DM pair)
|
|
43
|
+
* - DM without room_id: seed from senderId to isolate per-sender conversations
|
|
44
|
+
*/
|
|
45
|
+
export function buildSessionKey(
|
|
46
|
+
roomId?: string,
|
|
47
|
+
topic?: string,
|
|
48
|
+
senderId?: string,
|
|
49
|
+
): string {
|
|
50
|
+
let seed: string;
|
|
51
|
+
if (roomId) {
|
|
52
|
+
seed = topic ? `${roomId}:${topic}` : roomId;
|
|
53
|
+
} else if (senderId) {
|
|
54
|
+
seed = `dm:${senderId}`;
|
|
55
|
+
} else {
|
|
56
|
+
seed = "default";
|
|
57
|
+
}
|
|
58
|
+
return `botcord:${uuidV5(seed, SESSION_KEY_NAMESPACE)}`;
|
|
59
|
+
}
|