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