@andocorp/openclaw-plugin 0.0.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/index.ts +21 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +54 -0
- package/src/channel.ts +287 -0
- package/src/client.ts +26 -0
- package/src/mcp-tool-catalog.ts +223 -0
- package/src/mcp-tools.ts +369 -0
- package/src/monitor.ts +394 -0
- package/src/types.ts +7 -0
package/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import {
|
|
3
|
+
createPluginRuntimeStore,
|
|
4
|
+
type PluginRuntime,
|
|
5
|
+
} from "openclaw/plugin-sdk/runtime-store";
|
|
6
|
+
import { andoPlugin } from "./src/channel.js";
|
|
7
|
+
import { registerAndoMcpTools } from "./src/mcp-tools.js";
|
|
8
|
+
|
|
9
|
+
const { setRuntime: setAndoRuntime } =
|
|
10
|
+
createPluginRuntimeStore<PluginRuntime>("Ando runtime not initialized");
|
|
11
|
+
|
|
12
|
+
export default defineChannelPluginEntry({
|
|
13
|
+
id: "ando",
|
|
14
|
+
name: "Ando",
|
|
15
|
+
description: "OpenClaw adapter for Ando",
|
|
16
|
+
plugin: andoPlugin,
|
|
17
|
+
setRuntime: setAndoRuntime,
|
|
18
|
+
registerFull(api) {
|
|
19
|
+
registerAndoMcpTools(api);
|
|
20
|
+
},
|
|
21
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@andocorp/openclaw-plugin",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "OpenClaw adapter for Ando",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"src",
|
|
11
|
+
"!src/**/*.test.ts"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "pnpm --filter @andocorp/sdk build && tsc --noEmit",
|
|
18
|
+
"test": "pnpm --filter @andocorp/sdk build && vitest run",
|
|
19
|
+
"test:boundaries": "pnpm --filter @andocorp/sdk build && vitest run src/monitor.test.ts src/channel.test.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@andocorp/sdk": "workspace:*",
|
|
23
|
+
"zod": "^4.3.6"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"openclaw": ">=2026.3.23"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"openclaw": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "restricted"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^24.10.9",
|
|
38
|
+
"openclaw": "2026.3.23",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"vitest": "^3.2.4"
|
|
41
|
+
},
|
|
42
|
+
"openclaw": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./index.ts"
|
|
45
|
+
],
|
|
46
|
+
"channel": {
|
|
47
|
+
"id": "ando",
|
|
48
|
+
"label": "Ando",
|
|
49
|
+
"selectionLabel": "Ando",
|
|
50
|
+
"docsPath": "/channels/ando",
|
|
51
|
+
"blurb": "Live agent membership inside Ando conversations."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChannelConfigSchema,
|
|
3
|
+
ChannelPlugin,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
} from "openclaw/plugin-sdk/core";
|
|
6
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
7
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-resolution";
|
|
8
|
+
import { createTopLevelChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
9
|
+
import { formatAndoTarget, parseAndoTarget } from "@andocorp/sdk";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { postAndoMessage } from "./client.js";
|
|
12
|
+
import { monitorAndoProvider } from "./monitor.js";
|
|
13
|
+
import { ResolvedAndoAccount } from "./types.js";
|
|
14
|
+
|
|
15
|
+
const meta = packageJson.openclaw.channel as {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
selectionLabel: string;
|
|
19
|
+
docsPath: string;
|
|
20
|
+
blurb: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type AndoConfig = {
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
realtimeHost?: string;
|
|
27
|
+
mcpUrl?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function trimValue(value?: string | null): string {
|
|
31
|
+
return (value ?? "").trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeUrl(value?: string | null): string {
|
|
35
|
+
return trimValue(value).replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function waitForAbort(signal?: AbortSignal): Promise<void> {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
if (signal?.aborted) {
|
|
41
|
+
resolve();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
signal?.addEventListener("abort", () => resolve(), {
|
|
46
|
+
once: true,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function resolveAndoMcpUrl(params: {
|
|
51
|
+
baseUrl: string;
|
|
52
|
+
mcpUrl?: string | null;
|
|
53
|
+
}): string {
|
|
54
|
+
const explicitMcpUrl = normalizeUrl(params.mcpUrl);
|
|
55
|
+
if (explicitMcpUrl) {
|
|
56
|
+
return explicitMcpUrl;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baseUrl = normalizeUrl(params.baseUrl);
|
|
60
|
+
return baseUrl ? `${baseUrl}/mcp` : "";
|
|
61
|
+
}
|
|
62
|
+
export function resolveAndoAccount(cfg: OpenClawConfig): ResolvedAndoAccount {
|
|
63
|
+
const config = (cfg.channels?.ando ?? {}) as AndoConfig;
|
|
64
|
+
const realtimeHost = normalizeUrl(config.realtimeHost);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
68
|
+
baseUrl: normalizeUrl(config.baseUrl),
|
|
69
|
+
apiKey: trimValue(config.apiKey),
|
|
70
|
+
realtimeHost: realtimeHost || null,
|
|
71
|
+
mcpUrl: resolveAndoMcpUrl({
|
|
72
|
+
baseUrl: config.baseUrl ?? "",
|
|
73
|
+
mcpUrl: config.mcpUrl,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const AndoConfigSchema = z
|
|
79
|
+
.object({
|
|
80
|
+
baseUrl: z.string().optional(),
|
|
81
|
+
apiKey: z.string().optional(),
|
|
82
|
+
realtimeHost: z.string().optional(),
|
|
83
|
+
mcpUrl: z.string().optional(),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
|
|
87
|
+
const andoConfigBase = createTopLevelChannelConfigBase<
|
|
88
|
+
ResolvedAndoAccount,
|
|
89
|
+
OpenClawConfig
|
|
90
|
+
>({
|
|
91
|
+
sectionKey: "ando",
|
|
92
|
+
resolveAccount: (cfg) => resolveAndoAccount(cfg),
|
|
93
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
94
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
95
|
+
deleteMode: "clear-fields",
|
|
96
|
+
clearBaseFields: ["baseUrl", "apiKey", "realtimeHost", "mcpUrl"],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
type AndoOutboundParams = {
|
|
100
|
+
cfg: OpenClawConfig;
|
|
101
|
+
to: string;
|
|
102
|
+
text: string;
|
|
103
|
+
replyToId?: string | null;
|
|
104
|
+
threadId?: string | number | null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function isAndoRestConfigured(account: ResolvedAndoAccount): boolean {
|
|
108
|
+
return Boolean(account.baseUrl && account.apiKey);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertAndoRestConfigured(account: ResolvedAndoAccount): void {
|
|
112
|
+
if (!isAndoRestConfigured(account)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`[ando] account "${account.accountId}" is missing baseUrl or apiKey`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertAndoRealtimeConfigured(account: ResolvedAndoAccount): void {
|
|
120
|
+
assertAndoRestConfigured(account);
|
|
121
|
+
|
|
122
|
+
if (!account.realtimeHost) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[ando] account "${account.accountId}" is missing realtimeHost for realtime monitoring`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveThreadRootId(params: {
|
|
130
|
+
replyToId?: string | null;
|
|
131
|
+
threadId?: string | number | null;
|
|
132
|
+
}): string | null {
|
|
133
|
+
const threadId = typeof params.threadId === "string" ? params.threadId.trim() : "";
|
|
134
|
+
if (threadId) {
|
|
135
|
+
return threadId;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const replyToId = params.replyToId?.trim();
|
|
139
|
+
if (replyToId) {
|
|
140
|
+
return replyToId;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseExplicitAndoChannelTarget(value?: string | null) {
|
|
147
|
+
const trimmed = value?.trim() ?? "";
|
|
148
|
+
if (!trimmed) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const normalized = trimmed.replace(/^ando:/i, "").trim();
|
|
153
|
+
if (!/^(conversation|channel|group|room):/i.test(normalized)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsed = parseAndoTarget(trimmed);
|
|
158
|
+
return parsed?.kind === "channel" ? parsed : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveAndoMentionStripPatterns(): string[] {
|
|
162
|
+
return ["<!member_group:[^>]+>"];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function sendAndoOutbound(
|
|
166
|
+
params: Omit<AndoOutboundParams, "text"> & { text?: string | null },
|
|
167
|
+
) {
|
|
168
|
+
const target = parseExplicitAndoChannelTarget(params.to);
|
|
169
|
+
if (!target) {
|
|
170
|
+
throw new Error(`[ando] unsupported outbound target: ${params.to}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const account = resolveAndoAccount(params.cfg);
|
|
174
|
+
assertAndoRestConfigured(account);
|
|
175
|
+
|
|
176
|
+
const response = await postAndoMessage({
|
|
177
|
+
account,
|
|
178
|
+
conversationId: target.conversationId,
|
|
179
|
+
markdownContent: params.text?.trim() || null,
|
|
180
|
+
threadRootId: resolveThreadRootId({
|
|
181
|
+
replyToId: params.replyToId,
|
|
182
|
+
threadId: params.threadId,
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
messageId: response.id,
|
|
188
|
+
chatId: target.conversationId,
|
|
189
|
+
to: formatAndoTarget(target),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function handleOutboundSend(params: AndoOutboundParams) {
|
|
194
|
+
return {
|
|
195
|
+
channel: "ando" as const,
|
|
196
|
+
...(await sendAndoOutbound(params)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const andoPlugin: ChannelPlugin<ResolvedAndoAccount> = {
|
|
201
|
+
id: "ando",
|
|
202
|
+
meta,
|
|
203
|
+
capabilities: {
|
|
204
|
+
chatTypes: ["channel", "thread"],
|
|
205
|
+
threads: true,
|
|
206
|
+
media: false,
|
|
207
|
+
nativeCommands: false,
|
|
208
|
+
blockStreaming: true,
|
|
209
|
+
},
|
|
210
|
+
reload: { configPrefixes: ["channels.ando"] },
|
|
211
|
+
configSchema: buildChannelConfigSchema(AndoConfigSchema),
|
|
212
|
+
config: {
|
|
213
|
+
...andoConfigBase,
|
|
214
|
+
isConfigured: isAndoRestConfigured,
|
|
215
|
+
unconfiguredReason: (account) => {
|
|
216
|
+
if (!account.baseUrl || !account.apiKey) {
|
|
217
|
+
return "baseUrl and apiKey are required";
|
|
218
|
+
}
|
|
219
|
+
return "Ando channel is configured";
|
|
220
|
+
},
|
|
221
|
+
describeAccount: (account) => ({
|
|
222
|
+
accountId: account.accountId,
|
|
223
|
+
configured: isAndoRestConfigured(account),
|
|
224
|
+
}),
|
|
225
|
+
resolveAllowFrom: () => undefined,
|
|
226
|
+
formatAllowFrom: () => [],
|
|
227
|
+
},
|
|
228
|
+
groups: {
|
|
229
|
+
resolveRequireMention: () => true,
|
|
230
|
+
},
|
|
231
|
+
mentions: {
|
|
232
|
+
stripPatterns: () => resolveAndoMentionStripPatterns(),
|
|
233
|
+
},
|
|
234
|
+
messaging: {
|
|
235
|
+
normalizeTarget: (target) => {
|
|
236
|
+
const parsed = parseExplicitAndoChannelTarget(target);
|
|
237
|
+
return parsed ? formatAndoTarget(parsed) : target.trim();
|
|
238
|
+
},
|
|
239
|
+
targetResolver: {
|
|
240
|
+
looksLikeId: (input, normalized) =>
|
|
241
|
+
parseExplicitAndoChannelTarget(normalized ?? input) != null,
|
|
242
|
+
hint: "<conversation:uuid>",
|
|
243
|
+
resolveTarget: async ({ normalized }) => {
|
|
244
|
+
const parsed = parseExplicitAndoChannelTarget(normalized);
|
|
245
|
+
if (!parsed) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
to: formatAndoTarget(parsed),
|
|
250
|
+
kind: "channel" as const,
|
|
251
|
+
source: "normalized" as const,
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
outbound: {
|
|
257
|
+
deliveryMode: "direct",
|
|
258
|
+
textChunkLimit: 12_000,
|
|
259
|
+
sendText: handleOutboundSend,
|
|
260
|
+
},
|
|
261
|
+
gateway: {
|
|
262
|
+
startAccount: async (ctx) => {
|
|
263
|
+
const account = ctx.account;
|
|
264
|
+
if (!account.realtimeHost) {
|
|
265
|
+
ctx.runtime.log?.(
|
|
266
|
+
`[ando] skipping realtime monitoring for account "${account.accountId}" because realtimeHost is not configured`
|
|
267
|
+
);
|
|
268
|
+
await waitForAbort(ctx.abortSignal);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
assertAndoRealtimeConfigured(account);
|
|
273
|
+
const channelRuntime = ctx.channelRuntime;
|
|
274
|
+
if (!channelRuntime) {
|
|
275
|
+
throw new Error("[ando] channelRuntime is required");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await monitorAndoProvider({
|
|
279
|
+
account,
|
|
280
|
+
cfg: ctx.cfg,
|
|
281
|
+
runtime: ctx.runtime,
|
|
282
|
+
abortSignal: ctx.abortSignal,
|
|
283
|
+
channelRuntime,
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AndoClient } from "@andocorp/sdk";
|
|
2
|
+
import { ResolvedAndoAccount } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export function createAndoSdkClient(account: ResolvedAndoAccount) {
|
|
5
|
+
return new AndoClient({
|
|
6
|
+
baseUrl: account.baseUrl,
|
|
7
|
+
realtimeHost: account.realtimeHost ?? undefined,
|
|
8
|
+
auth: {
|
|
9
|
+
apiKey: account.apiKey,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function postAndoMessage(params: {
|
|
15
|
+
account: ResolvedAndoAccount;
|
|
16
|
+
client?: AndoClient;
|
|
17
|
+
conversationId: string;
|
|
18
|
+
markdownContent: string | null;
|
|
19
|
+
threadRootId?: string | null;
|
|
20
|
+
}) {
|
|
21
|
+
return (params.client ?? createAndoSdkClient(params.account)).postMessage({
|
|
22
|
+
conversationId: params.conversationId,
|
|
23
|
+
markdownContent: params.markdownContent,
|
|
24
|
+
threadRootId: params.threadRootId ?? null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
export const DEFAULT_MCP_INPUT_SCHEMA = {
|
|
2
|
+
type: "object",
|
|
3
|
+
properties: {},
|
|
4
|
+
} as const;
|
|
5
|
+
|
|
6
|
+
export type ToolDefinition = {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
inputSchema?: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function stringProperty(description: string) {
|
|
13
|
+
return {
|
|
14
|
+
type: "string",
|
|
15
|
+
description,
|
|
16
|
+
} as const;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function numberProperty(description: string) {
|
|
20
|
+
return {
|
|
21
|
+
type: "number",
|
|
22
|
+
description,
|
|
23
|
+
} as const;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function objectSchema(
|
|
27
|
+
properties: Record<string, unknown>,
|
|
28
|
+
required: string[] = []
|
|
29
|
+
) {
|
|
30
|
+
return {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties,
|
|
33
|
+
...(required.length > 0 ? { required } : {}),
|
|
34
|
+
} as const;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CURATED_ANDO_MCP_TOOLS: ToolDefinition[] = [
|
|
38
|
+
{
|
|
39
|
+
name: "search_messages",
|
|
40
|
+
description:
|
|
41
|
+
"Search messages across conversations by keyword or semantic similarity. Requires a real search query — do not use wildcards like '*'. To browse recent messages or filter by author without a keyword, use get_conversation_messages instead.",
|
|
42
|
+
inputSchema: objectSchema(
|
|
43
|
+
{
|
|
44
|
+
q: stringProperty(
|
|
45
|
+
"Search query text — must be a real keyword or phrase, not a wildcard"
|
|
46
|
+
),
|
|
47
|
+
author: stringProperty("Comma-separated member IDs to filter by author"),
|
|
48
|
+
conversation: stringProperty(
|
|
49
|
+
"Comma-separated conversation IDs to restrict search scope"
|
|
50
|
+
),
|
|
51
|
+
thread: stringProperty(
|
|
52
|
+
"Thread root message ID to search within a specific thread"
|
|
53
|
+
),
|
|
54
|
+
after: stringProperty(
|
|
55
|
+
"ISO timestamp — only include messages after this date"
|
|
56
|
+
),
|
|
57
|
+
before: stringProperty(
|
|
58
|
+
"ISO timestamp — only include messages before this date"
|
|
59
|
+
),
|
|
60
|
+
mode: {
|
|
61
|
+
type: "string",
|
|
62
|
+
enum: ["full-text", "semantic"],
|
|
63
|
+
description:
|
|
64
|
+
"Search mode: 'full-text' for keyword search (default), 'semantic' for AI-powered similarity",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
["q"]
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "search_members",
|
|
72
|
+
description:
|
|
73
|
+
"Search workspace members by name, email, or title. Returns matching member profiles.",
|
|
74
|
+
inputSchema: objectSchema(
|
|
75
|
+
{
|
|
76
|
+
q: stringProperty("Search query text"),
|
|
77
|
+
},
|
|
78
|
+
["q"]
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "search_conversations",
|
|
83
|
+
description:
|
|
84
|
+
"Search conversations (channels and DMs) by name or description. Returns matching conversations with member counts.",
|
|
85
|
+
inputSchema: objectSchema(
|
|
86
|
+
{
|
|
87
|
+
q: stringProperty("Search query text"),
|
|
88
|
+
},
|
|
89
|
+
["q"]
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "search_clipboard",
|
|
94
|
+
description:
|
|
95
|
+
"Search clipboard items (curated context bundles). Returns matching clipboards with title, description, and item count.",
|
|
96
|
+
inputSchema: objectSchema(
|
|
97
|
+
{
|
|
98
|
+
q: stringProperty("Search query text"),
|
|
99
|
+
},
|
|
100
|
+
["q"]
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "search_calls",
|
|
105
|
+
description:
|
|
106
|
+
"Search calls by transcript content. Returns matching calls with participants, duration, and summary.",
|
|
107
|
+
inputSchema: objectSchema(
|
|
108
|
+
{
|
|
109
|
+
q: stringProperty("Search query text"),
|
|
110
|
+
conversation: stringProperty(
|
|
111
|
+
"Comma-separated conversation IDs to restrict search scope"
|
|
112
|
+
),
|
|
113
|
+
after: stringProperty(
|
|
114
|
+
"ISO timestamp — only include calls after this date"
|
|
115
|
+
),
|
|
116
|
+
before: stringProperty(
|
|
117
|
+
"ISO timestamp — only include calls before this date"
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
["q"]
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "get_message",
|
|
125
|
+
description:
|
|
126
|
+
"Fetch a single message by ID. Returns the full message with author, conversation, and content.",
|
|
127
|
+
inputSchema: objectSchema(
|
|
128
|
+
{
|
|
129
|
+
message_id: stringProperty("The message ID to look up"),
|
|
130
|
+
},
|
|
131
|
+
["message_id"]
|
|
132
|
+
),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "get_member",
|
|
136
|
+
description:
|
|
137
|
+
"Fetch a single workspace member by ID. Returns the member's profile including name, email, and title.",
|
|
138
|
+
inputSchema: objectSchema(
|
|
139
|
+
{
|
|
140
|
+
member_id: stringProperty("The member ID to look up"),
|
|
141
|
+
},
|
|
142
|
+
["member_id"]
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "get_clipboard",
|
|
147
|
+
description:
|
|
148
|
+
"Fetch a clipboard (curated context bundle) by ID. Returns full clipboard with all items including messages, members, conversations, and files.",
|
|
149
|
+
inputSchema: objectSchema(
|
|
150
|
+
{
|
|
151
|
+
clipboard_id: stringProperty("The clipboard ID to look up"),
|
|
152
|
+
},
|
|
153
|
+
["clipboard_id"]
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "get_call",
|
|
158
|
+
description:
|
|
159
|
+
"Fetch a call by ID, also called a jam. Returns call details including participants, duration, status, and summary.",
|
|
160
|
+
inputSchema: objectSchema(
|
|
161
|
+
{
|
|
162
|
+
call_id: stringProperty("The call ID to look up"),
|
|
163
|
+
},
|
|
164
|
+
["call_id"]
|
|
165
|
+
),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "get_call_transcript",
|
|
169
|
+
description:
|
|
170
|
+
"Fetch a paginated transcript for a call, also called a jam. Returns transcript segments with speaker names and timestamps. Use cursor for pagination.",
|
|
171
|
+
inputSchema: objectSchema(
|
|
172
|
+
{
|
|
173
|
+
call_id: stringProperty("The call ID"),
|
|
174
|
+
limit: numberProperty(
|
|
175
|
+
"Maximum number of segments to return (default 100)"
|
|
176
|
+
),
|
|
177
|
+
cursor: stringProperty(
|
|
178
|
+
"Pagination cursor from a previous response's pageInfo.endCursor"
|
|
179
|
+
),
|
|
180
|
+
},
|
|
181
|
+
["call_id"]
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "get_conversation_messages",
|
|
186
|
+
description:
|
|
187
|
+
"Fetch paginated messages from a conversation. Returns messages in reverse chronological order. Use this to browse recent messages, optionally filtered by author. Use 'before' cursor for pagination.",
|
|
188
|
+
inputSchema: objectSchema(
|
|
189
|
+
{
|
|
190
|
+
conversation_id: stringProperty("The conversation ID"),
|
|
191
|
+
author: stringProperty("Comma-separated member IDs to filter by author"),
|
|
192
|
+
limit: numberProperty(
|
|
193
|
+
"Maximum number of messages to return (default 20)"
|
|
194
|
+
),
|
|
195
|
+
before: stringProperty(
|
|
196
|
+
"Pagination cursor — fetch messages before this cursor (from pageInfo.startCursor)"
|
|
197
|
+
),
|
|
198
|
+
},
|
|
199
|
+
["conversation_id"]
|
|
200
|
+
),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "get_thread_replies",
|
|
204
|
+
description:
|
|
205
|
+
"Fetch paginated replies to a thread root message. Use this when a message has replies_count > 0. Returns replies in chronological order (oldest first). Use 'after' cursor for pagination.",
|
|
206
|
+
inputSchema: objectSchema(
|
|
207
|
+
{
|
|
208
|
+
message_id: stringProperty("The thread root message ID"),
|
|
209
|
+
limit: numberProperty(
|
|
210
|
+
"Maximum number of replies to return (default 20)"
|
|
211
|
+
),
|
|
212
|
+
after: stringProperty(
|
|
213
|
+
"Pagination cursor — fetch replies after this cursor (from pageInfo.endCursor)"
|
|
214
|
+
),
|
|
215
|
+
},
|
|
216
|
+
["message_id"]
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
export function listCuratedAndoMcpTools(): ToolDefinition[] {
|
|
222
|
+
return CURATED_ANDO_MCP_TOOLS;
|
|
223
|
+
}
|
package/src/mcp-tools.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { resolveAndoAccount } from "./channel.js";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MCP_INPUT_SCHEMA,
|
|
5
|
+
listCuratedAndoMcpTools,
|
|
6
|
+
} from "./mcp-tool-catalog.js";
|
|
7
|
+
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
8
|
+
const MCP_ACCEPT_HEADER = "application/json, text/event-stream";
|
|
9
|
+
|
|
10
|
+
type JsonRpcEnvelope = {
|
|
11
|
+
result?: unknown;
|
|
12
|
+
error?: {
|
|
13
|
+
message?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type McpHttpResponse = {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
status: number;
|
|
20
|
+
body: string;
|
|
21
|
+
sessionId: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function buildMcpJsonRpcRequest(params: {
|
|
25
|
+
id: string;
|
|
26
|
+
method: string;
|
|
27
|
+
params: Record<string, unknown>;
|
|
28
|
+
}) {
|
|
29
|
+
return {
|
|
30
|
+
jsonrpc: "2.0" as const,
|
|
31
|
+
id: params.id,
|
|
32
|
+
method: params.method,
|
|
33
|
+
params: params.params,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildMcpJsonRpcNotification(params: {
|
|
38
|
+
method: string;
|
|
39
|
+
params?: Record<string, unknown>;
|
|
40
|
+
}) {
|
|
41
|
+
return {
|
|
42
|
+
jsonrpc: "2.0" as const,
|
|
43
|
+
method: params.method,
|
|
44
|
+
...(params.params ? { params: params.params } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildMcpRequestHeaders(apiKey: string, sessionId?: string): Record<string, string> {
|
|
49
|
+
const headers: Record<string, string> = {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
Accept: MCP_ACCEPT_HEADER,
|
|
52
|
+
Authorization: `Bearer ${apiKey}`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (sessionId) {
|
|
56
|
+
headers["mcp-session-id"] = sessionId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return headers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toAndoOpenClawToolName(toolName: string): string {
|
|
63
|
+
return `ando_${toolName}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function extractMcpJsonPayload(raw: string): JsonRpcEnvelope {
|
|
67
|
+
const events = raw
|
|
68
|
+
.split(/\r?\n\r?\n/)
|
|
69
|
+
.map((chunk) =>
|
|
70
|
+
chunk
|
|
71
|
+
.split(/\r?\n/)
|
|
72
|
+
.filter((line) => line.startsWith("data:"))
|
|
73
|
+
.map((line) => line.slice(5).trim())
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join("\n"),
|
|
76
|
+
)
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
const jsonText = (events.length > 0 ? events.at(-1) ?? "" : raw).trim();
|
|
79
|
+
|
|
80
|
+
if (!jsonText) {
|
|
81
|
+
throw new Error("Empty MCP response");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return JSON.parse(jsonText) as JsonRpcEnvelope;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectTextParts(content: unknown): string[] {
|
|
88
|
+
if (!Array.isArray(content)) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return content.map((part) => {
|
|
93
|
+
const typedPart = asMcpTextPart(part);
|
|
94
|
+
if (typedPart?.type === "text") {
|
|
95
|
+
return typedPart.text ?? "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return JSON.stringify(part, null, 2);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type McpToolResult = {
|
|
103
|
+
structuredContent?: unknown;
|
|
104
|
+
content?: unknown;
|
|
105
|
+
isError?: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function asMcpTextPart(value: unknown): { type?: string; text?: string } | null {
|
|
109
|
+
if (!value || typeof value !== "object" || !("type" in value)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return value as { type?: string; text?: string };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function asMcpToolResult(value: unknown): McpToolResult {
|
|
117
|
+
if (!value || typeof value !== "object") {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return value as McpToolResult;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function formatMcpToolResultText(result: McpToolResult): string {
|
|
125
|
+
if (result?.structuredContent != null) {
|
|
126
|
+
return JSON.stringify(result.structuredContent, null, 2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const textParts = collectTextParts(result?.content);
|
|
130
|
+
if (textParts.length > 0) {
|
|
131
|
+
return textParts.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return JSON.stringify(result, null, 2);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractMcpResultErrorMessage(result: McpToolResult): string {
|
|
138
|
+
const textParts = collectTextParts(result?.content);
|
|
139
|
+
for (const text of textParts) {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(text) as { error?: unknown };
|
|
142
|
+
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
|
143
|
+
return parsed.error;
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Ignore non-JSON content and fall back to the raw text below.
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const trimmed = text.trim();
|
|
150
|
+
if (trimmed) {
|
|
151
|
+
return trimmed;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return formatMcpToolResultText(result);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ensureMcpOk(payload: JsonRpcEnvelope): void {
|
|
159
|
+
if (payload.error) {
|
|
160
|
+
throw new Error(payload.error.message ?? "Unknown MCP error");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
payload.result &&
|
|
165
|
+
typeof payload.result === "object" &&
|
|
166
|
+
"isError" in payload.result &&
|
|
167
|
+
(payload.result as McpToolResult).isError
|
|
168
|
+
) {
|
|
169
|
+
throw new Error(extractMcpResultErrorMessage(payload.result as McpToolResult));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ensureMcpHttpOk(response: McpHttpResponse, action: string): void {
|
|
174
|
+
if (response.ok) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error(`MCP ${action} failed: ${response.status} ${response.body}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function runMcpJsonRpc(params: {
|
|
182
|
+
mcpUrl: string;
|
|
183
|
+
apiKey: string;
|
|
184
|
+
action: string;
|
|
185
|
+
request: Record<string, unknown>;
|
|
186
|
+
sessionId?: string;
|
|
187
|
+
}): Promise<McpHttpResponse> {
|
|
188
|
+
const response = await fetch(params.mcpUrl, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: buildMcpRequestHeaders(params.apiKey, params.sessionId),
|
|
191
|
+
body: JSON.stringify(params.request),
|
|
192
|
+
});
|
|
193
|
+
const body = await response.text();
|
|
194
|
+
const result: McpHttpResponse = {
|
|
195
|
+
ok: response.ok,
|
|
196
|
+
status: response.status,
|
|
197
|
+
body,
|
|
198
|
+
sessionId: response.headers.get("mcp-session-id"),
|
|
199
|
+
};
|
|
200
|
+
ensureMcpHttpOk(result, params.action);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function closeMcpSession(params: {
|
|
205
|
+
mcpUrl: string;
|
|
206
|
+
apiKey: string;
|
|
207
|
+
sessionId: string;
|
|
208
|
+
}): Promise<void> {
|
|
209
|
+
const response = await fetch(params.mcpUrl, {
|
|
210
|
+
method: "DELETE",
|
|
211
|
+
headers: buildMcpRequestHeaders(params.apiKey, params.sessionId),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (response.ok) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const body = await response.text();
|
|
219
|
+
ensureMcpHttpOk(
|
|
220
|
+
{
|
|
221
|
+
ok: response.ok,
|
|
222
|
+
status: response.status,
|
|
223
|
+
body,
|
|
224
|
+
sessionId: response.headers.get("mcp-session-id"),
|
|
225
|
+
},
|
|
226
|
+
"session/delete"
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function initializeMcpSession(mcpUrl: string, apiKey: string): Promise<string> {
|
|
231
|
+
const response = await runMcpJsonRpc({
|
|
232
|
+
mcpUrl,
|
|
233
|
+
apiKey,
|
|
234
|
+
action: "initialize",
|
|
235
|
+
request: buildMcpJsonRpcRequest({
|
|
236
|
+
id: "1",
|
|
237
|
+
method: "initialize",
|
|
238
|
+
params: {
|
|
239
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
240
|
+
capabilities: {},
|
|
241
|
+
clientInfo: {
|
|
242
|
+
name: "openclaw-ando-plugin",
|
|
243
|
+
version: "1.0",
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const payload = extractMcpJsonPayload(response.body);
|
|
250
|
+
ensureMcpOk(payload);
|
|
251
|
+
|
|
252
|
+
const sessionId = response.sessionId?.trim();
|
|
253
|
+
if (!sessionId) {
|
|
254
|
+
throw new Error("Missing MCP session ID");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return sessionId;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function notifyMcpSessionInitialized(params: {
|
|
261
|
+
mcpUrl: string;
|
|
262
|
+
apiKey: string;
|
|
263
|
+
sessionId: string;
|
|
264
|
+
}): Promise<void> {
|
|
265
|
+
await runMcpJsonRpc({
|
|
266
|
+
mcpUrl: params.mcpUrl,
|
|
267
|
+
apiKey: params.apiKey,
|
|
268
|
+
sessionId: params.sessionId,
|
|
269
|
+
action: "notifications/initialized",
|
|
270
|
+
request: buildMcpJsonRpcNotification({
|
|
271
|
+
method: "notifications/initialized",
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function callAndoMcpTool(params: {
|
|
277
|
+
mcpUrl: string;
|
|
278
|
+
apiKey: string;
|
|
279
|
+
name: string;
|
|
280
|
+
arguments_: Record<string, unknown>;
|
|
281
|
+
}): Promise<string> {
|
|
282
|
+
const sessionId = await initializeMcpSession(params.mcpUrl, params.apiKey);
|
|
283
|
+
try {
|
|
284
|
+
await notifyMcpSessionInitialized({
|
|
285
|
+
mcpUrl: params.mcpUrl,
|
|
286
|
+
apiKey: params.apiKey,
|
|
287
|
+
sessionId,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const response = await runMcpJsonRpc({
|
|
291
|
+
mcpUrl: params.mcpUrl,
|
|
292
|
+
apiKey: params.apiKey,
|
|
293
|
+
sessionId,
|
|
294
|
+
action: `tools/call(${params.name})`,
|
|
295
|
+
request: buildMcpJsonRpcRequest({
|
|
296
|
+
id: "2",
|
|
297
|
+
method: "tools/call",
|
|
298
|
+
params: {
|
|
299
|
+
name: params.name,
|
|
300
|
+
arguments: params.arguments_,
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const payload = extractMcpJsonPayload(response.body);
|
|
306
|
+
ensureMcpOk(payload);
|
|
307
|
+
return formatMcpToolResultText(asMcpToolResult(payload.result));
|
|
308
|
+
} finally {
|
|
309
|
+
await closeMcpSession({
|
|
310
|
+
mcpUrl: params.mcpUrl,
|
|
311
|
+
apiKey: params.apiKey,
|
|
312
|
+
sessionId,
|
|
313
|
+
}).catch(() => undefined);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function registerAndoMcpTools(
|
|
318
|
+
api: Pick<OpenClawPluginApi, "config" | "registerTool" | "logger">,
|
|
319
|
+
) {
|
|
320
|
+
const tools = listCuratedAndoMcpTools();
|
|
321
|
+
|
|
322
|
+
for (const tool of tools) {
|
|
323
|
+
const openClawToolName = toAndoOpenClawToolName(tool.name);
|
|
324
|
+
api.registerTool((context: OpenClawPluginToolContext) => {
|
|
325
|
+
const account = resolveAndoAccount(context.config ?? {});
|
|
326
|
+
|
|
327
|
+
if (!account.mcpUrl || !account.apiKey) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const mcpUrl = account.mcpUrl;
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
name: openClawToolName,
|
|
335
|
+
label: openClawToolName,
|
|
336
|
+
description: tool.description
|
|
337
|
+
? `Ando MCP: ${tool.description}`
|
|
338
|
+
: "Ando MCP tool",
|
|
339
|
+
parameters: (tool.inputSchema ?? DEFAULT_MCP_INPUT_SCHEMA) as Record<
|
|
340
|
+
string,
|
|
341
|
+
unknown
|
|
342
|
+
>,
|
|
343
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
344
|
+
try {
|
|
345
|
+
const text = await callAndoMcpTool({
|
|
346
|
+
mcpUrl,
|
|
347
|
+
apiKey: account.apiKey,
|
|
348
|
+
name: tool.name,
|
|
349
|
+
arguments_: params,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: "text" as const, text }],
|
|
354
|
+
details: null,
|
|
355
|
+
};
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const message =
|
|
358
|
+
error instanceof Error ? error.message : String(error);
|
|
359
|
+
throw new Error(`Error calling Ando MCP tool ${tool.name}: ${message}`);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
api.logger.info?.(
|
|
367
|
+
`[ando] registered ${tools.length} curated MCP tool proxy factories`
|
|
368
|
+
);
|
|
369
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveThreadSessionKeys,
|
|
3
|
+
OpenClawConfig,
|
|
4
|
+
PluginRuntime,
|
|
5
|
+
} from "openclaw/plugin-sdk/core";
|
|
6
|
+
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
7
|
+
import {
|
|
8
|
+
createNormalizedOutboundDeliverer,
|
|
9
|
+
deliverFormattedTextWithAttachments,
|
|
10
|
+
} from "openclaw/plugin-sdk/reply-payload";
|
|
11
|
+
import {
|
|
12
|
+
formatAndoTarget,
|
|
13
|
+
AndoConversation,
|
|
14
|
+
AndoNormalizedMessageCreatedEvent,
|
|
15
|
+
} from "@andocorp/sdk";
|
|
16
|
+
import { createAndoSdkClient, postAndoMessage } from "./client.js";
|
|
17
|
+
import { ResolvedAndoAccount } from "./types.js";
|
|
18
|
+
|
|
19
|
+
type MonitorRuntime = {
|
|
20
|
+
log?: (message: string) => void;
|
|
21
|
+
error?: (message: string) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ANDO_MEMBER_GROUP_MENTION_REGEX = /(^|\s)<!member_group:[^>]+>\s*/g;
|
|
25
|
+
type AndoInboundHistory =
|
|
26
|
+
NonNullable<AndoNormalizedMessageCreatedEvent["context"]>["inboundHistory"];
|
|
27
|
+
type AndoInboundHistoryEntry = AndoInboundHistory[number];
|
|
28
|
+
|
|
29
|
+
function stringifyError(error: unknown): string {
|
|
30
|
+
if (error instanceof Error) {
|
|
31
|
+
return error.message;
|
|
32
|
+
}
|
|
33
|
+
return String(error);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripAndoMemberGroupMentions(text?: string | null): string {
|
|
37
|
+
const value = text ?? "";
|
|
38
|
+
return value.replace(ANDO_MEMBER_GROUP_MENTION_REGEX, "$1").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripAndoInboundHistory(
|
|
42
|
+
history: AndoInboundHistory,
|
|
43
|
+
): AndoInboundHistory {
|
|
44
|
+
return history.map((entry: AndoInboundHistoryEntry) => ({
|
|
45
|
+
...entry,
|
|
46
|
+
body: stripAndoMemberGroupMentions(entry.body),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toUnixTimestamp(value: string | Date | undefined): number | undefined {
|
|
51
|
+
if (!value) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return value instanceof Date ? value.getTime() : Date.parse(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveAuthorLabel(author: {
|
|
59
|
+
display_name?: string | null;
|
|
60
|
+
id?: string | null;
|
|
61
|
+
}): string {
|
|
62
|
+
return author.display_name?.trim() || author.id || "unknown";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveConversationLabel(
|
|
66
|
+
conversation: AndoConversation | undefined,
|
|
67
|
+
conversationId: string
|
|
68
|
+
): string {
|
|
69
|
+
const name = conversation?.name?.trim();
|
|
70
|
+
if (name) {
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
return `Ando conversation ${conversationId}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveAndoInboundThreadId(params: {
|
|
77
|
+
replyThreadRootId?: string | null;
|
|
78
|
+
}): string | undefined {
|
|
79
|
+
const threadId = params.replyThreadRootId?.trim();
|
|
80
|
+
if (!threadId) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return threadId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveAndoReplyThreadRootId(params: {
|
|
88
|
+
contextReplyThreadRootId?: string | null;
|
|
89
|
+
payloadReplyToId?: string | null;
|
|
90
|
+
}): string | null {
|
|
91
|
+
const contextThreadRootId = params.contextReplyThreadRootId?.trim();
|
|
92
|
+
if (contextThreadRootId) {
|
|
93
|
+
return contextThreadRootId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payloadReplyToId = params.payloadReplyToId?.trim();
|
|
97
|
+
return payloadReplyToId || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveAndoConversationTarget(params: {
|
|
101
|
+
conversationId: string;
|
|
102
|
+
}): string {
|
|
103
|
+
return formatAndoTarget({
|
|
104
|
+
kind: "channel",
|
|
105
|
+
conversationId: params.conversationId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resolveAndoDispatchKey(params: {
|
|
110
|
+
accountId: string;
|
|
111
|
+
event: AndoNormalizedMessageCreatedEvent;
|
|
112
|
+
}): string {
|
|
113
|
+
const threadId = resolveAndoInboundThreadId({
|
|
114
|
+
replyThreadRootId: params.event.context?.replyThreadRootId,
|
|
115
|
+
});
|
|
116
|
+
const baseTarget = resolveAndoConversationTarget({
|
|
117
|
+
conversationId: params.event.message.conversation_id,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return threadId
|
|
121
|
+
? `${params.accountId}:${baseTarget}:thread:${threadId}`
|
|
122
|
+
: `${params.accountId}:${baseTarget}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createAndoDispatchSerializer() {
|
|
126
|
+
const pending = new Map<string, Promise<void>>();
|
|
127
|
+
|
|
128
|
+
return function runSerialized<T>(
|
|
129
|
+
key: string,
|
|
130
|
+
task: () => Promise<T>,
|
|
131
|
+
): Promise<T> {
|
|
132
|
+
const previous = pending.get(key) ?? Promise.resolve();
|
|
133
|
+
const current = previous.then(task);
|
|
134
|
+
const tracked = current.then(
|
|
135
|
+
() => {
|
|
136
|
+
if (pending.get(key) === tracked) {
|
|
137
|
+
pending.delete(key);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
() => {
|
|
141
|
+
if (pending.get(key) === tracked) {
|
|
142
|
+
pending.delete(key);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
pending.set(key, tracked);
|
|
148
|
+
return current;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function dispatchAndoMessage(params: {
|
|
153
|
+
cfg: OpenClawConfig;
|
|
154
|
+
channelRuntime: PluginRuntime["channel"];
|
|
155
|
+
client: ReturnType<typeof createAndoSdkClient>;
|
|
156
|
+
account: ResolvedAndoAccount;
|
|
157
|
+
event: AndoNormalizedMessageCreatedEvent;
|
|
158
|
+
runtime: MonitorRuntime;
|
|
159
|
+
}) {
|
|
160
|
+
if (params.event.isDirectMessage) {
|
|
161
|
+
params.runtime.log?.(
|
|
162
|
+
`[ando] skipping direct message=${params.event.message.id} because this adapter only handles channel mentions`
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const context = params.event.context;
|
|
168
|
+
if (!context) {
|
|
169
|
+
params.runtime.log?.(
|
|
170
|
+
`[ando] skipping message=${params.event.message.id} because inbound context is empty`
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const message = params.event.message;
|
|
176
|
+
const conversation = params.event.conversation;
|
|
177
|
+
const route = params.channelRuntime.routing.resolveAgentRoute({
|
|
178
|
+
cfg: params.cfg,
|
|
179
|
+
channel: "ando",
|
|
180
|
+
accountId: params.account.accountId,
|
|
181
|
+
peer: {
|
|
182
|
+
kind: "channel",
|
|
183
|
+
id: message.conversation_id,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const inboundThreadId = resolveAndoInboundThreadId({
|
|
187
|
+
replyThreadRootId: context.replyThreadRootId,
|
|
188
|
+
});
|
|
189
|
+
const threadKeys = resolveThreadSessionKeys({
|
|
190
|
+
baseSessionKey: route.sessionKey,
|
|
191
|
+
threadId: inboundThreadId,
|
|
192
|
+
parentSessionKey: undefined,
|
|
193
|
+
});
|
|
194
|
+
const conversationLabel = resolveConversationLabel(
|
|
195
|
+
conversation,
|
|
196
|
+
message.conversation_id
|
|
197
|
+
);
|
|
198
|
+
const senderName = resolveAuthorLabel(message.author);
|
|
199
|
+
const channelTo = resolveAndoConversationTarget({
|
|
200
|
+
conversationId: message.conversation_id,
|
|
201
|
+
});
|
|
202
|
+
const currentText = stripAndoMemberGroupMentions(context.currentText);
|
|
203
|
+
if (!currentText) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const inboundHistory = stripAndoInboundHistory(context.inboundHistory);
|
|
207
|
+
const threadStarterBody = stripAndoMemberGroupMentions(
|
|
208
|
+
context.threadStarterBody,
|
|
209
|
+
);
|
|
210
|
+
const threadHistoryBody = stripAndoMemberGroupMentions(
|
|
211
|
+
context.threadHistoryBody,
|
|
212
|
+
);
|
|
213
|
+
const ctxPayload = params.channelRuntime.reply.finalizeInboundContext({
|
|
214
|
+
Body: params.channelRuntime.reply.formatInboundEnvelope({
|
|
215
|
+
channel: "Ando",
|
|
216
|
+
from: conversationLabel,
|
|
217
|
+
timestamp: toUnixTimestamp(message.created_at),
|
|
218
|
+
body: currentText,
|
|
219
|
+
chatType: "channel",
|
|
220
|
+
senderLabel: senderName,
|
|
221
|
+
}),
|
|
222
|
+
BodyForAgent: currentText,
|
|
223
|
+
RawBody: currentText,
|
|
224
|
+
CommandBody: currentText,
|
|
225
|
+
BodyForCommands: currentText,
|
|
226
|
+
InboundHistory: inboundHistory,
|
|
227
|
+
ThreadStarterBody: threadStarterBody || undefined,
|
|
228
|
+
ThreadHistoryBody: threadHistoryBody,
|
|
229
|
+
From: `ando:${channelTo}`,
|
|
230
|
+
To: channelTo,
|
|
231
|
+
SessionKey: threadKeys.sessionKey,
|
|
232
|
+
ParentSessionKey: threadKeys.parentSessionKey,
|
|
233
|
+
AccountId: route.accountId,
|
|
234
|
+
ChatType: "channel",
|
|
235
|
+
ConversationLabel: conversationLabel,
|
|
236
|
+
GroupSubject: conversationLabel,
|
|
237
|
+
SenderName: senderName,
|
|
238
|
+
SenderId: message.author.id,
|
|
239
|
+
Provider: "ando",
|
|
240
|
+
Surface: "ando",
|
|
241
|
+
MessageSid: message.id,
|
|
242
|
+
ReplyToId: context.replyThreadRootId,
|
|
243
|
+
MessageThreadId: inboundThreadId,
|
|
244
|
+
Timestamp: toUnixTimestamp(message.created_at) ?? Date.now(),
|
|
245
|
+
WasMentioned: true,
|
|
246
|
+
CommandAuthorized: false,
|
|
247
|
+
OriginatingChannel: "ando",
|
|
248
|
+
OriginatingTo: channelTo,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const storePath = params.channelRuntime.session.resolveStorePath(
|
|
252
|
+
params.cfg.session?.store,
|
|
253
|
+
{
|
|
254
|
+
agentId: route.agentId,
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
params.runtime.log?.(
|
|
259
|
+
`[ando] dispatching message=${message.id} agent=${route.agentId} session=${threadKeys.sessionKey}`
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
await params.channelRuntime.session.recordInboundSession({
|
|
263
|
+
storePath,
|
|
264
|
+
sessionKey: ctxPayload.SessionKey ?? threadKeys.sessionKey,
|
|
265
|
+
ctx: ctxPayload,
|
|
266
|
+
onRecordError: (error) => {
|
|
267
|
+
params.runtime.error?.(
|
|
268
|
+
`[ando] failed to record inbound session: ${stringifyError(error)}`
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
|
274
|
+
cfg: params.cfg,
|
|
275
|
+
agentId: route.agentId,
|
|
276
|
+
channel: "ando",
|
|
277
|
+
accountId: route.accountId,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
281
|
+
ctx: ctxPayload,
|
|
282
|
+
cfg: params.cfg,
|
|
283
|
+
dispatcherOptions: {
|
|
284
|
+
...replyPipeline,
|
|
285
|
+
deliver: createNormalizedOutboundDeliverer(async (payload) => {
|
|
286
|
+
await deliverFormattedTextWithAttachments({
|
|
287
|
+
payload,
|
|
288
|
+
send: async ({ text, replyToId }) => {
|
|
289
|
+
const markdownContent = text.trim();
|
|
290
|
+
if (!markdownContent) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await postAndoMessage({
|
|
295
|
+
client: params.client,
|
|
296
|
+
account: params.account,
|
|
297
|
+
conversationId: message.conversation_id,
|
|
298
|
+
markdownContent,
|
|
299
|
+
threadRootId: resolveAndoReplyThreadRootId({
|
|
300
|
+
contextReplyThreadRootId: context.replyThreadRootId,
|
|
301
|
+
payloadReplyToId: replyToId,
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}),
|
|
307
|
+
onError: (error, info) => {
|
|
308
|
+
params.runtime.error?.(
|
|
309
|
+
`[ando] reply ${info.kind} failed: ${stringifyError(error)}`
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
replyOptions: {
|
|
314
|
+
onModelSelected,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function monitorAndoProvider(params: {
|
|
320
|
+
account: ResolvedAndoAccount;
|
|
321
|
+
cfg: OpenClawConfig;
|
|
322
|
+
runtime: MonitorRuntime;
|
|
323
|
+
abortSignal?: AbortSignal;
|
|
324
|
+
channelRuntime: PluginRuntime["channel"];
|
|
325
|
+
}) {
|
|
326
|
+
const channelRuntime = params.channelRuntime;
|
|
327
|
+
const runSerializedDispatch = createAndoDispatchSerializer();
|
|
328
|
+
const pendingDispatches = new Set<Promise<void>>();
|
|
329
|
+
const client = createAndoSdkClient(params.account);
|
|
330
|
+
|
|
331
|
+
const trackDispatch = (task: Promise<void>) => {
|
|
332
|
+
pendingDispatches.add(task);
|
|
333
|
+
task.finally(() => {
|
|
334
|
+
pendingDispatches.delete(task);
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const waitForPendingDispatches = async () => {
|
|
339
|
+
while (pendingDispatches.size > 0) {
|
|
340
|
+
await Promise.allSettled(Array.from(pendingDispatches));
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const stream = await client.realtime.subscribeMember({
|
|
345
|
+
signal: params.abortSignal,
|
|
346
|
+
onMessage: async (event) => {
|
|
347
|
+
const dispatchKey = resolveAndoDispatchKey({
|
|
348
|
+
accountId: params.account.accountId,
|
|
349
|
+
event,
|
|
350
|
+
});
|
|
351
|
+
const task = runSerializedDispatch(dispatchKey, async () => {
|
|
352
|
+
if (params.abortSignal?.aborted) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await dispatchAndoMessage({
|
|
357
|
+
cfg: params.cfg,
|
|
358
|
+
channelRuntime,
|
|
359
|
+
client,
|
|
360
|
+
account: params.account,
|
|
361
|
+
event,
|
|
362
|
+
runtime: params.runtime,
|
|
363
|
+
});
|
|
364
|
+
}).catch((error) => {
|
|
365
|
+
params.runtime.error?.(
|
|
366
|
+
`[ando] dispatch failed: ${stringifyError(error)}`
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
trackDispatch(task);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const stopReason = await Promise.race([
|
|
374
|
+
stream.done().then(() => "stream-ended" as const),
|
|
375
|
+
new Promise<"aborted">((resolve) => {
|
|
376
|
+
if (params.abortSignal?.aborted) {
|
|
377
|
+
resolve("aborted");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
params.abortSignal?.addEventListener("abort", () => resolve("aborted"), {
|
|
382
|
+
once: true,
|
|
383
|
+
});
|
|
384
|
+
}),
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
if (stopReason === "stream-ended") {
|
|
388
|
+
throw new Error("[ando] realtime subscription stopped unexpectedly");
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
await stream.close();
|
|
392
|
+
await waitForPendingDispatches();
|
|
393
|
+
}
|
|
394
|
+
}
|