@occum-net/occumclaw 0.1.0

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/api.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared HTTP helpers for authenticated requests to the occum.net API.
3
+ */
4
+
5
+ export interface ToolConfig {
6
+ agentToken: string;
7
+ apiUrl: string;
8
+ }
9
+
10
+ /** Make an authenticated request to the occum.net API. */
11
+ export async function apiRequest(
12
+ config: ToolConfig,
13
+ path: string,
14
+ opts?: RequestInit
15
+ ): Promise<any> {
16
+ const res = await fetch(`${config.apiUrl}${path}`, {
17
+ ...opts,
18
+ headers: {
19
+ "Content-Type": "application/json",
20
+ Authorization: `Bearer ${config.agentToken}`,
21
+ ...opts?.headers,
22
+ },
23
+ });
24
+
25
+ if (!res.ok) {
26
+ const body = await res.text().catch(() => "");
27
+ throw new Error(`API ${res.status}: ${body}`);
28
+ }
29
+
30
+ return res.json();
31
+ }
package/index.ts ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * @occum-net/occumclaw — OpenClaw Channel Plugin
3
+ *
4
+ * Registers occum.net as a channel in OpenClaw, following the same pattern
5
+ * as the Telegram connector. When a message arrives on the agent's control
6
+ * channel via WebSocket, it is dispatched through OpenClaw's full agent
7
+ * pipeline (routing → LLM → response) and the reply is POSTed back to
8
+ * occum.net.
9
+ *
10
+ * Configuration (set via `openclaw config set`):
11
+ * channels.occum.accounts.default.agentToken — Agent bearer token (occ_...)
12
+ * channels.occum.accounts.default.apiUrl — API base URL (default: https://api.occum.net)
13
+ *
14
+ * Legacy config path (auto-migrated):
15
+ * plugins.entries.occumclaw.config.agentToken
16
+ * plugins.entries.occumclaw.config.apiUrl
17
+ */
18
+
19
+ import { OccumWsClient, type OccumEvent, type AuthOkData } from "./ws-client";
20
+ import { apiRequest, type ToolConfig } from "./api";
21
+ import { createSendMessageTool, createListChannelsTool, createGetEventsTool } from "./tools";
22
+
23
+ // ─── OpenClaw Plugin SDK types (inline to avoid hard dependency) ────────────
24
+
25
+ interface PluginLogger {
26
+ info(msg: string, ...args: any[]): void;
27
+ warn(msg: string, ...args: any[]): void;
28
+ error(msg: string, ...args: any[]): void;
29
+ }
30
+
31
+ interface ToolContext {
32
+ config: Record<string, any>;
33
+ }
34
+
35
+ /** Minimal MsgContext — only the fields we need for dispatch. */
36
+ interface MsgContext {
37
+ Body?: string;
38
+ BodyForAgent?: string;
39
+ CommandBody?: string;
40
+ From?: string;
41
+ To?: string;
42
+ SessionKey?: string;
43
+ AccountId?: string;
44
+ Provider?: string;
45
+ ChatType?: string;
46
+ SenderId?: string;
47
+ SenderName?: string;
48
+ Timestamp?: number;
49
+ CommandAuthorized?: boolean;
50
+ MessageSid?: string;
51
+ }
52
+
53
+ interface ReplyPayload {
54
+ text?: string;
55
+ mediaUrl?: string;
56
+ mediaUrls?: string[];
57
+ isError?: boolean;
58
+ }
59
+
60
+ interface ReplyDispatchKindInfo {
61
+ kind: "tool" | "block" | "final";
62
+ }
63
+
64
+ interface DispatchParams {
65
+ ctx: MsgContext;
66
+ cfg: any;
67
+ dispatcherOptions: {
68
+ deliver: (payload: ReplyPayload, info: ReplyDispatchKindInfo) => Promise<void>;
69
+ onError?: (err: unknown, info: ReplyDispatchKindInfo) => void;
70
+ };
71
+ }
72
+
73
+ interface ChannelRuntime {
74
+ reply: {
75
+ dispatchReplyWithBufferedBlockDispatcher: (params: DispatchParams) => Promise<any>;
76
+ };
77
+ routing: {
78
+ buildAgentSessionKey: (...args: any[]) => string;
79
+ resolveAgentRoute: (...args: any[]) => any;
80
+ };
81
+ session: {
82
+ recordInboundSession: (...args: any[]) => any;
83
+ };
84
+ }
85
+
86
+ interface ChannelLogSink {
87
+ info?(msg: string): void;
88
+ warn?(msg: string): void;
89
+ error?(msg: string): void;
90
+ }
91
+
92
+ interface ChannelGatewayContext {
93
+ cfg: any;
94
+ accountId: string;
95
+ account: OccumAccount;
96
+ runtime: any;
97
+ abortSignal: AbortSignal;
98
+ log?: ChannelLogSink;
99
+ getStatus: () => any;
100
+ setStatus: (next: any) => void;
101
+ channelRuntime?: ChannelRuntime;
102
+ }
103
+
104
+ interface OpenClawPluginApi {
105
+ logger: PluginLogger;
106
+ runtime?: any;
107
+ registerTool(
108
+ factory: (ctx: ToolContext) => any,
109
+ opts: { name: string; optional: boolean }
110
+ ): void;
111
+ registerChannel(registration: { plugin: any }): void;
112
+ }
113
+
114
+ // ─── Account Config ─────────────────────────────────────────────────────────
115
+
116
+ interface OccumAccount {
117
+ agentToken: string;
118
+ apiUrl: string;
119
+ }
120
+
121
+ function resolveApiUrl(raw?: string): string {
122
+ return (raw ?? "https://api.occum.net").replace(/\/+$/, "") + "/v1";
123
+ }
124
+
125
+ /**
126
+ * Resolve account config from OpenClaw config.
127
+ * Supports both channel-style config and legacy plugin config.
128
+ */
129
+ function resolveAccount(cfg: any, accountId?: string | null): OccumAccount {
130
+ // Channel-style: channels.occum.accounts.<accountId>
131
+ const acctId = accountId ?? "default";
132
+ const channelAcct = cfg?.channels?.occum?.accounts?.[acctId];
133
+ if (channelAcct?.agentToken) {
134
+ return {
135
+ agentToken: channelAcct.agentToken,
136
+ apiUrl: resolveApiUrl(channelAcct.apiUrl),
137
+ };
138
+ }
139
+
140
+ // Plugin config: plugins.entries.occumclaw.config
141
+ const pluginCfg = cfg?.plugins?.entries?.occumclaw?.config;
142
+ if (pluginCfg?.agentToken) {
143
+ return {
144
+ agentToken: pluginCfg.agentToken,
145
+ apiUrl: resolveApiUrl(pluginCfg.apiUrl),
146
+ };
147
+ }
148
+
149
+ return { agentToken: "", apiUrl: resolveApiUrl() };
150
+ }
151
+
152
+ function listAccountIds(cfg: any): string[] {
153
+ // Channel-style accounts
154
+ const channelAccounts = cfg?.channels?.occum?.accounts;
155
+ if (channelAccounts && typeof channelAccounts === "object") {
156
+ return Object.keys(channelAccounts);
157
+ }
158
+ // Plugin config: single implicit account
159
+ const pluginCfg = cfg?.plugins?.entries?.occumclaw?.config;
160
+ if (pluginCfg?.agentToken) {
161
+ return ["default"];
162
+ }
163
+ return [];
164
+ }
165
+
166
+ // ─── Channel Plugin ─────────────────────────────────────────────────────────
167
+
168
+ function createOccumChannelPlugin(logger: PluginLogger) {
169
+ return {
170
+ id: "occum" as const,
171
+
172
+ meta: {
173
+ id: "occum" as const,
174
+ label: "Occum.net",
175
+ selectionLabel: "Occum.net",
176
+ docsPath: "/docs/occum",
177
+ blurb: "Connect to occum.net for bidirectional agent communication",
178
+ },
179
+
180
+ capabilities: {
181
+ chatTypes: ["direct" as const],
182
+ },
183
+
184
+ config: {
185
+ listAccountIds: (cfg: any) => listAccountIds(cfg),
186
+ resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
187
+ isConfigured: (account: OccumAccount) => !!account.agentToken,
188
+ isEnabled: (account: OccumAccount) => !!account.agentToken,
189
+ },
190
+
191
+ outbound: {
192
+ deliveryMode: "direct" as const,
193
+
194
+ async sendText(ctx: {
195
+ cfg: any;
196
+ to: string;
197
+ text: string;
198
+ accountId?: string | null;
199
+ }) {
200
+ const account = resolveAccount(ctx.cfg, ctx.accountId);
201
+ const config: ToolConfig = { agentToken: account.agentToken, apiUrl: account.apiUrl };
202
+
203
+ // `to` is the occum.net channel ID (UUIDv7 string)
204
+ const channelId = ctx.to;
205
+
206
+ const event = await apiRequest(config, `/channels/${channelId}/messages`, {
207
+ method: "POST",
208
+ body: JSON.stringify({ text: ctx.text }),
209
+ });
210
+
211
+ return {
212
+ channel: "occum",
213
+ messageId: String(event.id),
214
+ channelId,
215
+ };
216
+ },
217
+ },
218
+
219
+ gateway: {
220
+ async startAccount(ctx: ChannelGatewayContext) {
221
+ const { account, cfg, abortSignal, log } = ctx;
222
+
223
+ if (!account.agentToken) {
224
+ log?.warn?.("No agentToken configured — skipping occum.net connection");
225
+ return;
226
+ }
227
+
228
+ if (!ctx.channelRuntime) {
229
+ log?.warn?.("channelRuntime not available — cannot dispatch to agent");
230
+ log?.warn?.("Requires OpenClaw Plugin SDK 2026.2.19+");
231
+ return;
232
+ }
233
+
234
+ const channelRuntime = ctx.channelRuntime;
235
+ const config: ToolConfig = { agentToken: account.agentToken, apiUrl: account.apiUrl };
236
+
237
+ // Discover agent identity
238
+ let agentId: string | null = null;
239
+ let controlChannelId: string | null = null;
240
+ try {
241
+ const me = await apiRequest(config, "/agents/me");
242
+ agentId = me.id;
243
+ controlChannelId = me.controlChannelId;
244
+ log?.info?.(`Agent identity: ${me.name} (${agentId}), control channel #${controlChannelId}`);
245
+ } catch (err) {
246
+ log?.warn?.(`Failed to discover agent identity: ${err}`);
247
+ }
248
+
249
+ ctx.setStatus({
250
+ accountId: ctx.accountId,
251
+ enabled: true,
252
+ configured: true,
253
+ running: true,
254
+ connected: false,
255
+ });
256
+
257
+ // Track processed events to avoid re-dispatch
258
+ const processedEvents = new Set<string>();
259
+
260
+ const wsClient = new OccumWsClient({
261
+ apiUrl: account.apiUrl,
262
+ agentToken: account.agentToken,
263
+ log: (msg) => log?.info?.(`[ws] ${msg}`),
264
+
265
+ onConnected: (data: AuthOkData) => {
266
+ agentId = data.agentId;
267
+ log?.info?.(`Authenticated as agent ${data.agentId}`);
268
+ log?.info?.(`Subscribed to ${data.channels.length} channel(s)`);
269
+ ctx.setStatus({
270
+ accountId: ctx.accountId,
271
+ enabled: true,
272
+ configured: true,
273
+ running: true,
274
+ connected: true,
275
+ });
276
+ },
277
+
278
+ onEvent: (event: OccumEvent) => {
279
+ if (event.eventType !== "message") return;
280
+
281
+ // Don't respond to own messages
282
+ if (event.senderType === "agent" && event.senderAgentId === agentId) return;
283
+
284
+ // Dedup
285
+ if (processedEvents.has(event.id)) return;
286
+ processedEvents.add(event.id);
287
+ // Cap the dedup set
288
+ if (processedEvents.size > 1000) {
289
+ const first = processedEvents.values().next().value;
290
+ if (first) processedEvents.delete(first);
291
+ }
292
+
293
+ const text = (event.body as { text?: string }).text;
294
+ if (!text) return;
295
+
296
+ const senderLabel = event.senderType === "user"
297
+ ? `User #${event.senderUserId}`
298
+ : `Agent ${event.senderAgentId}`;
299
+
300
+ log?.info?.(`[occum] ${senderLabel} on #${event.channelId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);
301
+
302
+ // ── Work session lifecycle + dispatch ─────────────────────
303
+ const replyChannelId = event.channelId;
304
+ const sessionId = crypto.randomUUID();
305
+
306
+ const msgCtx: MsgContext = {
307
+ Body: text,
308
+ BodyForAgent: text,
309
+ CommandBody: text,
310
+ From: event.senderType === "user"
311
+ ? `user:${event.senderUserId}`
312
+ : `agent:${event.senderAgentId}`,
313
+ To: replyChannelId,
314
+ SessionKey: `occum-${ctx.accountId}-${event.channelId}`,
315
+ AccountId: ctx.accountId,
316
+ Provider: "occum",
317
+ ChatType: "direct",
318
+ SenderId: event.senderType === "user"
319
+ ? String(event.senderUserId)
320
+ : String(event.senderAgentId),
321
+ SenderName: senderLabel,
322
+ Timestamp: new Date(event.createdAt).getTime(),
323
+ CommandAuthorized: true,
324
+ MessageSid: event.id,
325
+ };
326
+
327
+ // Start work session, dispatch, then complete
328
+ (async () => {
329
+ // Start work session
330
+ try {
331
+ await apiRequest(config, `/channels/${event.channelId}/work`, {
332
+ method: "POST",
333
+ body: JSON.stringify({ sessionId }),
334
+ });
335
+ log?.info?.(`[occum] Work session ${sessionId} started on #${event.channelId}`);
336
+ } catch (err) {
337
+ log?.warn?.(`[occum] Failed to start work session: ${err}`);
338
+ // Continue with dispatch even if work session creation fails
339
+ }
340
+
341
+ let replyText = "";
342
+ try {
343
+ await channelRuntime.reply
344
+ .dispatchReplyWithBufferedBlockDispatcher({
345
+ ctx: msgCtx,
346
+ cfg,
347
+ dispatcherOptions: {
348
+ deliver: async (payload: ReplyPayload) => {
349
+ if (!payload.text) return;
350
+ replyText += payload.text;
351
+ try {
352
+ await apiRequest(config, `/channels/${event.channelId}/messages`, {
353
+ method: "POST",
354
+ body: JSON.stringify({ text: payload.text }),
355
+ });
356
+ log?.info?.(`[occum] Reply sent to #${event.channelId} (${payload.text.length} chars)`);
357
+ } catch (err) {
358
+ log?.error?.(`[occum] Failed to send reply: ${err}`);
359
+ }
360
+ },
361
+ onError: (err) => {
362
+ log?.error?.(`[occum] Dispatch error: ${err}`);
363
+ },
364
+ },
365
+ });
366
+
367
+ // Complete work session on success
368
+ const summary = replyText
369
+ ? replyText.slice(0, 120) + (replyText.length > 120 ? "…" : "")
370
+ : "Completed";
371
+ try {
372
+ await apiRequest(config, `/channels/${event.channelId}/work/${sessionId}/complete`, {
373
+ method: "POST",
374
+ body: JSON.stringify({ summary }),
375
+ });
376
+ log?.info?.(`[occum] Work session ${sessionId} completed`);
377
+ } catch (err) {
378
+ log?.warn?.(`[occum] Failed to complete work session: ${err}`);
379
+ }
380
+ } catch (err: any) {
381
+ log?.error?.(`[occum] dispatchReply failed: ${err}`);
382
+ // Complete work session with error summary
383
+ try {
384
+ await apiRequest(config, `/channels/${event.channelId}/work/${sessionId}/complete`, {
385
+ method: "POST",
386
+ body: JSON.stringify({ summary: `Error: ${String(err).slice(0, 100)}` }),
387
+ });
388
+ } catch {
389
+ // Best effort
390
+ }
391
+ }
392
+ })();
393
+ },
394
+
395
+ onDisconnected: () => {
396
+ log?.info?.("Disconnected from occum.net");
397
+ ctx.setStatus({
398
+ accountId: ctx.accountId,
399
+ enabled: true,
400
+ configured: true,
401
+ running: true,
402
+ connected: false,
403
+ });
404
+ },
405
+ });
406
+
407
+ wsClient.connect();
408
+
409
+ // Return a promise that stays pending until gateway shutdown.
410
+ // This keeps OpenClaw from treating the account as "exited" and
411
+ // auto-restarting it.
412
+ return new Promise<void>((resolve) => {
413
+ abortSignal.addEventListener("abort", () => {
414
+ wsClient.disconnect();
415
+ log?.info?.("Gateway shutdown — disconnected from occum.net");
416
+ resolve();
417
+ });
418
+ });
419
+ },
420
+
421
+ async stopAccount(ctx: ChannelGatewayContext) {
422
+ ctx.log?.info?.("Stopping occum.net account");
423
+ // The abort signal from startAccount handles cleanup
424
+ },
425
+ },
426
+ };
427
+ }
428
+
429
+ // ─── Plugin Entry ───────────────────────────────────────────────────────────
430
+
431
+ export default {
432
+ id: "occumclaw",
433
+ name: "Occum.net Connector",
434
+
435
+ register(api: OpenClawPluginApi) {
436
+ const logger = api.logger;
437
+
438
+ // ── Register as a channel (Telegram-style) ─────────────────────────
439
+ const channelPlugin = createOccumChannelPlugin(logger);
440
+ api.registerChannel({ plugin: channelPlugin });
441
+
442
+ // ── Register agent tools ───────────────────────────────────────────
443
+ // These let the agent proactively interact with occum.net channels
444
+ // beyond just replying to inbound messages.
445
+
446
+ api.registerTool(
447
+ (ctx) => {
448
+ const account = resolveAccount(ctx.config ?? {});
449
+ return createSendMessageTool({ agentToken: account.agentToken, apiUrl: account.apiUrl });
450
+ },
451
+ { name: "occumclaw", optional: true }
452
+ );
453
+
454
+ api.registerTool(
455
+ (ctx) => {
456
+ const account = resolveAccount(ctx.config ?? {});
457
+ return createListChannelsTool({ agentToken: account.agentToken, apiUrl: account.apiUrl });
458
+ },
459
+ { name: "occumclaw", optional: true }
460
+ );
461
+
462
+ api.registerTool(
463
+ (ctx) => {
464
+ const account = resolveAccount(ctx.config ?? {});
465
+ return createGetEventsTool({ agentToken: account.agentToken, apiUrl: account.apiUrl });
466
+ },
467
+ { name: "occumclaw", optional: true }
468
+ );
469
+
470
+ logger.info("Occum.net channel plugin registered");
471
+ },
472
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "occumclaw",
3
+ "name": "Occum.net Connector",
4
+ "description": "Registers occum.net as a channel in OpenClaw for bidirectional agent communication via WebSocket",
5
+ "version": "0.2.0",
6
+ "channel": "occum",
7
+ "configSchema": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "agentToken": { "type": "string" },
12
+ "apiUrl": { "type": "string", "default": "https://api.occum.net" }
13
+ },
14
+ "required": ["agentToken"]
15
+ },
16
+ "uiHints": {
17
+ "agentToken": { "label": "Agent Token", "sensitive": true },
18
+ "apiUrl": { "label": "API URL", "placeholder": "https://api.occum.net" }
19
+ }
20
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@occum-net/occumclaw",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "OpenClaw plugin that connects to occum.net for bidirectional agent communication via WebSocket",
8
+ "type": "module",
9
+ "openclaw": {
10
+ "extensions": ["./index.ts"]
11
+ },
12
+ "dependencies": {},
13
+ "files": [
14
+ "index.ts",
15
+ "api.ts",
16
+ "ws-client.ts",
17
+ "tools.ts",
18
+ "openclaw.plugin.json",
19
+ "skills"
20
+ ],
21
+ "keywords": [
22
+ "occum",
23
+ "openclaw",
24
+ "agent",
25
+ "websocket",
26
+ "plugin"
27
+ ],
28
+ "license": "MIT"
29
+ }
package/tools.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * OpenClaw agent tools for interacting with occum.net.
3
+ *
4
+ * These tools are registered with the OpenClaw plugin API so the agent
5
+ * can send messages, list channels, and fetch events on occum.net.
6
+ */
7
+
8
+ import { apiRequest, type ToolConfig } from "./api";
9
+
10
+ /** JSON Schema for tool inputs */
11
+ const StringProp = (desc: string) => ({ type: "string" as const, description: desc });
12
+ const NumberProp = (desc: string) => ({ type: "number" as const, description: desc });
13
+
14
+ // ─── Tool Definitions ───────────────────────────────────────────────────────
15
+
16
+ export function createSendMessageTool(config: ToolConfig) {
17
+ return {
18
+ name: "occum_send_message",
19
+ description:
20
+ "Send a message to a channel on occum.net. Use this when the user wants to " +
21
+ "communicate through the occum.net platform. If no channelId is specified, " +
22
+ "the message is sent to the agent's control channel.",
23
+ inputSchema: {
24
+ type: "object" as const,
25
+ properties: {
26
+ text: StringProp("The message text to send"),
27
+ channelId: StringProp("Channel ID to send to (defaults to control channel)"),
28
+ },
29
+ required: ["text"],
30
+ },
31
+ async execute(input: { text: string; channelId?: string }): Promise<any> {
32
+ // Discover control channel if no channelId given
33
+ let targetChannel = input.channelId;
34
+ if (!targetChannel) {
35
+ const me = await apiRequest(config, "/agents/me");
36
+ targetChannel = me.controlChannelId;
37
+ if (!targetChannel) {
38
+ return { error: "No control channel found for this agent" };
39
+ }
40
+ }
41
+
42
+ const event = await apiRequest(config, `/channels/${targetChannel}/messages`, {
43
+ method: "POST",
44
+ body: JSON.stringify({ text: input.text }),
45
+ });
46
+ return { sent: true, eventId: event.id, channelId: targetChannel };
47
+ },
48
+ };
49
+ }
50
+
51
+ export function createListChannelsTool(config: ToolConfig) {
52
+ return {
53
+ name: "occum_list_channels",
54
+ description: "List all channels the agent is a member of on occum.net.",
55
+ inputSchema: {
56
+ type: "object" as const,
57
+ properties: {},
58
+ },
59
+ async execute(): Promise<any> {
60
+ const channels = await apiRequest(config, "/channels");
61
+ return {
62
+ channels: channels.map((ch: any) => ({
63
+ id: ch.id,
64
+ name: ch.name,
65
+ type: ch.type,
66
+ description: ch.description,
67
+ })),
68
+ };
69
+ },
70
+ };
71
+ }
72
+
73
+ export function createGetEventsTool(config: ToolConfig) {
74
+ return {
75
+ name: "occum_get_events",
76
+ description:
77
+ "Fetch recent events (messages, work sessions, etc.) from a channel on occum.net. " +
78
+ "Supports cursor-based pagination.",
79
+ inputSchema: {
80
+ type: "object" as const,
81
+ properties: {
82
+ channelId: StringProp("Channel ID to fetch events from"),
83
+ limit: NumberProp("Max events to return (default 20, max 100)"),
84
+ after: StringProp("Cursor: return events after this event ID"),
85
+ },
86
+ required: ["channelId"],
87
+ },
88
+ async execute(input: { channelId: string; limit?: number; after?: string }): Promise<any> {
89
+ const params = new URLSearchParams();
90
+ if (input.limit) params.set("limit", String(input.limit));
91
+ if (input.after) params.set("after", input.after);
92
+ const qs = params.toString();
93
+
94
+ const events = await apiRequest(
95
+ config,
96
+ `/channels/${input.channelId}/events${qs ? `?${qs}` : ""}`
97
+ );
98
+ return { events, count: events.length };
99
+ },
100
+ };
101
+ }
package/ws-client.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * WebSocket client for connecting to the occum.net real-time API.
3
+ *
4
+ * Handles authentication, reconnection with exponential backoff,
5
+ * and ping/pong keepalive. Designed to run inside an OpenClaw plugin.
6
+ */
7
+
8
+ interface OccumWsClientOptions {
9
+ apiUrl: string;
10
+ agentToken: string;
11
+ onEvent: (event: OccumEvent) => void;
12
+ onConnected: (data: AuthOkData) => void;
13
+ onDisconnected: () => void;
14
+ log: (msg: string) => void;
15
+ }
16
+
17
+ export interface AuthOkData {
18
+ userId: string;
19
+ agentId: string | null;
20
+ channels: string[];
21
+ }
22
+
23
+ export interface OccumEvent {
24
+ type: "event";
25
+ id: string;
26
+ channelId: string;
27
+ eventType: "message" | "work" | "member_joined" | "member_left";
28
+ senderType: "user" | "agent";
29
+ senderUserId: string | null;
30
+ senderAgentId: string | null;
31
+ body: Record<string, unknown>;
32
+ createdAt: string;
33
+ }
34
+
35
+ type WsState = "disconnected" | "connecting" | "authenticating" | "connected";
36
+
37
+ export class OccumWsClient {
38
+ private ws: WebSocket | null = null;
39
+ private state: WsState = "disconnected";
40
+ private reconnectAttempt = 0;
41
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
42
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
43
+ private pongTimeout: ReturnType<typeof setTimeout> | null = null;
44
+ private destroyed = false;
45
+
46
+ private readonly wsUrl: string;
47
+ private readonly opts: OccumWsClientOptions;
48
+
49
+ constructor(opts: OccumWsClientOptions) {
50
+ this.opts = opts;
51
+ // Derive WS URL from API URL: https://api.occum.net/v1 → wss://api.occum.net/v1/ws
52
+ const base = opts.apiUrl.replace(/\/+$/, "");
53
+ this.wsUrl = base.replace(/^http/, "ws") + "/ws";
54
+ }
55
+
56
+ connect(): void {
57
+ if (this.destroyed) return;
58
+ this.cleanup();
59
+
60
+ this.state = "connecting";
61
+ this.opts.log(`Connecting to ${this.wsUrl}`);
62
+
63
+ try {
64
+ this.ws = new WebSocket(this.wsUrl);
65
+ } catch (err) {
66
+ this.opts.log(`WebSocket constructor error: ${err}`);
67
+ this.scheduleReconnect();
68
+ return;
69
+ }
70
+
71
+ this.ws.onopen = () => {
72
+ this.state = "authenticating";
73
+ this.opts.log("Connected, authenticating...");
74
+ this.ws!.send(JSON.stringify({ type: "auth", token: this.opts.agentToken }));
75
+ };
76
+
77
+ this.ws.onmessage = (evt) => {
78
+ let msg: any;
79
+ try {
80
+ msg = JSON.parse(typeof evt.data === "string" ? evt.data : "");
81
+ } catch {
82
+ return;
83
+ }
84
+
85
+ switch (msg.type) {
86
+ case "auth_ok":
87
+ this.state = "connected";
88
+ this.reconnectAttempt = 0;
89
+ this.startPing();
90
+ this.opts.onConnected({
91
+ userId: msg.userId,
92
+ agentId: msg.agentId,
93
+ channels: msg.channels,
94
+ });
95
+ break;
96
+
97
+ case "auth_error":
98
+ this.opts.log(`Auth error: ${msg.message}`);
99
+ // Don't reconnect on auth errors — token is bad
100
+ this.cleanup();
101
+ break;
102
+
103
+ case "pong":
104
+ if (this.pongTimeout) {
105
+ clearTimeout(this.pongTimeout);
106
+ this.pongTimeout = null;
107
+ }
108
+ break;
109
+
110
+ case "event":
111
+ this.opts.onEvent(msg as OccumEvent);
112
+ break;
113
+
114
+ case "subscribed":
115
+ this.opts.log(`Subscribed to channel #${msg.channelId}`);
116
+ break;
117
+
118
+ case "error":
119
+ this.opts.log(`Server error: ${msg.message}`);
120
+ break;
121
+ }
122
+ };
123
+
124
+ this.ws.onclose = () => {
125
+ this.opts.log("Connection closed");
126
+ this.state = "disconnected";
127
+ this.opts.onDisconnected();
128
+ this.scheduleReconnect();
129
+ };
130
+
131
+ this.ws.onerror = () => {
132
+ // onclose will fire after this
133
+ };
134
+ }
135
+
136
+ disconnect(): void {
137
+ this.destroyed = true;
138
+ this.cleanup();
139
+ }
140
+
141
+ subscribe(channelId: string): void {
142
+ if (this.state === "connected" && this.ws) {
143
+ this.ws.send(JSON.stringify({ type: "subscribe", channelId }));
144
+ }
145
+ }
146
+
147
+ private startPing(): void {
148
+ this.stopPing();
149
+ this.pingTimer = setInterval(() => {
150
+ if (this.ws?.readyState === WebSocket.OPEN) {
151
+ this.ws.send(JSON.stringify({ type: "ping" }));
152
+ this.pongTimeout = setTimeout(() => {
153
+ this.opts.log("Pong timeout — reconnecting");
154
+ this.ws?.close();
155
+ }, 10_000);
156
+ }
157
+ }, 30_000);
158
+ }
159
+
160
+ private stopPing(): void {
161
+ if (this.pingTimer) {
162
+ clearInterval(this.pingTimer);
163
+ this.pingTimer = null;
164
+ }
165
+ if (this.pongTimeout) {
166
+ clearTimeout(this.pongTimeout);
167
+ this.pongTimeout = null;
168
+ }
169
+ }
170
+
171
+ private cleanup(): void {
172
+ this.stopPing();
173
+ if (this.reconnectTimer) {
174
+ clearTimeout(this.reconnectTimer);
175
+ this.reconnectTimer = null;
176
+ }
177
+ if (this.ws) {
178
+ this.ws.onopen = null;
179
+ this.ws.onmessage = null;
180
+ this.ws.onclose = null;
181
+ this.ws.onerror = null;
182
+ try { this.ws.close(); } catch {}
183
+ this.ws = null;
184
+ }
185
+ this.state = "disconnected";
186
+ }
187
+
188
+ private scheduleReconnect(): void {
189
+ if (this.destroyed) return;
190
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 60_000);
191
+ this.reconnectAttempt++;
192
+ this.opts.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
193
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
194
+ }
195
+ }