@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/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
+ };
@@ -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
+