@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/types.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// BotCord protocol types (mirrors hub/schemas.py)
|
|
2
|
+
|
|
3
|
+
export type BotCordSignature = {
|
|
4
|
+
alg: "ed25519";
|
|
5
|
+
key_id: string;
|
|
6
|
+
value: string; // base64
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type MessageType =
|
|
10
|
+
| "message"
|
|
11
|
+
| "ack"
|
|
12
|
+
| "result"
|
|
13
|
+
| "error"
|
|
14
|
+
| "contact_request"
|
|
15
|
+
| "contact_request_response"
|
|
16
|
+
| "contact_removed"
|
|
17
|
+
| "system";
|
|
18
|
+
|
|
19
|
+
export type BotCordMessageEnvelope = {
|
|
20
|
+
v: string;
|
|
21
|
+
msg_id: string;
|
|
22
|
+
ts: number;
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
type: MessageType;
|
|
26
|
+
reply_to: string | null;
|
|
27
|
+
ttl_sec: number;
|
|
28
|
+
topic?: string | null;
|
|
29
|
+
goal?: string | null;
|
|
30
|
+
payload: Record<string, unknown>;
|
|
31
|
+
payload_hash: string;
|
|
32
|
+
sig: BotCordSignature;
|
|
33
|
+
mentions?: string[] | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Account config in openclaw.json channels.botcord
|
|
37
|
+
export type BotCordAccountConfig = {
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
credentialsFile?: string;
|
|
40
|
+
hubUrl?: string;
|
|
41
|
+
agentId?: string;
|
|
42
|
+
keyId?: string;
|
|
43
|
+
privateKey?: string;
|
|
44
|
+
publicKey?: string;
|
|
45
|
+
deliveryMode?: "polling" | "websocket";
|
|
46
|
+
pollIntervalMs?: number;
|
|
47
|
+
allowFrom?: string[];
|
|
48
|
+
notifySession?: string;
|
|
49
|
+
accounts?: Record<string, BotCordAccountConfig>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type BotCordChannelConfig = BotCordAccountConfig;
|
|
53
|
+
|
|
54
|
+
// Inbox poll response
|
|
55
|
+
export type InboxMessage = {
|
|
56
|
+
hub_msg_id: string;
|
|
57
|
+
envelope: BotCordMessageEnvelope;
|
|
58
|
+
text?: string;
|
|
59
|
+
room_id?: string;
|
|
60
|
+
room_name?: string;
|
|
61
|
+
room_member_count?: number;
|
|
62
|
+
room_member_names?: string[];
|
|
63
|
+
my_role?: string;
|
|
64
|
+
my_can_send?: boolean;
|
|
65
|
+
topic?: string;
|
|
66
|
+
topic_id?: string;
|
|
67
|
+
goal?: string;
|
|
68
|
+
mentioned?: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type InboxPollResponse = {
|
|
72
|
+
messages: InboxMessage[];
|
|
73
|
+
count: number;
|
|
74
|
+
has_more: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Hub API response types
|
|
78
|
+
export type SendResponse = {
|
|
79
|
+
queued: boolean;
|
|
80
|
+
hub_msg_id: string;
|
|
81
|
+
status: string;
|
|
82
|
+
topic_id?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type RoomInfo = {
|
|
86
|
+
room_id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
description?: string;
|
|
89
|
+
visibility: "private" | "public";
|
|
90
|
+
join_policy: "invite_only" | "open";
|
|
91
|
+
default_send: boolean;
|
|
92
|
+
member_count: number;
|
|
93
|
+
created_at: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type AgentInfo = {
|
|
97
|
+
agent_id: string;
|
|
98
|
+
display_name?: string;
|
|
99
|
+
bio?: string;
|
|
100
|
+
message_policy: string;
|
|
101
|
+
endpoints: Array<{ url: string; state: string }>;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type ContactInfo = {
|
|
105
|
+
contact_agent_id: string;
|
|
106
|
+
display_name?: string;
|
|
107
|
+
created_at: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type ContactRequestInfo = {
|
|
111
|
+
request_id: string;
|
|
112
|
+
from_agent_id: string;
|
|
113
|
+
to_agent_id: string;
|
|
114
|
+
state: "pending" | "accepted" | "rejected";
|
|
115
|
+
created_at: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// File upload response (mirrors hub/schemas.py FileUploadResponse)
|
|
119
|
+
export type FileUploadResponse = {
|
|
120
|
+
file_id: string;
|
|
121
|
+
url: string;
|
|
122
|
+
original_filename: string;
|
|
123
|
+
content_type: string;
|
|
124
|
+
size_bytes: number;
|
|
125
|
+
expires_at: string; // ISO 8601
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Attachment metadata included in message payloads
|
|
129
|
+
export type MessageAttachment = {
|
|
130
|
+
filename: string;
|
|
131
|
+
url: string;
|
|
132
|
+
content_type?: string;
|
|
133
|
+
size_bytes?: number;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Wallet types (mirrors hub wallet schemas)
|
|
137
|
+
|
|
138
|
+
export type WalletSummary = {
|
|
139
|
+
agent_id: string;
|
|
140
|
+
asset_code: string;
|
|
141
|
+
available_balance_minor: string;
|
|
142
|
+
locked_balance_minor: string;
|
|
143
|
+
total_balance_minor: string;
|
|
144
|
+
updated_at: string;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type WalletTransaction = {
|
|
148
|
+
tx_id: string;
|
|
149
|
+
type: "topup" | "withdrawal" | "transfer";
|
|
150
|
+
status: "pending" | "processing" | "completed" | "failed" | "cancelled";
|
|
151
|
+
asset_code: string;
|
|
152
|
+
amount_minor: string;
|
|
153
|
+
fee_minor: string;
|
|
154
|
+
from_agent_id: string | null;
|
|
155
|
+
to_agent_id: string | null;
|
|
156
|
+
metadata_json: string | null;
|
|
157
|
+
created_at: string;
|
|
158
|
+
completed_at: string | null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export type WalletLedgerEntry = {
|
|
162
|
+
entry_id: string;
|
|
163
|
+
tx_id: string;
|
|
164
|
+
agent_id: string;
|
|
165
|
+
asset_code: string;
|
|
166
|
+
direction: "debit" | "credit";
|
|
167
|
+
amount_minor: string;
|
|
168
|
+
balance_after_minor: string;
|
|
169
|
+
created_at: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type WalletLedgerResponse = {
|
|
173
|
+
entries: WalletLedgerEntry[];
|
|
174
|
+
next_cursor: string | null;
|
|
175
|
+
has_more: boolean;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export type TopupResponse = {
|
|
179
|
+
topup_id: string;
|
|
180
|
+
tx_id: string | null;
|
|
181
|
+
agent_id: string;
|
|
182
|
+
asset_code: string;
|
|
183
|
+
amount_minor: string;
|
|
184
|
+
status: string;
|
|
185
|
+
channel: string;
|
|
186
|
+
created_at: string;
|
|
187
|
+
completed_at: string | null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export type WithdrawalResponse = {
|
|
191
|
+
withdrawal_id: string;
|
|
192
|
+
tx_id: string | null;
|
|
193
|
+
agent_id: string;
|
|
194
|
+
asset_code: string;
|
|
195
|
+
amount_minor: string;
|
|
196
|
+
fee_minor: string;
|
|
197
|
+
status: string;
|
|
198
|
+
destination_type: string | null;
|
|
199
|
+
review_note: string | null;
|
|
200
|
+
created_at: string;
|
|
201
|
+
reviewed_at: string | null;
|
|
202
|
+
completed_at: string | null;
|
|
203
|
+
};
|
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket client for real-time BotCord Hub inbox notifications.
|
|
3
|
+
*
|
|
4
|
+
* Protocol:
|
|
5
|
+
* 1. Connect to ws(s)://<hubUrl>/hub/ws
|
|
6
|
+
* 2. Send {"type": "auth", "token": "<JWT>"}
|
|
7
|
+
* 3. Receive {"type": "auth_ok", "agent_id": "ag_xxx"}
|
|
8
|
+
* 4. Receive {"type": "inbox_update"} when new messages arrive
|
|
9
|
+
* 5. On inbox_update → poll /hub/inbox to fetch messages
|
|
10
|
+
* 6. Receive {"type": "heartbeat"} every 30s (keepalive)
|
|
11
|
+
*/
|
|
12
|
+
import WebSocket from "ws";
|
|
13
|
+
import { BotCordClient } from "./client.js";
|
|
14
|
+
import { handleInboxMessage } from "./inbound.js";
|
|
15
|
+
import { displayPrefix } from "./config.js";
|
|
16
|
+
|
|
17
|
+
interface WsClientOptions {
|
|
18
|
+
client: BotCordClient;
|
|
19
|
+
accountId: string;
|
|
20
|
+
cfg: any;
|
|
21
|
+
abortSignal?: AbortSignal;
|
|
22
|
+
log?: {
|
|
23
|
+
info: (msg: string) => void;
|
|
24
|
+
warn: (msg: string) => void;
|
|
25
|
+
error: (msg: string) => void;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const activeWsClients = new Map<string, { stop: () => void }>();
|
|
30
|
+
|
|
31
|
+
// Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
|
32
|
+
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
33
|
+
|
|
34
|
+
export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
35
|
+
// Stop any existing client for this account before creating a new one
|
|
36
|
+
const existing = activeWsClients.get(opts.accountId);
|
|
37
|
+
if (existing) existing.stop();
|
|
38
|
+
|
|
39
|
+
const { client, accountId, cfg, abortSignal, log } = opts;
|
|
40
|
+
const dp = displayPrefix(accountId, cfg);
|
|
41
|
+
let running = true;
|
|
42
|
+
let ws: WebSocket | null = null;
|
|
43
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
let reconnectAttempt = 0;
|
|
45
|
+
let processing = false;
|
|
46
|
+
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
const KEEPALIVE_INTERVAL = 20_000; // 20s — well under Caddy/proxy 30s timeout
|
|
48
|
+
|
|
49
|
+
async function fetchAndDispatch() {
|
|
50
|
+
if (processing) return;
|
|
51
|
+
processing = true;
|
|
52
|
+
try {
|
|
53
|
+
const resp = await client.pollInbox({ limit: 20, ack: true });
|
|
54
|
+
const messages = resp.messages || [];
|
|
55
|
+
for (const msg of messages) {
|
|
56
|
+
try {
|
|
57
|
+
await handleInboxMessage(msg, accountId, cfg);
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
log?.error(`[${dp}] ws dispatch error for ${msg.hub_msg_id}: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
log?.error(`[${dp}] ws poll error: ${err.message}`);
|
|
64
|
+
} finally {
|
|
65
|
+
processing = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function connect() {
|
|
70
|
+
if (!running || abortSignal?.aborted) return;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Get a fresh JWT token
|
|
74
|
+
const token = await client.ensureToken();
|
|
75
|
+
const hubUrl = client.getHubUrl();
|
|
76
|
+
const wsUrl = hubUrl.replace(/^http/, "ws") + "/hub/ws";
|
|
77
|
+
|
|
78
|
+
log?.info(`[${dp}] WebSocket connecting to ${wsUrl}`);
|
|
79
|
+
ws = new WebSocket(wsUrl);
|
|
80
|
+
|
|
81
|
+
ws.on("open", () => {
|
|
82
|
+
// Send auth message
|
|
83
|
+
ws!.send(JSON.stringify({ type: "auth", token }));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
ws.on("message", async (data: WebSocket.Data) => {
|
|
87
|
+
try {
|
|
88
|
+
const msg = JSON.parse(data.toString());
|
|
89
|
+
switch (msg.type) {
|
|
90
|
+
case "auth_ok":
|
|
91
|
+
log?.info(`[${dp}] WebSocket authenticated as ${msg.agent_id}`);
|
|
92
|
+
reconnectAttempt = 0; // Reset backoff on successful auth
|
|
93
|
+
// Start client-side keepalive to survive proxies/Caddy timeouts
|
|
94
|
+
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
95
|
+
keepaliveTimer = setInterval(() => {
|
|
96
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
97
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
98
|
+
}
|
|
99
|
+
}, KEEPALIVE_INTERVAL);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case "inbox_update":
|
|
103
|
+
// New messages available — fetch them
|
|
104
|
+
await fetchAndDispatch();
|
|
105
|
+
break;
|
|
106
|
+
|
|
107
|
+
case "heartbeat":
|
|
108
|
+
// Respond with ping to keep alive
|
|
109
|
+
ws?.send(JSON.stringify({ type: "ping" }));
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case "pong":
|
|
113
|
+
// Server responded to our ping
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
default:
|
|
117
|
+
log?.warn(`[${dp}] unknown ws message type: ${msg.type}`);
|
|
118
|
+
}
|
|
119
|
+
} catch (err: any) {
|
|
120
|
+
log?.error(`[${dp}] ws message parse error: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
ws.on("close", (code: number, reason: Buffer) => {
|
|
125
|
+
const reasonStr = reason.toString();
|
|
126
|
+
log?.info(`[${dp}] WebSocket closed: code=${code} reason=${reasonStr}`);
|
|
127
|
+
ws = null;
|
|
128
|
+
if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
|
|
129
|
+
|
|
130
|
+
if (code === 4001) {
|
|
131
|
+
// Auth failure — don't reconnect immediately, token may need refresh
|
|
132
|
+
log?.warn(`[${dp}] WebSocket auth failed, will retry with fresh token`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
scheduleReconnect();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
ws.on("error", (err: Error) => {
|
|
139
|
+
log?.error(`[${dp}] WebSocket error: ${err.message}`);
|
|
140
|
+
// 'close' event will fire after this, triggering reconnect
|
|
141
|
+
});
|
|
142
|
+
} catch (err: any) {
|
|
143
|
+
log?.error(`[${dp}] WebSocket connect failed: ${err.message}`);
|
|
144
|
+
scheduleReconnect();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scheduleReconnect() {
|
|
149
|
+
if (!running || abortSignal?.aborted) return;
|
|
150
|
+
const delay =
|
|
151
|
+
RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
|
|
152
|
+
reconnectAttempt++;
|
|
153
|
+
log?.info(`[${dp}] WebSocket reconnecting in ${delay}ms (attempt ${reconnectAttempt})`);
|
|
154
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stop() {
|
|
158
|
+
running = false;
|
|
159
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
160
|
+
if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
|
|
161
|
+
if (ws) {
|
|
162
|
+
try {
|
|
163
|
+
ws.close(1000, "client shutdown");
|
|
164
|
+
} catch {
|
|
165
|
+
// ignore
|
|
166
|
+
}
|
|
167
|
+
ws = null;
|
|
168
|
+
}
|
|
169
|
+
activeWsClients.delete(accountId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Start connection
|
|
173
|
+
connect();
|
|
174
|
+
|
|
175
|
+
const entry = { stop };
|
|
176
|
+
activeWsClients.set(accountId, entry);
|
|
177
|
+
|
|
178
|
+
abortSignal?.addEventListener("abort", stop, { once: true });
|
|
179
|
+
|
|
180
|
+
return entry;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function stopWsClient(accountId: string): void {
|
|
184
|
+
const entry = activeWsClients.get(accountId);
|
|
185
|
+
if (entry) entry.stop();
|
|
186
|
+
}
|
|
187
|
+
|