@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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * TopicTracker — Agent-side Topic lifecycle state management.
3
+ *
4
+ * Implements the decision tree from the Topic lifecycle design doc:
5
+ * - No topic → one-way notification, don't auto-reply
6
+ * - Has topic + open → auto-reply OK
7
+ * - Has topic + terminated + has goal → reactivate to open, auto-reply OK
8
+ * - Has topic + terminated + no goal → don't auto-reply
9
+ * - type: result → mark completed
10
+ * - type: error → mark failed
11
+ * - TTL expiration → mark expired
12
+ */
13
+
14
+ export type TopicState = "open" | "completed" | "failed" | "expired";
15
+
16
+ export interface TopicInfo {
17
+ state: TopicState;
18
+ goal?: string;
19
+ lastActivityAt: number; // timestamp ms
20
+ ttlMs: number;
21
+ }
22
+
23
+ export interface HandleIncomingParams {
24
+ topic?: string | null;
25
+ goal?: string | null;
26
+ type: string; // message type: "message", "ack", "result", "error", etc.
27
+ }
28
+
29
+ export interface HandleIncomingResult {
30
+ shouldReply: boolean;
31
+ reason: string;
32
+ }
33
+
34
+ export class TopicTracker {
35
+ private topics = new Map<string, TopicInfo>();
36
+ private defaultTtlMs: number;
37
+
38
+ constructor(options?: { defaultTtlMs?: number }) {
39
+ this.defaultTtlMs = options?.defaultTtlMs ?? 3_600_000; // 1 hour default
40
+ }
41
+
42
+ /**
43
+ * Process an incoming message and return whether the agent should auto-reply.
44
+ *
45
+ * Decision tree:
46
+ * - No topic → one-way notification, don't auto-reply
47
+ * - type: "result" → mark completed, don't auto-reply (termination signal)
48
+ * - type: "error" → mark failed, don't auto-reply (termination signal)
49
+ * - Topic unseen → create as open, auto-reply
50
+ * - Topic open → update activity, auto-reply
51
+ * - Topic terminated (completed/failed/expired):
52
+ * - Message has goal → reactivate to open, auto-reply
53
+ * - Message has no goal → don't auto-reply
54
+ */
55
+ handleIncoming(params: HandleIncomingParams): HandleIncomingResult {
56
+ const { topic, goal, type } = params;
57
+
58
+ // No topic → one-way notification
59
+ if (!topic) {
60
+ return { shouldReply: false, reason: "no topic — treated as one-way notification" };
61
+ }
62
+
63
+ const now = Date.now();
64
+ const existing = this.topics.get(topic);
65
+
66
+ // Check if existing topic has expired by TTL
67
+ if (existing && existing.state === "open") {
68
+ if (now - existing.lastActivityAt > existing.ttlMs) {
69
+ existing.state = "expired";
70
+ }
71
+ }
72
+
73
+ // Termination signals: result or error
74
+ if (type === "result") {
75
+ this.upsertTopic(topic, "completed", goal ?? existing?.goal, now);
76
+ return { shouldReply: false, reason: "type is result — topic marked completed" };
77
+ }
78
+
79
+ if (type === "error") {
80
+ this.upsertTopic(topic, "failed", goal ?? existing?.goal, now);
81
+ return { shouldReply: false, reason: "type is error — topic marked failed" };
82
+ }
83
+
84
+ // Topic not seen before → create as open
85
+ if (!existing) {
86
+ this.upsertTopic(topic, "open", goal ?? undefined, now);
87
+ return { shouldReply: true, reason: "new topic created as open" };
88
+ }
89
+
90
+ // Topic is open → update activity, auto-reply OK
91
+ if (existing.state === "open") {
92
+ existing.lastActivityAt = now;
93
+ if (goal) existing.goal = goal;
94
+ return { shouldReply: true, reason: "topic is open — auto-reply allowed" };
95
+ }
96
+
97
+ // Topic is terminated (completed / failed / expired)
98
+ if (goal) {
99
+ // Reactivate with new goal
100
+ existing.state = "open";
101
+ existing.goal = goal;
102
+ existing.lastActivityAt = now;
103
+ return { shouldReply: true, reason: "terminated topic reactivated with new goal" };
104
+ }
105
+
106
+ // Terminated + no goal → don't auto-reply
107
+ return {
108
+ shouldReply: false,
109
+ reason: `topic is ${existing.state} and message has no goal — not auto-replying`,
110
+ };
111
+ }
112
+
113
+ /** Get current state of a topic, checking TTL expiration. */
114
+ getState(topicKey: string): TopicState | undefined {
115
+ const info = this.topics.get(topicKey);
116
+ if (!info) return undefined;
117
+
118
+ // Check TTL expiration for open topics
119
+ if (info.state === "open" && Date.now() - info.lastActivityAt > info.ttlMs) {
120
+ info.state = "expired";
121
+ }
122
+
123
+ return info.state;
124
+ }
125
+
126
+ /** Get full topic info. */
127
+ getTopicInfo(topicKey: string): TopicInfo | undefined {
128
+ const info = this.topics.get(topicKey);
129
+ if (!info) return undefined;
130
+
131
+ // Check TTL expiration for open topics
132
+ if (info.state === "open" && Date.now() - info.lastActivityAt > info.ttlMs) {
133
+ info.state = "expired";
134
+ }
135
+
136
+ return { ...info };
137
+ }
138
+
139
+ /** Mark a topic as completed (e.g., when sending a result message). */
140
+ markCompleted(topicKey: string): void {
141
+ const info = this.topics.get(topicKey);
142
+ if (info) {
143
+ info.state = "completed";
144
+ info.lastActivityAt = Date.now();
145
+ }
146
+ }
147
+
148
+ /** Mark a topic as failed (e.g., when sending an error message). */
149
+ markFailed(topicKey: string): void {
150
+ const info = this.topics.get(topicKey);
151
+ if (info) {
152
+ info.state = "failed";
153
+ info.lastActivityAt = Date.now();
154
+ }
155
+ }
156
+
157
+ /** Clean up expired topics. Returns the count of topics removed. */
158
+ sweepExpired(): number {
159
+ const now = Date.now();
160
+ let count = 0;
161
+
162
+ for (const [key, info] of this.topics) {
163
+ // Check TTL expiration for open topics
164
+ if (info.state === "open" && now - info.lastActivityAt > info.ttlMs) {
165
+ info.state = "expired";
166
+ }
167
+
168
+ if (info.state === "expired") {
169
+ this.topics.delete(key);
170
+ count++;
171
+ }
172
+ }
173
+
174
+ return count;
175
+ }
176
+
177
+ /** Return the number of tracked topics. */
178
+ get size(): number {
179
+ return this.topics.size;
180
+ }
181
+
182
+ // ── Internal helpers ────────────────────────────────────────────
183
+
184
+ private upsertTopic(
185
+ key: string,
186
+ state: TopicState,
187
+ goal: string | undefined,
188
+ now: number,
189
+ ): void {
190
+ const existing = this.topics.get(key);
191
+ if (existing) {
192
+ existing.state = state;
193
+ existing.lastActivityAt = now;
194
+ if (goal !== undefined) existing.goal = goal;
195
+ } else {
196
+ this.topics.set(key, {
197
+ state,
198
+ goal,
199
+ lastActivityAt: now,
200
+ ttlMs: this.defaultTtlMs,
201
+ });
202
+ }
203
+ }
204
+ }
package/src/types.ts ADDED
@@ -0,0 +1,273 @@
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 SourceType = "agent" | "dashboard_user_chat";
56
+
57
+ export type InboxMessage = {
58
+ hub_msg_id: string;
59
+ envelope: BotCordMessageEnvelope;
60
+ text?: string;
61
+ room_id?: string;
62
+ room_name?: string;
63
+ room_rule?: string | null;
64
+ room_member_count?: number;
65
+ room_member_names?: string[];
66
+ my_role?: string;
67
+ my_can_send?: boolean;
68
+ topic?: string;
69
+ topic_id?: string;
70
+ goal?: string;
71
+ mentioned?: boolean;
72
+ source_type?: SourceType;
73
+ source_user_id?: string | null;
74
+ source_session_kind?: string | null;
75
+ };
76
+
77
+ export type InboxPollResponse = {
78
+ messages: InboxMessage[];
79
+ count: number;
80
+ has_more: boolean;
81
+ };
82
+
83
+ // Hub API response types
84
+ export type SendResponse = {
85
+ queued: boolean;
86
+ hub_msg_id: string;
87
+ status: string;
88
+ topic_id?: string;
89
+ };
90
+
91
+ export type RoomInfo = {
92
+ room_id: string;
93
+ name: string;
94
+ description?: string;
95
+ rule?: string | null;
96
+ visibility: "private" | "public";
97
+ join_policy: "invite_only" | "open";
98
+ required_subscription_product_id?: string | null;
99
+ max_members?: number | null;
100
+ default_send: boolean;
101
+ default_invite?: boolean;
102
+ slow_mode_seconds?: number | null;
103
+ member_count: number;
104
+ created_at: string;
105
+ };
106
+
107
+ export type AgentInfo = {
108
+ agent_id: string;
109
+ display_name?: string;
110
+ bio?: string;
111
+ message_policy: string;
112
+ endpoints: Array<{ url: string; state: string }>;
113
+ };
114
+
115
+ export type ContactInfo = {
116
+ contact_agent_id: string;
117
+ display_name?: string;
118
+ created_at: string;
119
+ };
120
+
121
+ export type ContactRequestInfo = {
122
+ request_id: string;
123
+ from_agent_id: string;
124
+ to_agent_id: string;
125
+ state: "pending" | "accepted" | "rejected";
126
+ created_at: string;
127
+ };
128
+
129
+ // File upload response (mirrors hub/schemas.py FileUploadResponse)
130
+ export type FileUploadResponse = {
131
+ file_id: string;
132
+ url: string;
133
+ original_filename: string;
134
+ content_type: string;
135
+ size_bytes: number;
136
+ expires_at: string; // ISO 8601
137
+ };
138
+
139
+ // Attachment metadata included in message payloads
140
+ export type MessageAttachment = {
141
+ filename: string;
142
+ url: string;
143
+ content_type?: string;
144
+ size_bytes?: number;
145
+ };
146
+
147
+ // Wallet types (mirrors hub wallet schemas)
148
+
149
+ export type WalletSummary = {
150
+ agent_id: string;
151
+ asset_code: string;
152
+ available_balance_minor: string;
153
+ locked_balance_minor: string;
154
+ total_balance_minor: string;
155
+ updated_at: string;
156
+ };
157
+
158
+ export type WalletTransaction = {
159
+ tx_id: string;
160
+ type: "topup" | "withdrawal" | "transfer";
161
+ status: "pending" | "processing" | "completed" | "failed" | "cancelled";
162
+ asset_code: string;
163
+ amount_minor: string;
164
+ fee_minor: string;
165
+ from_agent_id: string | null;
166
+ to_agent_id: string | null;
167
+ reference_type: string | null;
168
+ reference_id: string | null;
169
+ idempotency_key: string | null;
170
+ metadata_json: string | null;
171
+ created_at: string;
172
+ updated_at: string;
173
+ completed_at: string | null;
174
+ };
175
+
176
+ export type WalletLedgerEntry = {
177
+ entry_id: string;
178
+ tx_id: string;
179
+ agent_id: string;
180
+ asset_code: string;
181
+ direction: "debit" | "credit";
182
+ amount_minor: string;
183
+ balance_after_minor: string;
184
+ created_at: string;
185
+ };
186
+
187
+ export type WalletLedgerResponse = {
188
+ entries: WalletLedgerEntry[];
189
+ next_cursor: string | null;
190
+ has_more: boolean;
191
+ };
192
+
193
+ export type TopupResponse = {
194
+ topup_id: string;
195
+ tx_id: string | null;
196
+ agent_id: string;
197
+ asset_code: string;
198
+ amount_minor: string;
199
+ status: string;
200
+ channel: string;
201
+ created_at: string;
202
+ completed_at: string | null;
203
+ };
204
+
205
+ export type WithdrawalResponse = {
206
+ withdrawal_id: string;
207
+ tx_id: string | null;
208
+ agent_id: string;
209
+ asset_code: string;
210
+ amount_minor: string;
211
+ fee_minor: string;
212
+ status: string;
213
+ destination_type: string | null;
214
+ review_note: string | null;
215
+ created_at: string;
216
+ reviewed_at: string | null;
217
+ completed_at: string | null;
218
+ };
219
+
220
+ export type BillingInterval = "week" | "month";
221
+
222
+ export type SubscriptionProductStatus = "active" | "archived";
223
+
224
+ export type SubscriptionStatus = "active" | "past_due" | "cancelled";
225
+
226
+ export type SubscriptionChargeAttemptStatus = "pending" | "succeeded" | "failed";
227
+
228
+ export type SubscriptionProduct = {
229
+ product_id: string;
230
+ owner_agent_id: string;
231
+ name: string;
232
+ description: string;
233
+ asset_code: string;
234
+ amount_minor: string;
235
+ billing_interval: BillingInterval;
236
+ status: SubscriptionProductStatus;
237
+ created_at: string;
238
+ updated_at: string;
239
+ archived_at: string | null;
240
+ };
241
+
242
+ export type Subscription = {
243
+ subscription_id: string;
244
+ product_id: string;
245
+ subscriber_agent_id: string;
246
+ provider_agent_id: string;
247
+ asset_code: string;
248
+ amount_minor: string;
249
+ billing_interval: BillingInterval;
250
+ status: SubscriptionStatus;
251
+ current_period_start: string;
252
+ current_period_end: string;
253
+ next_charge_at: string;
254
+ cancel_at_period_end: boolean;
255
+ cancelled_at: string | null;
256
+ last_charged_at: string | null;
257
+ last_charge_tx_id: string | null;
258
+ consecutive_failed_attempts: number;
259
+ created_at: string;
260
+ updated_at: string;
261
+ };
262
+
263
+ export type SubscriptionChargeAttempt = {
264
+ attempt_id: string;
265
+ subscription_id: string;
266
+ billing_cycle_key: string;
267
+ status: SubscriptionChargeAttemptStatus;
268
+ scheduled_at: string;
269
+ attempted_at: string | null;
270
+ tx_id: string | null;
271
+ failure_reason: string | null;
272
+ created_at: string;
273
+ };
@@ -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
+ import { buildHubWebSocketUrl } from "./hub-url.js";
17
+
18
+ interface WsClientOptions {
19
+ client: BotCordClient;
20
+ accountId: string;
21
+ cfg: any;
22
+ abortSignal?: AbortSignal;
23
+ log?: {
24
+ info: (msg: string) => void;
25
+ warn: (msg: string) => void;
26
+ error: (msg: string) => void;
27
+ };
28
+ }
29
+
30
+ const activeWsClients = new Map<string, { stop: () => void }>();
31
+
32
+ // Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s max
33
+ const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
34
+
35
+ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
36
+ // Stop any existing client for this account before creating a new one
37
+ const existing = activeWsClients.get(opts.accountId);
38
+ if (existing) existing.stop();
39
+
40
+ const { client, accountId, cfg, abortSignal, log } = opts;
41
+ const dp = displayPrefix(accountId, cfg);
42
+ let running = true;
43
+ let ws: WebSocket | null = null;
44
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
45
+ let reconnectAttempt = 0;
46
+ let processing = false;
47
+ let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
48
+ const KEEPALIVE_INTERVAL = 20_000; // 20s — well under Caddy/proxy 30s timeout
49
+
50
+ async function fetchAndDispatch() {
51
+ if (processing) return;
52
+ processing = true;
53
+ try {
54
+ const resp = await client.pollInbox({ limit: 20, ack: true });
55
+ const messages = resp.messages || [];
56
+ for (const msg of messages) {
57
+ try {
58
+ await handleInboxMessage(msg, accountId, cfg);
59
+ } catch (err: any) {
60
+ log?.error(`[${dp}] ws dispatch error for ${msg.hub_msg_id}: ${err.message}`);
61
+ }
62
+ }
63
+ } catch (err: any) {
64
+ log?.error(`[${dp}] ws poll error: ${err.message}`);
65
+ } finally {
66
+ processing = false;
67
+ }
68
+ }
69
+
70
+ async function connect() {
71
+ if (!running || abortSignal?.aborted) return;
72
+
73
+ try {
74
+ // Get a fresh JWT token
75
+ const token = await client.ensureToken();
76
+ const hubUrl = client.getHubUrl();
77
+ const wsUrl = buildHubWebSocketUrl(hubUrl);
78
+
79
+ log?.info(`[${dp}] WebSocket connecting to ${wsUrl}`);
80
+ ws = new WebSocket(wsUrl);
81
+
82
+ ws.on("open", () => {
83
+ // Send auth message
84
+ ws!.send(JSON.stringify({ type: "auth", token }));
85
+ });
86
+
87
+ ws.on("message", async (data: WebSocket.Data) => {
88
+ try {
89
+ const msg = JSON.parse(data.toString());
90
+ switch (msg.type) {
91
+ case "auth_ok":
92
+ log?.info(`[${dp}] WebSocket authenticated as ${msg.agent_id}`);
93
+ reconnectAttempt = 0; // Reset backoff on successful auth
94
+ // Start client-side keepalive to survive proxies/Caddy timeouts
95
+ if (keepaliveTimer) clearInterval(keepaliveTimer);
96
+ keepaliveTimer = setInterval(() => {
97
+ if (ws?.readyState === WebSocket.OPEN) {
98
+ ws.send(JSON.stringify({ type: "ping" }));
99
+ }
100
+ }, KEEPALIVE_INTERVAL);
101
+ break;
102
+
103
+ case "inbox_update":
104
+ // New messages available — fetch them
105
+ await fetchAndDispatch();
106
+ break;
107
+
108
+ case "heartbeat":
109
+ // Respond with ping to keep alive
110
+ ws?.send(JSON.stringify({ type: "ping" }));
111
+ break;
112
+
113
+ case "pong":
114
+ // Server responded to our ping
115
+ break;
116
+
117
+ default:
118
+ log?.warn(`[${dp}] unknown ws message type: ${msg.type}`);
119
+ }
120
+ } catch (err: any) {
121
+ log?.error(`[${dp}] ws message parse error: ${err.message}`);
122
+ }
123
+ });
124
+
125
+ ws.on("close", (code: number, reason: Buffer) => {
126
+ const reasonStr = reason.toString();
127
+ log?.info(`[${dp}] WebSocket closed: code=${code} reason=${reasonStr}`);
128
+ ws = null;
129
+ if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
130
+
131
+ if (code === 4001) {
132
+ // Auth failure — don't reconnect immediately, token may need refresh
133
+ log?.warn(`[${dp}] WebSocket auth failed, will retry with fresh token`);
134
+ }
135
+
136
+ scheduleReconnect();
137
+ });
138
+
139
+ ws.on("error", (err: Error) => {
140
+ log?.error(`[${dp}] WebSocket error: ${err.message}`);
141
+ // 'close' event will fire after this, triggering reconnect
142
+ });
143
+ } catch (err: any) {
144
+ log?.error(`[${dp}] WebSocket connect failed: ${err.message}`);
145
+ scheduleReconnect();
146
+ }
147
+ }
148
+
149
+ function scheduleReconnect() {
150
+ if (!running || abortSignal?.aborted) return;
151
+ const delay =
152
+ RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
153
+ reconnectAttempt++;
154
+ log?.info(`[${dp}] WebSocket reconnecting in ${delay}ms (attempt ${reconnectAttempt})`);
155
+ reconnectTimer = setTimeout(connect, delay);
156
+ }
157
+
158
+ function stop() {
159
+ running = false;
160
+ if (reconnectTimer) clearTimeout(reconnectTimer);
161
+ if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
162
+ if (ws) {
163
+ try {
164
+ ws.close(1000, "client shutdown");
165
+ } catch {
166
+ // ignore
167
+ }
168
+ ws = null;
169
+ }
170
+ activeWsClients.delete(accountId);
171
+ }
172
+
173
+ // Start connection
174
+ connect();
175
+
176
+ const entry = { stop };
177
+ activeWsClients.set(accountId, entry);
178
+
179
+ abortSignal?.addEventListener("abort", stop, { once: true });
180
+
181
+ return entry;
182
+ }
183
+
184
+ export function stopWsClient(accountId: string): void {
185
+ const entry = activeWsClients.get(accountId);
186
+ if (entry) entry.stop();
187
+ }