@pnds/pond 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/openclaw.plugin.json +26 -0
- package/package.json +30 -0
- package/src/accounts.ts +38 -0
- package/src/channel.ts +40 -0
- package/src/gateway.ts +221 -0
- package/src/hooks.ts +91 -0
- package/src/index.ts +19 -0
- package/src/outbound.ts +32 -0
- package/src/runtime.ts +52 -0
- package/src/session.ts +31 -0
- package/src/types.ts +18 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "pond",
|
|
3
|
+
"channels": ["pond"],
|
|
4
|
+
"configSchema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["pond_url", "api_key", "org_id"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"pond_url": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"minLength": 1,
|
|
12
|
+
"description": "Pond API base URL"
|
|
13
|
+
},
|
|
14
|
+
"api_key": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"minLength": 1,
|
|
17
|
+
"description": "Agent API key (agk_xxx)"
|
|
18
|
+
},
|
|
19
|
+
"org_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 1,
|
|
22
|
+
"description": "Organisation ID"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pnds/pond",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw channel plugin for Pond IM",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/RFlowAI/pond",
|
|
9
|
+
"directory": "ts/openclaw-channel"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"openclaw.plugin.json"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@pnds/sdk": "^0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.7.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"openclaw": ">=2025"
|
|
24
|
+
},
|
|
25
|
+
"openclaw": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/index.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ResolvedPondAccount, PondChannelConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
4
|
+
|
|
5
|
+
type OpenClawConfig = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function readPondConfig(cfg: OpenClawConfig): PondChannelConfig | undefined {
|
|
8
|
+
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
9
|
+
return channels?.pond as PondChannelConfig | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function listPondAccountIds(cfg: OpenClawConfig): string[] {
|
|
13
|
+
const pondCfg = readPondConfig(cfg);
|
|
14
|
+
if (!pondCfg) return [];
|
|
15
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolvePondAccount(params: {
|
|
19
|
+
cfg: OpenClawConfig;
|
|
20
|
+
accountId?: string;
|
|
21
|
+
}): ResolvedPondAccount {
|
|
22
|
+
const { cfg, accountId = DEFAULT_ACCOUNT_ID } = params;
|
|
23
|
+
const pondCfg = readPondConfig(cfg);
|
|
24
|
+
const config: PondChannelConfig = {
|
|
25
|
+
pond_url: pondCfg?.pond_url ?? "",
|
|
26
|
+
api_key: pondCfg?.api_key ?? "",
|
|
27
|
+
org_id: pondCfg?.org_id ?? "",
|
|
28
|
+
ws_url: pondCfg?.ws_url,
|
|
29
|
+
enabled: pondCfg?.enabled,
|
|
30
|
+
};
|
|
31
|
+
const configured = Boolean(config.api_key && config.pond_url && config.org_id);
|
|
32
|
+
return {
|
|
33
|
+
accountId,
|
|
34
|
+
enabled: config.enabled !== false,
|
|
35
|
+
configured,
|
|
36
|
+
config,
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ChannelPlugin, ChannelMeta } from "openclaw/plugin-sdk";
|
|
2
|
+
import { listPondAccountIds, resolvePondAccount } from "./accounts.js";
|
|
3
|
+
import { pondGateway } from "./gateway.js";
|
|
4
|
+
import { pondOutbound } from "./outbound.js";
|
|
5
|
+
import type { ResolvedPondAccount } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const meta: ChannelMeta = {
|
|
8
|
+
id: "pond",
|
|
9
|
+
label: "Pond",
|
|
10
|
+
selectionLabel: "Pond IM",
|
|
11
|
+
docsPath: "/channels/pond",
|
|
12
|
+
blurb: "Agent-Native IM platform.",
|
|
13
|
+
order: 80,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const pondPlugin: ChannelPlugin<ResolvedPondAccount> = {
|
|
17
|
+
id: "pond",
|
|
18
|
+
meta,
|
|
19
|
+
capabilities: {
|
|
20
|
+
chatTypes: ["direct", "group"],
|
|
21
|
+
polls: false,
|
|
22
|
+
threads: false,
|
|
23
|
+
media: false,
|
|
24
|
+
reactions: false,
|
|
25
|
+
edit: false,
|
|
26
|
+
reply: false,
|
|
27
|
+
},
|
|
28
|
+
config: {
|
|
29
|
+
listAccountIds: (cfg) => listPondAccountIds(cfg),
|
|
30
|
+
resolveAccount: (cfg, accountId) => resolvePondAccount({ cfg, accountId: accountId ?? undefined }),
|
|
31
|
+
isConfigured: (account) => account.configured,
|
|
32
|
+
describeAccount: (account) => ({
|
|
33
|
+
accountId: account.accountId,
|
|
34
|
+
enabled: account.enabled,
|
|
35
|
+
configured: account.configured,
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
outbound: pondOutbound,
|
|
39
|
+
gateway: pondGateway,
|
|
40
|
+
};
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { ChannelGatewayAdapter } from "openclaw/plugin-sdk";
|
|
2
|
+
import { PondClient, PondWs, MENTION_ALL_USER_ID, WS_EVENTS } from "@pnds/sdk";
|
|
3
|
+
import type { Chat, MessageNewData, SendMessageRequest, TextContent, HelloData, ChatUpdateData } from "@pnds/sdk";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { resolvePondAccount } from "./accounts.js";
|
|
6
|
+
import { getPondRuntime, setPondAccountState, removePondAccountState, setSessionChatId } from "./runtime.js";
|
|
7
|
+
import { buildSessionKey } from "./session.js";
|
|
8
|
+
import type { ResolvedPondAccount } from "./types.js";
|
|
9
|
+
|
|
10
|
+
function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
11
|
+
if (account.config.ws_url) return account.config.ws_url;
|
|
12
|
+
const base = account.config.pond_url.replace(/\/$/, "");
|
|
13
|
+
const wsBase = base.replace(/^http/, "ws");
|
|
14
|
+
return `${wsBase}/ws`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch all chats with pagination and populate the type map.
|
|
19
|
+
*/
|
|
20
|
+
async function fetchAllChats(
|
|
21
|
+
client: PondClient,
|
|
22
|
+
orgId: string,
|
|
23
|
+
chatTypeMap: Map<string, Chat["type"]>,
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
let cursor: string | undefined;
|
|
26
|
+
let total = 0;
|
|
27
|
+
do {
|
|
28
|
+
const res = await client.getChats(orgId, { limit: 100, cursor });
|
|
29
|
+
for (const c of res.data) {
|
|
30
|
+
chatTypeMap.set(c.id, c.type);
|
|
31
|
+
}
|
|
32
|
+
total += res.data.length;
|
|
33
|
+
cursor = res.has_more ? res.next_cursor : undefined;
|
|
34
|
+
} while (cursor);
|
|
35
|
+
return total;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
39
|
+
startAccount: async (ctx) => {
|
|
40
|
+
const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
|
41
|
+
if (!account.enabled) return;
|
|
42
|
+
if (!account.configured) throw new Error("Pond account is not configured: pond_url/api_key/org_id required");
|
|
43
|
+
|
|
44
|
+
const { config } = account;
|
|
45
|
+
const core = getPondRuntime();
|
|
46
|
+
const log = ctx.log;
|
|
47
|
+
|
|
48
|
+
const client = new PondClient({
|
|
49
|
+
baseUrl: config.pond_url,
|
|
50
|
+
token: config.api_key,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Resolve agent's own user ID
|
|
54
|
+
const me = await client.getMe();
|
|
55
|
+
const agentUserId = me.id;
|
|
56
|
+
log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
|
|
57
|
+
|
|
58
|
+
// Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
|
|
59
|
+
const wsUrl = resolveWsUrl(account);
|
|
60
|
+
const ws = new PondWs({
|
|
61
|
+
getTicket: () => client.getWsTicket(),
|
|
62
|
+
wsUrl,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Chat type lookup — populated on hello, used by message handler
|
|
66
|
+
const chatTypeMap = new Map<string, Chat["type"]>();
|
|
67
|
+
|
|
68
|
+
// Session ID from hello — used for heartbeat
|
|
69
|
+
let sessionId = "";
|
|
70
|
+
|
|
71
|
+
// Log connection state changes (covers reconnection)
|
|
72
|
+
ws.onStateChange((state) => {
|
|
73
|
+
log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// On hello, cache chat types (with pagination) and start heartbeat
|
|
77
|
+
ws.on("hello", async (data: HelloData) => {
|
|
78
|
+
sessionId = data.session_id ?? "";
|
|
79
|
+
try {
|
|
80
|
+
const count = await fetchAllChats(client, config.org_id, chatTypeMap);
|
|
81
|
+
log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
|
|
82
|
+
|
|
83
|
+
// Cache client state only after successful connection
|
|
84
|
+
setPondAccountState(ctx.accountId, {
|
|
85
|
+
client,
|
|
86
|
+
orgId: config.org_id,
|
|
87
|
+
agentUserId,
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Track chat type changes (new chats, renames, etc.)
|
|
95
|
+
ws.on("chat.update", (data: ChatUpdateData) => {
|
|
96
|
+
if (data.changes && typeof data.changes.type === "string") {
|
|
97
|
+
chatTypeMap.set(data.chat_id, data.changes.type as Chat["type"]);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Handle inbound messages
|
|
102
|
+
ws.on("message.new", async (data: MessageNewData) => {
|
|
103
|
+
if (data.sender_id === agentUserId) return;
|
|
104
|
+
if (data.message_type !== "text") return;
|
|
105
|
+
|
|
106
|
+
const content = data.content as TextContent;
|
|
107
|
+
const text = content.text?.trim();
|
|
108
|
+
if (!text) return;
|
|
109
|
+
|
|
110
|
+
const chatId = data.chat_id;
|
|
111
|
+
|
|
112
|
+
// Resolve chat type — query API for unknown chats
|
|
113
|
+
let chatType = chatTypeMap.get(chatId);
|
|
114
|
+
if (!chatType) {
|
|
115
|
+
try {
|
|
116
|
+
const chat = await client.getChat(config.org_id, chatId);
|
|
117
|
+
chatType = chat.type;
|
|
118
|
+
chatTypeMap.set(chatId, chatType);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log?.warn(`pond[${ctx.accountId}]: failed to resolve chat type for ${chatId}, skipping message: ${String(err)}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// In group chats, only respond when @mentioned or @all
|
|
126
|
+
if (chatType === "group") {
|
|
127
|
+
const mentions = content.mentions ?? [];
|
|
128
|
+
const isMentioned = mentions.some(
|
|
129
|
+
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
|
|
130
|
+
);
|
|
131
|
+
if (!isMentioned) return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
|
|
135
|
+
setSessionChatId(sessionKey, chatId);
|
|
136
|
+
|
|
137
|
+
// Build inbound context for OpenClaw agent
|
|
138
|
+
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
139
|
+
Body: text,
|
|
140
|
+
BodyForAgent: text,
|
|
141
|
+
RawBody: text,
|
|
142
|
+
CommandBody: text,
|
|
143
|
+
From: `pond:${data.sender_id}`,
|
|
144
|
+
To: `pond:${chatId}`,
|
|
145
|
+
SessionKey: sessionKey,
|
|
146
|
+
AccountId: ctx.accountId,
|
|
147
|
+
ChatType: chatType,
|
|
148
|
+
SenderName: data.sender?.display_name ?? data.sender_id,
|
|
149
|
+
SenderId: data.sender_id,
|
|
150
|
+
Provider: "pond" as const,
|
|
151
|
+
Surface: "pond" as const,
|
|
152
|
+
MessageSid: data.id,
|
|
153
|
+
Timestamp: Date.now(),
|
|
154
|
+
CommandAuthorized: true,
|
|
155
|
+
OriginatingChannel: "pond" as const,
|
|
156
|
+
OriginatingTo: `pond:${chatId}`,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
let thinkingSent = false;
|
|
160
|
+
try {
|
|
161
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
162
|
+
ctx: inboundCtx,
|
|
163
|
+
cfg: ctx.cfg,
|
|
164
|
+
dispatcherOptions: {
|
|
165
|
+
deliver: async (payload) => {
|
|
166
|
+
const replyText = payload.text?.trim();
|
|
167
|
+
if (!replyText) return;
|
|
168
|
+
await client.sendMessage(config.org_id, chatId, {
|
|
169
|
+
message_type: "text",
|
|
170
|
+
content: { text: replyText },
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
onReplyStart: () => {
|
|
174
|
+
if (thinkingSent) return;
|
|
175
|
+
thinkingSent = true;
|
|
176
|
+
// Send "thinking" state delta (fire-and-forget)
|
|
177
|
+
client.sendMessage(config.org_id, chatId, {
|
|
178
|
+
message_type: "state_delta",
|
|
179
|
+
content: { state: "thinking", text: "", progress: 0, collapsible: true },
|
|
180
|
+
} as SendMessageRequest).catch((err) => {
|
|
181
|
+
log?.warn(`pond[${ctx.accountId}]: failed to send thinking state: ${String(err)}`);
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
log?.error(`pond[${ctx.accountId}]: dispatch failed for ${data.id}: ${String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
|
|
192
|
+
await ws.connect();
|
|
193
|
+
|
|
194
|
+
// Start periodic heartbeat with system telemetry
|
|
195
|
+
const heartbeatInterval = setInterval(() => {
|
|
196
|
+
if (ws.state !== "connected") return;
|
|
197
|
+
const totalMem = os.totalmem();
|
|
198
|
+
const freeMem = os.freemem();
|
|
199
|
+
ws.sendAgentHeartbeat(sessionId, {
|
|
200
|
+
hostname: os.hostname(),
|
|
201
|
+
cores: os.cpus().length,
|
|
202
|
+
mem_total: totalMem,
|
|
203
|
+
mem_free: freeMem,
|
|
204
|
+
uptime: os.uptime(),
|
|
205
|
+
});
|
|
206
|
+
}, 30_000); // every 30s
|
|
207
|
+
|
|
208
|
+
// Clean up on abort
|
|
209
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
210
|
+
clearInterval(heartbeatInterval);
|
|
211
|
+
ws.disconnect();
|
|
212
|
+
removePondAccountState(ctx.accountId);
|
|
213
|
+
log?.info(`pond[${ctx.accountId}]: disconnected`);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Keep the gateway alive until aborted
|
|
217
|
+
return new Promise<void>((resolve) => {
|
|
218
|
+
ctx.abortSignal.addEventListener("abort", () => resolve());
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { SendMessageRequest } from "@pnds/sdk";
|
|
3
|
+
import { extractAccountIdFromSessionKey } from "./session.js";
|
|
4
|
+
import { getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the PondClient + orgId for a hook context.
|
|
8
|
+
* Matches by accountId extracted from the session key.
|
|
9
|
+
*/
|
|
10
|
+
function resolveClientForHook(sessionKey: string | undefined) {
|
|
11
|
+
if (!sessionKey) return undefined;
|
|
12
|
+
// Use stored mapping to recover original (case-sensitive) chat ID,
|
|
13
|
+
// because openclaw normalizes session keys to lowercase.
|
|
14
|
+
const chatId = getSessionChatId(sessionKey);
|
|
15
|
+
if (!chatId) return undefined;
|
|
16
|
+
const accountId = extractAccountIdFromSessionKey(sessionKey);
|
|
17
|
+
if (!accountId) return undefined;
|
|
18
|
+
|
|
19
|
+
const state = getPondAccountState(accountId);
|
|
20
|
+
if (!state) return undefined;
|
|
21
|
+
return { ...state, chatId };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerPondHooks(api: OpenClawPluginApi) {
|
|
25
|
+
const log = api.logger;
|
|
26
|
+
|
|
27
|
+
// before_tool_call -> send tool_call message to Pond
|
|
28
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
29
|
+
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
30
|
+
if (!resolved) return;
|
|
31
|
+
try {
|
|
32
|
+
const req: SendMessageRequest = {
|
|
33
|
+
message_type: "tool_call",
|
|
34
|
+
content: {
|
|
35
|
+
tool_name: event.toolName,
|
|
36
|
+
tool_input: event.params ?? {},
|
|
37
|
+
status: "running",
|
|
38
|
+
started_at: new Date().toISOString(),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// after_tool_call -> send tool_result message to Pond
|
|
48
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
49
|
+
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
50
|
+
if (!resolved) return;
|
|
51
|
+
try {
|
|
52
|
+
const req: SendMessageRequest = {
|
|
53
|
+
message_type: "tool_result",
|
|
54
|
+
content: {
|
|
55
|
+
tool_call_id: event.toolCallId ?? "",
|
|
56
|
+
tool_name: event.toolName,
|
|
57
|
+
status: event.error ? "error" : "success",
|
|
58
|
+
output: event.error ?? (typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? "")),
|
|
59
|
+
duration_ms: event.durationMs ?? 0,
|
|
60
|
+
collapsible: true,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log?.warn(`pond hook after_tool_call failed: ${String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// agent_end -> send state_delta completed
|
|
70
|
+
api.on("agent_end", async (_event, ctx) => {
|
|
71
|
+
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const req: SendMessageRequest = {
|
|
78
|
+
message_type: "state_delta",
|
|
79
|
+
content: {
|
|
80
|
+
state: "completed",
|
|
81
|
+
text: "",
|
|
82
|
+
progress: 100,
|
|
83
|
+
collapsible: true,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log?.warn(`pond hook agent_end failed: ${String(err)}`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { pondPlugin } from "./channel.js";
|
|
4
|
+
import { setPondRuntime } from "./runtime.js";
|
|
5
|
+
import { registerPondHooks } from "./hooks.js";
|
|
6
|
+
|
|
7
|
+
const plugin = {
|
|
8
|
+
id: "pond",
|
|
9
|
+
name: "Pond",
|
|
10
|
+
description: "Pond IM channel plugin — Agent-Native messaging with tool call visualization.",
|
|
11
|
+
configSchema: emptyPluginConfigSchema(),
|
|
12
|
+
register(api: OpenClawPluginApi) {
|
|
13
|
+
setPondRuntime(api.runtime);
|
|
14
|
+
api.registerChannel({ plugin: pondPlugin });
|
|
15
|
+
registerPondHooks(api);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default plugin;
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolvePondAccount } from "./accounts.js";
|
|
3
|
+
import { getPondAccountState } from "./runtime.js";
|
|
4
|
+
import { PondClient } from "@pnds/sdk";
|
|
5
|
+
import type { SendMessageRequest } from "@pnds/sdk";
|
|
6
|
+
|
|
7
|
+
export const pondOutbound: ChannelOutboundAdapter = {
|
|
8
|
+
deliveryMode: "direct",
|
|
9
|
+
textChunkLimit: 10000,
|
|
10
|
+
chunker: null,
|
|
11
|
+
|
|
12
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
13
|
+
const account = resolvePondAccount({ cfg, accountId: accountId ?? undefined });
|
|
14
|
+
if (!account.enabled) throw new Error("Pond account is disabled");
|
|
15
|
+
if (!account.configured) throw new Error("Pond account is not configured: pond_url/api_key/org_id required");
|
|
16
|
+
|
|
17
|
+
const state = getPondAccountState(account.accountId);
|
|
18
|
+
const client = state?.client ?? new PondClient({
|
|
19
|
+
baseUrl: account.config.pond_url,
|
|
20
|
+
token: account.config.api_key,
|
|
21
|
+
});
|
|
22
|
+
const orgId = state?.orgId ?? account.config.org_id;
|
|
23
|
+
const chatId = to;
|
|
24
|
+
|
|
25
|
+
const req: SendMessageRequest = {
|
|
26
|
+
message_type: "text",
|
|
27
|
+
content: { text },
|
|
28
|
+
};
|
|
29
|
+
const msg = await client.sendMessage(orgId, chatId, req);
|
|
30
|
+
return { channel: "pond", messageId: msg.id, channelId: chatId };
|
|
31
|
+
},
|
|
32
|
+
};
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { PondClient } from "@pnds/sdk";
|
|
3
|
+
|
|
4
|
+
let runtime: PluginRuntime | null = null;
|
|
5
|
+
|
|
6
|
+
export function setPondRuntime(next: PluginRuntime) {
|
|
7
|
+
runtime = next;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getPondRuntime(): PluginRuntime {
|
|
11
|
+
if (!runtime) {
|
|
12
|
+
throw new Error("Pond runtime not initialized");
|
|
13
|
+
}
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Cached client state per account, populated by gateway.ts startAccount
|
|
18
|
+
export type PondAccountState = {
|
|
19
|
+
client: PondClient;
|
|
20
|
+
orgId: string;
|
|
21
|
+
agentUserId: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const accountStates = new Map<string, PondAccountState>();
|
|
25
|
+
|
|
26
|
+
export function setPondAccountState(accountId: string, state: PondAccountState) {
|
|
27
|
+
accountStates.set(accountId, state);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function removePondAccountState(accountId: string) {
|
|
31
|
+
accountStates.delete(accountId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getPondAccountState(accountId: string): PondAccountState | undefined {
|
|
35
|
+
return accountStates.get(accountId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getAllPondAccountStates(): ReadonlyMap<string, PondAccountState> {
|
|
39
|
+
return new Map(accountStates);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Session key → original chat ID mapping.
|
|
43
|
+
// OpenClaw normalizes session keys to lowercase, but Pond IDs are case-sensitive.
|
|
44
|
+
const sessionChatIdMap = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
export function setSessionChatId(sessionKey: string, chatId: string) {
|
|
47
|
+
sessionChatIdMap.set(sessionKey.toLowerCase(), chatId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getSessionChatId(sessionKey: string): string | undefined {
|
|
51
|
+
return sessionChatIdMap.get(sessionKey.toLowerCase());
|
|
52
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const POND_SESSION_PREFIX = "agent:main:pond:";
|
|
2
|
+
|
|
3
|
+
/** Build OpenClaw session key from Pond account, chat type, and chat ID. */
|
|
4
|
+
export function buildSessionKey(accountId: string, chatType: string, chatId: string): string {
|
|
5
|
+
return `${POND_SESSION_PREFIX}${accountId}:${chatType}:${chatId}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract account ID from an OpenClaw session key.
|
|
10
|
+
* Format: "agent:main:pond:{accountId}:{type}:{chatId}"
|
|
11
|
+
*/
|
|
12
|
+
export function extractAccountIdFromSessionKey(sessionKey: string | undefined): string | undefined {
|
|
13
|
+
if (!sessionKey?.startsWith(POND_SESSION_PREFIX)) return undefined;
|
|
14
|
+
const rest = sessionKey.slice(POND_SESSION_PREFIX.length);
|
|
15
|
+
const firstColon = rest.indexOf(":");
|
|
16
|
+
if (firstColon < 0) return undefined;
|
|
17
|
+
return rest.slice(0, firstColon) || undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract chat ID from an OpenClaw session key.
|
|
22
|
+
* Format: "agent:main:pond:{accountId}:{type}:{chatId}"
|
|
23
|
+
*/
|
|
24
|
+
export function extractChatIdFromSessionKey(sessionKey: string | undefined): string | undefined {
|
|
25
|
+
if (!sessionKey?.startsWith(POND_SESSION_PREFIX)) return undefined;
|
|
26
|
+
const rest = sessionKey.slice(POND_SESSION_PREFIX.length);
|
|
27
|
+
// Skip accountId and chatType segments
|
|
28
|
+
const parts = rest.split(":");
|
|
29
|
+
if (parts.length < 3) return undefined;
|
|
30
|
+
return parts.slice(2).join(":") || undefined;
|
|
31
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type PondChannelConfig = {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
/** Pond server URL, e.g. "https://pond.example.com" */
|
|
4
|
+
pond_url: string;
|
|
5
|
+
/** Agent API key (agk_xxx) */
|
|
6
|
+
api_key: string;
|
|
7
|
+
/** Organization ID */
|
|
8
|
+
org_id: string;
|
|
9
|
+
/** WS Gateway URL (defaults to pond_url with ws:// scheme + /ws path) */
|
|
10
|
+
ws_url?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ResolvedPondAccount = {
|
|
14
|
+
accountId: string;
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
configured: boolean;
|
|
17
|
+
config: PondChannelConfig;
|
|
18
|
+
};
|