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/DESIGN.md +733 -0
- package/README.md +70 -0
- package/USAGE.md +910 -0
- package/WEBSOCKET_PROTOCOL.md +506 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +26 -0
- package/server/.claude/settings.local.json +16 -0
- package/server/go.mod +5 -0
- package/server/main.go +786 -0
- package/src/channel.ts +245 -0
- package/src/config-schema.ts +8 -0
- package/src/monitor.ts +325 -0
- package/src/runtime.ts +15 -0
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
|
+
};
|
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
|
+
}
|