adp-openclaw 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/channel.ts ADDED
@@ -0,0 +1,245 @@
1
+ // Simple Go channel plugin for OpenClaw
2
+ // Supports: API Token auth, multiple clients, multi-turn conversations
3
+
4
+ import {
5
+ type ChannelPlugin,
6
+ DEFAULT_ACCOUNT_ID,
7
+ setAccountEnabledInConfigSection,
8
+ deleteAccountFromConfigSection,
9
+ } from "openclaw/plugin-sdk";
10
+
11
+ export type SimpleGoConfig = {
12
+ channels?: {
13
+ simplego?: {
14
+ enabled?: boolean;
15
+ serverUrl?: string;
16
+ apiToken?: string; // Client API token for authentication
17
+ pollIntervalMs?: number;
18
+ };
19
+ };
20
+ };
21
+
22
+ export type ResolvedSimpleGoAccount = {
23
+ accountId: string;
24
+ name: string;
25
+ enabled: boolean;
26
+ configured: boolean;
27
+ serverUrl: string;
28
+ apiToken: string;
29
+ pollIntervalMs: number;
30
+ };
31
+
32
+ function resolveAccount(cfg: SimpleGoConfig, accountId?: string): ResolvedSimpleGoAccount {
33
+ const section = cfg.channels?.simplego ?? {};
34
+ // Use environment variables as fallback
35
+ const serverUrl = section.serverUrl?.trim() ||
36
+ process.env.SIMPLEGO_SERVER_URL ||
37
+ "http://localhost:9876";
38
+ const apiToken = section.apiToken?.trim() ||
39
+ process.env.SIMPLEGO_API_TOKEN ||
40
+ "92527c1a861bb50bd1acc47180ef317369e97529c7a5491b2bc67839992cfbb8";
41
+
42
+ return {
43
+ accountId: accountId || DEFAULT_ACCOUNT_ID,
44
+ name: "Simple Go",
45
+ enabled: section.enabled !== false,
46
+ configured: Boolean(serverUrl && apiToken), // Always configured if we have values
47
+ serverUrl,
48
+ apiToken,
49
+ pollIntervalMs: section.pollIntervalMs ?? 1000,
50
+ };
51
+ }
52
+
53
+ export const simpleGoPlugin: ChannelPlugin<ResolvedSimpleGoAccount> = {
54
+ id: "simplego",
55
+ meta: {
56
+ id: "simplego",
57
+ label: "Simple Go",
58
+ selectionLabel: "Simple Go (demo)",
59
+ docsPath: "/channels/simplego",
60
+ blurb: "Demo channel backed by a Go HTTP server.",
61
+ order: 999,
62
+ },
63
+ capabilities: {
64
+ chatTypes: ["direct"],
65
+ polls: false,
66
+ reactions: false,
67
+ threads: false,
68
+ media: false,
69
+ },
70
+ reload: { configPrefixes: ["channels.simplego"] },
71
+ config: {
72
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
73
+ resolveAccount: (cfg, accountId) => resolveAccount(cfg as SimpleGoConfig, accountId),
74
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
75
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
76
+ setAccountEnabledInConfigSection({
77
+ cfg: cfg as SimpleGoConfig,
78
+ sectionKey: "simplego",
79
+ accountId,
80
+ enabled,
81
+ allowTopLevel: true,
82
+ }),
83
+ deleteAccount: ({ cfg, accountId }) =>
84
+ deleteAccountFromConfigSection({
85
+ cfg: cfg as SimpleGoConfig,
86
+ sectionKey: "simplego",
87
+ accountId,
88
+ clearBaseFields: ["serverUrl", "apiToken", "pollIntervalMs"],
89
+ }),
90
+ isConfigured: (account) => account.configured,
91
+ describeAccount: (account) => ({
92
+ accountId: account.accountId,
93
+ name: account.name,
94
+ enabled: account.enabled,
95
+ configured: account.configured,
96
+ baseUrl: account.serverUrl,
97
+ }),
98
+ resolveAllowFrom: () => [],
99
+ formatAllowFrom: ({ allowFrom }) => allowFrom,
100
+ },
101
+ setup: {
102
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
103
+ validateInput: ({ input }: { input: Record<string, string | undefined> }) => {
104
+ // Accept both standard names (url, token) and custom names (serverUrl, apiToken)
105
+ const serverUrl = input.url || input.serverUrl;
106
+ const apiToken = input.token || input.apiToken;
107
+
108
+ if (!serverUrl?.trim()) {
109
+ return "Simple Go requires --url (server URL)";
110
+ }
111
+ if (!apiToken?.trim()) {
112
+ return "Simple Go requires --token (API token)";
113
+ }
114
+ return null;
115
+ },
116
+ applyAccountConfig: ({ cfg, input }) => {
117
+ const existing = (cfg as SimpleGoConfig).channels?.simplego ?? {};
118
+ // Accept both standard names and custom names
119
+ const serverUrl = input.url || input.serverUrl;
120
+ const apiToken = input.token || input.apiToken;
121
+
122
+ return {
123
+ ...cfg,
124
+ channels: {
125
+ ...(cfg as SimpleGoConfig).channels,
126
+ simplego: {
127
+ ...existing,
128
+ enabled: true,
129
+ serverUrl: serverUrl?.trim() || existing.serverUrl || "http://localhost:9876",
130
+ apiToken: apiToken?.trim() || existing.apiToken,
131
+ },
132
+ },
133
+ };
134
+ },
135
+ },
136
+ status: {
137
+ defaultRuntime: {
138
+ accountId: DEFAULT_ACCOUNT_ID,
139
+ running: false,
140
+ lastStartAt: null,
141
+ lastStopAt: null,
142
+ lastError: null,
143
+ },
144
+ collectStatusIssues: () => [],
145
+ buildChannelSummary: ({ snapshot }) => ({
146
+ configured: snapshot.configured ?? false,
147
+ baseUrl: snapshot.baseUrl ?? null,
148
+ running: snapshot.running ?? false,
149
+ lastStartAt: snapshot.lastStartAt ?? null,
150
+ lastStopAt: snapshot.lastStopAt ?? null,
151
+ lastError: snapshot.lastError ?? null,
152
+ }),
153
+ probeAccount: async ({ cfg, timeoutMs }) => {
154
+ const account = resolveAccount(cfg as SimpleGoConfig);
155
+ const start = Date.now();
156
+ try {
157
+ const controller = new AbortController();
158
+ const timeout = setTimeout(() => controller.abort(), timeoutMs ?? 5000);
159
+ const res = await fetch(`${account.serverUrl}/health`, { signal: controller.signal });
160
+ clearTimeout(timeout);
161
+ const data = (await res.json()) as { ok?: boolean };
162
+ return {
163
+ ok: data.ok === true,
164
+ elapsedMs: Date.now() - start,
165
+ };
166
+ } catch (err) {
167
+ return {
168
+ ok: false,
169
+ error: err instanceof Error ? err.message : String(err),
170
+ elapsedMs: Date.now() - start,
171
+ };
172
+ }
173
+ },
174
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
175
+ accountId: account.accountId,
176
+ name: account.name,
177
+ enabled: account.enabled,
178
+ configured: account.configured,
179
+ baseUrl: account.serverUrl,
180
+ running: runtime?.running ?? false,
181
+ lastStartAt: runtime?.lastStartAt ?? null,
182
+ lastStopAt: runtime?.lastStopAt ?? null,
183
+ lastError: runtime?.lastError ?? null,
184
+ probe,
185
+ }),
186
+ },
187
+ gateway: {
188
+ startAccount: async (ctx) => {
189
+ const account = ctx.account;
190
+ ctx.setStatus({ accountId: account.accountId, baseUrl: account.serverUrl });
191
+ ctx.log?.info(`[simplego] starting poll loop → ${account.serverUrl}`);
192
+
193
+ const { monitorSimpleGo } = await import("./monitor.js");
194
+ return monitorSimpleGo({
195
+ serverUrl: account.serverUrl,
196
+ apiToken: account.apiToken,
197
+ pollIntervalMs: account.pollIntervalMs,
198
+ abortSignal: ctx.abortSignal,
199
+ log: ctx.log,
200
+ });
201
+ },
202
+ },
203
+ outbound: {
204
+ send: async ({ target, message, cfg, context }: {
205
+ target: string;
206
+ message: string;
207
+ cfg: unknown;
208
+ context?: {
209
+ conversationId?: string;
210
+ userId?: string;
211
+ username?: string;
212
+ tenantId?: string;
213
+ };
214
+ }) => {
215
+ const account = resolveAccount(cfg as SimpleGoConfig);
216
+ const conversationId = context?.conversationId || "";
217
+
218
+ // Extract user info from context for response tracing
219
+ const userInfo = context?.userId ? {
220
+ userId: context.userId,
221
+ username: context.username,
222
+ tenantId: context.tenantId,
223
+ } : undefined;
224
+
225
+ const res = await fetch(`${account.serverUrl}/send`, {
226
+ method: "POST",
227
+ headers: {
228
+ "Content-Type": "application/json",
229
+ Authorization: `Bearer ${account.apiToken}`,
230
+ },
231
+ body: JSON.stringify({
232
+ to: target,
233
+ text: message,
234
+ conversationId,
235
+ user: userInfo,
236
+ }),
237
+ });
238
+ const data = (await res.json()) as { ok?: boolean; message?: { id?: string } };
239
+ return {
240
+ ok: data.ok === true,
241
+ messageId: data.message?.id,
242
+ };
243
+ },
244
+ },
245
+ };
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+
3
+ export const SimpleGoConfigSchema = z.object({
4
+ enabled: z.boolean().optional(),
5
+ serverUrl: z.string().optional(),
6
+ apiToken: z.string().optional(),
7
+ pollIntervalMs: z.number().optional(),
8
+ });
package/src/monitor.ts ADDED
@@ -0,0 +1,325 @@
1
+ // Monitor: WebSocket connection to Go server for real-time message handling
2
+ // Supports: API Token auth, conversation tracking for multi-turn dialogues
3
+
4
+ import type { PluginLogger } from "openclaw/plugin-sdk";
5
+ import { getSimpleGoRuntime } from "./runtime.js";
6
+ import crypto from "crypto";
7
+
8
+ export type MonitorParams = {
9
+ serverUrl: string;
10
+ apiToken: string;
11
+ pollIntervalMs: number; // Used as reconnect delay
12
+ abortSignal?: AbortSignal;
13
+ log?: PluginLogger;
14
+ };
15
+
16
+ // WebSocket message types
17
+ const MsgType = {
18
+ Auth: "auth",
19
+ AuthResult: "auth_result",
20
+ Ping: "ping",
21
+ Pong: "pong",
22
+ Inbound: "inbound",
23
+ Outbound: "outbound",
24
+ Ack: "ack",
25
+ Error: "error",
26
+ ConvHistory: "conv_history",
27
+ ConvResponse: "conv_response",
28
+ } as const;
29
+
30
+ type WSMessage = {
31
+ type: string;
32
+ requestId?: string;
33
+ payload?: unknown;
34
+ timestamp: number;
35
+ };
36
+
37
+ // UserInfo represents full user identity (matching Go server's UserInfo)
38
+ type UserInfo = {
39
+ userId: string;
40
+ username?: string;
41
+ avatar?: string;
42
+ email?: string;
43
+ tenantId?: string;
44
+ source?: string;
45
+ extra?: Record<string, string>;
46
+ };
47
+
48
+ type InboundMessage = {
49
+ id: string;
50
+ conversationId: string;
51
+ clientId: string;
52
+ from: string;
53
+ to: string;
54
+ text: string;
55
+ timestamp: number;
56
+ user?: UserInfo; // Full user identity information
57
+ };
58
+
59
+ type AuthResultPayload = {
60
+ success: boolean;
61
+ clientId?: string;
62
+ message?: string;
63
+ };
64
+
65
+ // Generate HMAC-SHA256 signature for extra security
66
+ function generateSignature(token: string, nonce: string): string {
67
+ return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
68
+ }
69
+
70
+ // Generate random nonce
71
+ function generateNonce(): string {
72
+ return crypto.randomBytes(16).toString("hex");
73
+ }
74
+
75
+ // Generate unique request ID
76
+ function generateRequestId(): string {
77
+ return `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
78
+ }
79
+
80
+ export async function monitorSimpleGo(params: MonitorParams): Promise<void> {
81
+ const { serverUrl, apiToken, pollIntervalMs, abortSignal, log } = params;
82
+ const runtime = getSimpleGoRuntime();
83
+
84
+ // Convert HTTP URL to WebSocket URL
85
+ const wsUrl = serverUrl.replace(/^http/, "ws") + "/ws";
86
+
87
+ log?.info(`[simplego] WebSocket monitor started, connecting to ${wsUrl}`);
88
+
89
+ while (!abortSignal?.aborted) {
90
+ try {
91
+ await connectAndHandle({
92
+ wsUrl,
93
+ apiToken,
94
+ serverUrl,
95
+ abortSignal,
96
+ log,
97
+ runtime,
98
+ });
99
+ } catch (err) {
100
+ if (abortSignal?.aborted) break;
101
+ log?.error(`[simplego] WebSocket error: ${err}`);
102
+ }
103
+
104
+ // Wait before reconnecting
105
+ if (!abortSignal?.aborted) {
106
+ log?.info(`[simplego] Reconnecting in ${pollIntervalMs}ms...`);
107
+ await sleep(pollIntervalMs, abortSignal);
108
+ }
109
+ }
110
+
111
+ log?.info(`[simplego] WebSocket monitor stopped`);
112
+ }
113
+
114
+ type ConnectParams = {
115
+ wsUrl: string;
116
+ apiToken: string;
117
+ serverUrl: string;
118
+ abortSignal?: AbortSignal;
119
+ log?: PluginLogger;
120
+ runtime: ReturnType<typeof getSimpleGoRuntime>;
121
+ };
122
+
123
+ async function connectAndHandle(params: ConnectParams): Promise<void> {
124
+ const { wsUrl, apiToken, serverUrl, abortSignal, log, runtime } = params;
125
+
126
+ // Dynamic import for WebSocket (works in both Node.js and browser)
127
+ const WebSocket = (await import("ws")).default;
128
+
129
+ return new Promise((resolve, reject) => {
130
+ const ws = new WebSocket(wsUrl);
131
+ let authenticated = false;
132
+ let pingInterval: NodeJS.Timeout | null = null;
133
+
134
+ // Handle abort signal
135
+ const abortHandler = () => {
136
+ ws.close();
137
+ resolve();
138
+ };
139
+ abortSignal?.addEventListener("abort", abortHandler);
140
+
141
+ ws.on("open", () => {
142
+ log?.info(`[simplego] WebSocket connected, authenticating...`);
143
+
144
+ // Send authentication message with signature
145
+ const nonce = generateNonce();
146
+ const signature = generateSignature(apiToken, nonce);
147
+
148
+ const authMsg: WSMessage = {
149
+ type: MsgType.Auth,
150
+ requestId: generateRequestId(),
151
+ payload: {
152
+ token: apiToken,
153
+ nonce,
154
+ signature,
155
+ },
156
+ timestamp: Date.now(),
157
+ };
158
+
159
+ ws.send(JSON.stringify(authMsg));
160
+ });
161
+
162
+ ws.on("message", async (data: Buffer) => {
163
+ try {
164
+ const msg: WSMessage = JSON.parse(data.toString());
165
+
166
+ switch (msg.type) {
167
+ case MsgType.AuthResult: {
168
+ const result = msg.payload as AuthResultPayload;
169
+ if (result.success) {
170
+ authenticated = true;
171
+ log?.info(`[simplego] Authenticated as client ${result.clientId}`);
172
+
173
+ // Start ping interval
174
+ pingInterval = setInterval(() => {
175
+ if (ws.readyState === WebSocket.OPEN) {
176
+ ws.send(JSON.stringify({
177
+ type: MsgType.Ping,
178
+ requestId: generateRequestId(),
179
+ timestamp: Date.now(),
180
+ }));
181
+ }
182
+ }, 25000);
183
+ } else {
184
+ log?.error(`[simplego] Authentication failed: ${result.message}`);
185
+ ws.close();
186
+ }
187
+ break;
188
+ }
189
+
190
+ case MsgType.Pong:
191
+ // Heartbeat response, connection is alive
192
+ break;
193
+
194
+ case MsgType.Inbound: {
195
+ if (!authenticated) break;
196
+
197
+ const inMsg = msg.payload as InboundMessage;
198
+ log?.info(`[simplego] Received: ${inMsg.from}: ${inMsg.text} (conv=${inMsg.conversationId}, user=${JSON.stringify(inMsg.user || {})})`);
199
+
200
+ // Process the message with full user identity
201
+ try {
202
+ // Build user identity string for From field (like Feishu: "feishu:user_id")
203
+ const userIdentifier = inMsg.user?.userId || inMsg.from;
204
+ const tenantPrefix = inMsg.user?.tenantId ? `${inMsg.user.tenantId}:` : "";
205
+
206
+ // Build metadata for user context (passed through to openclaw)
207
+ const userMetadata: Record<string, string> = {};
208
+ if (inMsg.user) {
209
+ if (inMsg.user.username) userMetadata.username = inMsg.user.username;
210
+ if (inMsg.user.email) userMetadata.email = inMsg.user.email;
211
+ if (inMsg.user.avatar) userMetadata.avatar = inMsg.user.avatar;
212
+ if (inMsg.user.tenantId) userMetadata.tenantId = inMsg.user.tenantId;
213
+ if (inMsg.user.source) userMetadata.source = inMsg.user.source;
214
+ if (inMsg.user.extra) {
215
+ Object.entries(inMsg.user.extra).forEach(([k, v]) => {
216
+ userMetadata[`extra_${k}`] = v;
217
+ });
218
+ }
219
+ }
220
+
221
+ const ctx = runtime.channel.reply.finalizeInboundContext({
222
+ Body: inMsg.text,
223
+ RawBody: inMsg.text,
224
+ CommandBody: inMsg.text,
225
+ // User identity: format as "simplego:{tenantId}:{userId}" for multi-tenant support
226
+ From: `simplego:${tenantPrefix}${userIdentifier}`,
227
+ To: `simplego:bot`,
228
+ // SessionKey uses conversationId for multi-turn dialogue tracking per user
229
+ SessionKey: inMsg.conversationId,
230
+ AccountId: "default",
231
+ ChatType: "direct",
232
+ // SenderId carries the raw user ID for identification
233
+ SenderId: userIdentifier,
234
+ Provider: "simplego",
235
+ Surface: inMsg.user?.source || "simplego",
236
+ MessageSid: inMsg.id,
237
+ MessageSidFull: inMsg.id,
238
+ OriginatingChannel: "simplego",
239
+ OriginatingTo: "simplego:bot",
240
+ // Pass user metadata through context (like Feishu does)
241
+ ...userMetadata,
242
+ });
243
+
244
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
245
+ ctx,
246
+ cfg: {},
247
+ dispatcherOptions: {
248
+ deliver: async (payload: { text: string }) => {
249
+ const displayName = inMsg.user?.username || inMsg.from;
250
+ log?.info(`[simplego] Sending reply to ${displayName}: ${payload.text.slice(0, 50)}...`);
251
+
252
+ // Send via WebSocket
253
+ const outMsg: WSMessage = {
254
+ type: MsgType.Outbound,
255
+ requestId: generateRequestId(),
256
+ payload: {
257
+ to: inMsg.from,
258
+ text: payload.text,
259
+ conversationId: inMsg.conversationId,
260
+ // Include user info in response for tracing
261
+ user: inMsg.user,
262
+ },
263
+ timestamp: Date.now(),
264
+ };
265
+
266
+ ws.send(JSON.stringify(outMsg));
267
+ },
268
+ onError: (err: Error) => {
269
+ log?.error(`[simplego] Reply error: ${err.message}`);
270
+ },
271
+ },
272
+ });
273
+ } catch (err) {
274
+ log?.error(`[simplego] Failed to process message: ${err}`);
275
+ }
276
+ break;
277
+ }
278
+
279
+ case MsgType.Ack:
280
+ // Message acknowledgment
281
+ log?.debug?.(`[simplego] Message acknowledged: ${msg.requestId}`);
282
+ break;
283
+
284
+ case MsgType.Error: {
285
+ const error = msg.payload as { error: string; message: string };
286
+ log?.error(`[simplego] Server error: ${error.error} - ${error.message}`);
287
+ break;
288
+ }
289
+
290
+ default:
291
+ log?.warn(`[simplego] Unknown message type: ${msg.type}`);
292
+ }
293
+ } catch (err) {
294
+ log?.error(`[simplego] Failed to parse message: ${err}`);
295
+ }
296
+ });
297
+
298
+ ws.on("close", (code, reason) => {
299
+ if (pingInterval) clearInterval(pingInterval);
300
+ abortSignal?.removeEventListener("abort", abortHandler);
301
+ log?.info(`[simplego] WebSocket closed: ${code} ${reason.toString()}`);
302
+ resolve();
303
+ });
304
+
305
+ ws.on("error", (err) => {
306
+ if (pingInterval) clearInterval(pingInterval);
307
+ abortSignal?.removeEventListener("abort", abortHandler);
308
+ reject(err);
309
+ });
310
+ });
311
+ }
312
+
313
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
314
+ return new Promise((resolve) => {
315
+ if (signal?.aborted) {
316
+ resolve();
317
+ return;
318
+ }
319
+ const timeout = setTimeout(resolve, ms);
320
+ signal?.addEventListener("abort", () => {
321
+ clearTimeout(timeout);
322
+ resolve();
323
+ });
324
+ });
325
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Runtime singleton for simplego plugin
2
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
3
+
4
+ let simpleGoRuntime: PluginRuntime | null = null;
5
+
6
+ export function setSimpleGoRuntime(runtime: PluginRuntime): void {
7
+ simpleGoRuntime = runtime;
8
+ }
9
+
10
+ export function getSimpleGoRuntime(): PluginRuntime {
11
+ if (!simpleGoRuntime) {
12
+ throw new Error("SimpleGo runtime not initialized");
13
+ }
14
+ return simpleGoRuntime;
15
+ }