@pnds/pond 1.1.0 → 1.2.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/TOOLS.md +54 -0
- package/package.json +17 -4
- package/src/action-tools.ts +223 -0
- package/src/channel.ts +2 -2
- package/src/fork.ts +213 -0
- package/src/gateway.ts +717 -222
- package/src/hooks.ts +30 -25
- package/src/index.ts +5 -1
- package/src/outbound.ts +4 -1
- package/src/routing.ts +48 -0
- package/src/runtime.ts +84 -1
- package/src/session.ts +5 -0
- package/src/tool-helpers.ts +11 -0
- package/src/tools-md.ts +7 -0
- package/src/wiki-helper.ts +157 -0
- package/src/wiki-tools.ts +370 -0
package/src/gateway.ts
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
/** Extract ChannelGatewayAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
|
|
5
|
+
type ChannelGatewayAdapter<T = unknown> = NonNullable<ChannelPlugin<T>["gateway"]>;
|
|
6
|
+
import { PondClient, PondWs, MENTION_ALL_USER_ID } from "@pnds/sdk";
|
|
7
|
+
import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData, TaskAssignedData, Task } from "@pnds/sdk";
|
|
4
8
|
import type { PondChannelConfig } from "./types.js";
|
|
5
9
|
import * as os from "node:os";
|
|
6
10
|
import * as path from "node:path";
|
|
7
11
|
import { resolvePondAccount } from "./accounts.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
12
|
+
import {
|
|
13
|
+
getPondRuntime,
|
|
14
|
+
setPondAccountState,
|
|
15
|
+
getPondAccountState,
|
|
16
|
+
removePondAccountState,
|
|
17
|
+
setSessionChatId,
|
|
18
|
+
setSessionMessageId,
|
|
19
|
+
setDispatchMessageId,
|
|
20
|
+
setDispatchNoReply,
|
|
21
|
+
} from "./runtime.js";
|
|
22
|
+
import type { PondEvent, DispatchState, ForkResult } from "./runtime.js";
|
|
23
|
+
import { buildOrchestratorSessionKey } from "./session.js";
|
|
10
24
|
import type { ResolvedPondAccount } from "./types.js";
|
|
11
25
|
import { fetchAndApplyPlatformConfig } from "./config-manager.js";
|
|
26
|
+
import { startWikiHelper } from "./wiki-helper.js";
|
|
27
|
+
import { routeTrigger, defaultRoutingStrategy } from "./routing.js";
|
|
28
|
+
import { forkOrchestratorSession, cleanupForkSession, resolveTranscriptFile } from "./fork.js";
|
|
12
29
|
|
|
13
30
|
function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
14
31
|
if (account.config.ws_url) return account.config.ws_url;
|
|
@@ -17,20 +34,22 @@ function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
|
17
34
|
return `${wsBase}/ws`;
|
|
18
35
|
}
|
|
19
36
|
|
|
37
|
+
type ChatInfo = { type: Chat["type"]; name: string | null };
|
|
38
|
+
|
|
20
39
|
/**
|
|
21
|
-
* Fetch all chats with pagination and populate the type
|
|
40
|
+
* Fetch all chats with pagination and populate the info map (type + name).
|
|
22
41
|
*/
|
|
23
42
|
async function fetchAllChats(
|
|
24
43
|
client: PondClient,
|
|
25
44
|
orgId: string,
|
|
26
|
-
|
|
45
|
+
chatInfoMap: Map<string, ChatInfo>,
|
|
27
46
|
): Promise<number> {
|
|
28
47
|
let cursor: string | undefined;
|
|
29
48
|
let total = 0;
|
|
30
49
|
do {
|
|
31
50
|
const res = await client.getChats(orgId, { limit: 100, cursor });
|
|
32
51
|
for (const c of res.data) {
|
|
33
|
-
|
|
52
|
+
chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null });
|
|
34
53
|
}
|
|
35
54
|
total += res.data.length;
|
|
36
55
|
cursor = res.has_more ? res.next_cursor : undefined;
|
|
@@ -38,64 +57,54 @@ async function fetchAllChats(
|
|
|
38
57
|
return total;
|
|
39
58
|
}
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
):
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
: { top_level: true as const };
|
|
60
|
-
const res = await client.getMessages(orgId, chatId, { before: beforeMessageId, limit, ...threadParams });
|
|
61
|
-
if (!res.data.length) return "";
|
|
62
|
-
|
|
63
|
-
const escapeHistoryValue = (value: string): string =>
|
|
64
|
-
value
|
|
65
|
-
.replace(/\r?\n/g, "\\n")
|
|
66
|
-
.replace(/\[Recent chat history\]|\[End of chat history\]/g, (m) => `\\${m}`);
|
|
67
|
-
|
|
68
|
-
const lines: string[] = [];
|
|
69
|
-
for (const msg of res.data) {
|
|
70
|
-
if (msg.message_type !== "text" && msg.message_type !== "file") continue;
|
|
71
|
-
const senderLabel = escapeHistoryValue(msg.sender?.display_name ?? msg.sender_id);
|
|
72
|
-
const role = msg.sender_id === agentUserId ? "You" : senderLabel;
|
|
73
|
-
|
|
74
|
-
if (msg.message_type === "text") {
|
|
75
|
-
const content = msg.content as TextContent;
|
|
76
|
-
const text = escapeHistoryValue(content.text?.trim() ?? "");
|
|
77
|
-
if (text) lines.push(`${role}: ${text}`);
|
|
78
|
-
} else {
|
|
79
|
-
const content = msg.content as MediaContent;
|
|
80
|
-
const caption = escapeHistoryValue(
|
|
81
|
-
content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`,
|
|
82
|
-
);
|
|
83
|
-
lines.push(`${role}: ${caption}`);
|
|
84
|
-
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Event body formatting
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function buildEventBody(event: PondEvent): string {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
if (event.type === "message") {
|
|
67
|
+
lines.push(`[Event: message.new]`);
|
|
68
|
+
const chatLabel = event.targetName
|
|
69
|
+
? `"${event.targetName}" (${event.targetId})`
|
|
70
|
+
: event.targetId;
|
|
71
|
+
lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? "unknown"}]`);
|
|
72
|
+
lines.push(`[From: ${event.senderName} (${event.senderId})]`);
|
|
73
|
+
lines.push(`[Message ID: ${event.messageId}]`);
|
|
74
|
+
if (event.threadRootId) lines.push(`[Thread: ${event.threadRootId}]`);
|
|
75
|
+
if (event.noReply) lines.push(`[Hint: no_reply]`);
|
|
76
|
+
if (event.mediaFields?.MediaUrl) {
|
|
77
|
+
lines.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
|
|
85
78
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
79
|
+
lines.push("", event.body);
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(`[Event: task.assigned]`);
|
|
82
|
+
const taskLabel = event.targetName
|
|
83
|
+
? `${event.targetName} (${event.targetId})`
|
|
84
|
+
: event.targetId;
|
|
85
|
+
lines.push(`[Task: ${taskLabel} | type: ${event.targetType ?? "task"}]`);
|
|
86
|
+
lines.push(`[Assigned by: ${event.senderName} (${event.senderId})]`);
|
|
87
|
+
lines.push("", event.body);
|
|
92
88
|
}
|
|
89
|
+
return lines.join("\n");
|
|
93
90
|
}
|
|
94
91
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
function buildForkResultPrefix(results: ForkResult[]): string {
|
|
93
|
+
if (!results.length) return "";
|
|
94
|
+
const blocks = results.map((r) => {
|
|
95
|
+
const lines = [`[Fork result]`];
|
|
96
|
+
lines.push(`[Handled: ${r.sourceEvent.type} — ${r.sourceEvent.summary}]`);
|
|
97
|
+
if (r.actions.length) lines.push(`[Actions: ${r.actions.join("; ")}]`);
|
|
98
|
+
if (r.agentRunId) lines.push(`[Agent run: ${r.agentRunId}]`);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
});
|
|
101
|
+
return blocks.join("\n\n") + "\n\n---\n\n";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Dispatch to OpenClaw agent (orchestrator mode — all text suppressed)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
99
108
|
async function dispatchToAgent(opts: {
|
|
100
109
|
core: PluginRuntime;
|
|
101
110
|
cfg: Record<string, unknown>;
|
|
@@ -104,13 +113,19 @@ async function dispatchToAgent(opts: {
|
|
|
104
113
|
agentUserId: string;
|
|
105
114
|
accountId: string;
|
|
106
115
|
sessionKey: string;
|
|
107
|
-
chatId: string;
|
|
108
116
|
messageId: string;
|
|
109
117
|
inboundCtx: Record<string, unknown>;
|
|
110
|
-
|
|
118
|
+
triggerType?: string;
|
|
119
|
+
triggerRef?: Record<string, unknown>;
|
|
120
|
+
chatId?: string;
|
|
121
|
+
/** For task dispatches: bind the run to a task target for Redis presence + fallback comments. */
|
|
122
|
+
defaultTargetType?: string;
|
|
123
|
+
defaultTargetId?: string;
|
|
111
124
|
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
112
125
|
}) {
|
|
113
|
-
const { core, cfg, client, config, agentUserId, accountId, sessionKey,
|
|
126
|
+
const { core, cfg, client, config, agentUserId, accountId, sessionKey, messageId, inboundCtx, log } = opts;
|
|
127
|
+
const triggerType = opts.triggerType ?? "mention";
|
|
128
|
+
const triggerRef = opts.triggerRef ?? { message_id: messageId };
|
|
114
129
|
const sessionKeyLower = sessionKey.toLowerCase();
|
|
115
130
|
let thinkingSent = false;
|
|
116
131
|
let runIdPromise: Promise<string | undefined> | undefined;
|
|
@@ -121,47 +136,22 @@ async function dispatchToAgent(opts: {
|
|
|
121
136
|
cfg,
|
|
122
137
|
dispatcherOptions: {
|
|
123
138
|
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
139
|
+
// Orchestrator mode: ALL text output is suppressed — agent uses tools to interact.
|
|
140
|
+
// Record as internal step for observability.
|
|
124
141
|
const replyText = payload.text?.trim();
|
|
125
142
|
if (!replyText) return;
|
|
126
|
-
// Wait for run creation to complete before sending
|
|
127
143
|
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
128
|
-
|
|
129
|
-
if (info.kind === "final") {
|
|
130
|
-
if (noReply) {
|
|
131
|
-
// no_reply: unconditionally suppress chat output; runId only affects step recording
|
|
132
|
-
if (runId) {
|
|
133
|
-
try {
|
|
134
|
-
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
135
|
-
step_type: "text",
|
|
136
|
-
content: { text: replyText, suppressed: true },
|
|
137
|
-
});
|
|
138
|
-
} catch (err) {
|
|
139
|
-
log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
|
|
140
|
-
}
|
|
141
|
-
} else {
|
|
142
|
-
log?.warn(`pond[${accountId}]: suppressing final reply but no runId available`);
|
|
143
|
-
}
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
// Normal: final reply → chat message
|
|
147
|
-
await client.sendMessage(config.org_id, chatId, {
|
|
148
|
-
message_type: "text",
|
|
149
|
-
content: { text: replyText },
|
|
150
|
-
agent_run_id: runId,
|
|
151
|
-
});
|
|
152
|
-
} else if (info.kind === "block" && runId) {
|
|
153
|
-
// Intermediate text → step with chat_projection hint (server decides)
|
|
144
|
+
if (runId) {
|
|
154
145
|
try {
|
|
155
146
|
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
156
147
|
step_type: "text",
|
|
157
|
-
content: { text: replyText },
|
|
158
|
-
chat_projection:
|
|
148
|
+
content: { text: replyText, suppressed: true },
|
|
149
|
+
...(info.kind === "block" ? { chat_projection: false } : {}),
|
|
159
150
|
});
|
|
160
151
|
} catch (err) {
|
|
161
|
-
log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
|
|
152
|
+
log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
|
|
162
153
|
}
|
|
163
154
|
}
|
|
164
|
-
// kind === "tool" → ignore (handled by hooks)
|
|
165
155
|
},
|
|
166
156
|
onReplyStart: () => {
|
|
167
157
|
if (thinkingSent) return;
|
|
@@ -169,9 +159,10 @@ async function dispatchToAgent(opts: {
|
|
|
169
159
|
runIdPromise = (async () => {
|
|
170
160
|
try {
|
|
171
161
|
const run = await client.createAgentRun(config.org_id, agentUserId, {
|
|
172
|
-
trigger_type:
|
|
173
|
-
trigger_ref:
|
|
174
|
-
chat_id: chatId,
|
|
162
|
+
trigger_type: triggerType,
|
|
163
|
+
trigger_ref: triggerRef,
|
|
164
|
+
...(opts.chatId ? { chat_id: opts.chatId } : {}),
|
|
165
|
+
...(opts.defaultTargetType ? { default_target_type: opts.defaultTargetType, default_target_id: opts.defaultTargetId } : {}),
|
|
175
166
|
});
|
|
176
167
|
return run.id;
|
|
177
168
|
} catch (err) {
|
|
@@ -179,7 +170,6 @@ async function dispatchToAgent(opts: {
|
|
|
179
170
|
return undefined;
|
|
180
171
|
}
|
|
181
172
|
})();
|
|
182
|
-
// Store promise immediately so hooks can await it before the run is created
|
|
183
173
|
const state = getPondAccountState(accountId);
|
|
184
174
|
if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
|
|
185
175
|
},
|
|
@@ -204,11 +194,17 @@ async function dispatchToAgent(opts: {
|
|
|
204
194
|
},
|
|
205
195
|
},
|
|
206
196
|
});
|
|
197
|
+
return true;
|
|
207
198
|
} catch (err) {
|
|
208
199
|
log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
|
|
200
|
+
return false;
|
|
209
201
|
}
|
|
210
202
|
}
|
|
211
203
|
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Gateway
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
212
208
|
export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
213
209
|
startAccount: async (ctx) => {
|
|
214
210
|
const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
|
@@ -229,16 +225,21 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
229
225
|
const agentUserId = me.id;
|
|
230
226
|
log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
|
|
231
227
|
|
|
232
|
-
// Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
|
|
233
|
-
process.env.POND_API_URL = config.pond_url;
|
|
234
|
-
process.env.POND_API_KEY = config.api_key;
|
|
235
|
-
process.env.POND_ORG_ID = config.org_id;
|
|
236
|
-
|
|
237
228
|
// Apply platform config (models, defaults)
|
|
238
|
-
// Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
|
|
239
229
|
const stateDir = process.env.OPENCLAW_STATE_DIR
|
|
240
230
|
|| path.join(process.env.HOME || "/data", ".openclaw");
|
|
241
231
|
const openclawConfigPath = path.join(stateDir, "openclaw.json");
|
|
232
|
+
const previousEnv = {
|
|
233
|
+
POND_API_URL: process.env.POND_API_URL,
|
|
234
|
+
POND_API_KEY: process.env.POND_API_KEY,
|
|
235
|
+
POND_ORG_ID: process.env.POND_ORG_ID,
|
|
236
|
+
POND_AGENT_ID: process.env.POND_AGENT_ID,
|
|
237
|
+
};
|
|
238
|
+
process.env.POND_API_URL = config.pond_url;
|
|
239
|
+
process.env.POND_API_KEY = config.api_key;
|
|
240
|
+
process.env.POND_ORG_ID = config.org_id;
|
|
241
|
+
process.env.POND_AGENT_ID = agentUserId;
|
|
242
|
+
|
|
242
243
|
const configManagerOpts = {
|
|
243
244
|
client,
|
|
244
245
|
stateDir,
|
|
@@ -253,6 +254,24 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
253
254
|
log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
let stopWikiHelper: (() => void) | null = null;
|
|
258
|
+
let wikiMountRoot: string | undefined;
|
|
259
|
+
try {
|
|
260
|
+
const wikiHelper = await startWikiHelper({
|
|
261
|
+
accountId: ctx.accountId,
|
|
262
|
+
pondUrl: config.pond_url,
|
|
263
|
+
apiKey: config.api_key,
|
|
264
|
+
orgId: config.org_id,
|
|
265
|
+
agentId: agentUserId,
|
|
266
|
+
stateDir,
|
|
267
|
+
log,
|
|
268
|
+
});
|
|
269
|
+
wikiMountRoot = wikiHelper.mountRoot;
|
|
270
|
+
stopWikiHelper = wikiHelper.stop;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log?.warn(`pond[${ctx.accountId}]: wiki helper startup failed: ${String(err)}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
256
275
|
// Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
|
|
257
276
|
const wsUrl = resolveWsUrl(account);
|
|
258
277
|
const ws = new PondWs({
|
|
@@ -260,40 +279,390 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
260
279
|
wsUrl,
|
|
261
280
|
});
|
|
262
281
|
|
|
263
|
-
//
|
|
264
|
-
const
|
|
282
|
+
// Orchestrator session key — single session for all Pond events
|
|
283
|
+
const orchestratorKey = buildOrchestratorSessionKey(ctx.accountId);
|
|
284
|
+
|
|
285
|
+
// Chat info lookup — populated on hello, used for structured event bodies
|
|
286
|
+
const chatInfoMap = new Map<string, ChatInfo>();
|
|
265
287
|
|
|
266
288
|
// Session ID from hello — used for heartbeat
|
|
267
289
|
let sessionId = "";
|
|
268
|
-
// Heartbeat interval handle — created inside hello handler with server-provided interval
|
|
269
290
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
270
|
-
//
|
|
291
|
+
// Typing indicator management — reference-counted per chat
|
|
271
292
|
const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
|
|
272
|
-
//
|
|
293
|
+
// Dedupe task dispatches
|
|
294
|
+
const dispatchedTasks = new Set<string>();
|
|
295
|
+
// Dedupe message dispatches — shared by live handler and catch-up.
|
|
296
|
+
// Bounded: evict oldest entries when cap is reached.
|
|
297
|
+
const dispatchedMessages = new Set<string>();
|
|
298
|
+
const DISPATCHED_MESSAGES_CAP = 5000;
|
|
299
|
+
// Tracks whether the agent has had at least one successful hello in this process.
|
|
300
|
+
let hadSuccessfulHello = false;
|
|
301
|
+
const COLD_START_WINDOW_MS = 5 * 60_000;
|
|
302
|
+
// Heartbeat watchdog
|
|
273
303
|
let lastHeartbeatAt = Date.now();
|
|
274
304
|
|
|
275
|
-
|
|
305
|
+
/** Atomically claim a message ID for processing. Returns false if already claimed. */
|
|
306
|
+
function tryClaimMessage(id: string): boolean {
|
|
307
|
+
if (dispatchedMessages.has(id)) return false;
|
|
308
|
+
if (dispatchedMessages.size >= DISPATCHED_MESSAGES_CAP) {
|
|
309
|
+
let toEvict = Math.floor(DISPATCHED_MESSAGES_CAP / 4);
|
|
310
|
+
for (const old of dispatchedMessages) {
|
|
311
|
+
dispatchedMessages.delete(old);
|
|
312
|
+
if (--toEvict <= 0) break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
dispatchedMessages.add(id);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Sessions dir for fork transcript resolution.
|
|
320
|
+
// Session key "agent:main:pond:..." → agentId is "main" → store at agents/main/sessions/
|
|
321
|
+
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Dispatch state for fork-on-busy routing
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
const dispatchState: DispatchState = {
|
|
327
|
+
mainDispatching: false,
|
|
328
|
+
activeForks: new Map(),
|
|
329
|
+
pendingForkResults: [],
|
|
330
|
+
mainBuffer: [],
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Per-fork queue state (internal to gateway, not exposed via DispatchState)
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
type ForkQueueItem = {
|
|
337
|
+
event: PondEvent;
|
|
338
|
+
resolve: (dispatched: boolean) => void;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
type ActiveForkState = {
|
|
342
|
+
sessionKey: string;
|
|
343
|
+
sessionFile: string;
|
|
344
|
+
targetId: string;
|
|
345
|
+
queue: ForkQueueItem[];
|
|
346
|
+
processedEvents: PondEvent[];
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const forkStates = new Map<string, ActiveForkState>();
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Typing indicator helpers
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
function startTyping(chatId: string) {
|
|
355
|
+
const existing = activeDispatches.get(chatId);
|
|
356
|
+
if (existing) {
|
|
357
|
+
existing.count++;
|
|
358
|
+
} else {
|
|
359
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
360
|
+
const typingRefresh = setInterval(() => {
|
|
361
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
362
|
+
}, 2000);
|
|
363
|
+
activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function stopTyping(chatId: string) {
|
|
368
|
+
const dispatch = activeDispatches.get(chatId);
|
|
369
|
+
if (!dispatch) return;
|
|
370
|
+
dispatch.count--;
|
|
371
|
+
if (dispatch.count <= 0) {
|
|
372
|
+
clearInterval(dispatch.typingTimer);
|
|
373
|
+
activeDispatches.delete(chatId);
|
|
374
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Fork drain loop — processes queued events on a forked session
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Process all queued events on a fork session, then clean up.
|
|
384
|
+
* Fire-and-forget: callers await per-event promises, not this function.
|
|
385
|
+
*
|
|
386
|
+
* Invariant: between the while-loop empty check and cleanup, there is no
|
|
387
|
+
* await, so JS single-threaded execution guarantees no new events can be
|
|
388
|
+
* pushed to the queue between the check and the cleanup.
|
|
389
|
+
*/
|
|
390
|
+
async function runForkDrainLoop(fork: ActiveForkState) {
|
|
391
|
+
try {
|
|
392
|
+
while (fork.queue.length > 0) {
|
|
393
|
+
const item = fork.queue.shift()!;
|
|
394
|
+
try {
|
|
395
|
+
await runDispatch(item.event, fork.sessionKey, buildEventBody(item.event));
|
|
396
|
+
fork.processedEvents.push(item.event);
|
|
397
|
+
item.resolve(true);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
log?.error(`pond[${ctx.accountId}]: fork dispatch failed for ${item.event.messageId}: ${String(err)}`);
|
|
400
|
+
item.resolve(false);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Resolve remaining items as not-dispatched (fork stopped after error).
|
|
405
|
+
// These events are NOT acked — inbox retains them for catch-up replay.
|
|
406
|
+
for (const remaining of fork.queue.splice(0)) {
|
|
407
|
+
remaining.resolve(false);
|
|
408
|
+
}
|
|
409
|
+
} finally {
|
|
410
|
+
// Collect fork result for injection into main session's next turn
|
|
411
|
+
if (fork.processedEvents.length > 0) {
|
|
412
|
+
const first = fork.processedEvents[0];
|
|
413
|
+
dispatchState.pendingForkResults.push({
|
|
414
|
+
forkSessionKey: fork.sessionKey,
|
|
415
|
+
sourceEvent: {
|
|
416
|
+
type: first.type,
|
|
417
|
+
targetId: fork.targetId,
|
|
418
|
+
summary: fork.processedEvents.length === 1
|
|
419
|
+
? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
|
|
420
|
+
: `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
|
|
421
|
+
},
|
|
422
|
+
actions: [],
|
|
423
|
+
agentRunId: undefined,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
// Remove from tracking maps and clean up session files
|
|
427
|
+
forkStates.delete(fork.targetId);
|
|
428
|
+
dispatchState.activeForks.delete(fork.targetId);
|
|
429
|
+
cleanupForkSession({ sessionFile: fork.sessionFile, sessionKey: fork.sessionKey, sessionsDir });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Build OpenClaw inbound context
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
function buildInboundCtx(event: PondEvent, sessionKey: string, bodyForAgent: string): Record<string, unknown> {
|
|
437
|
+
return core.channel.reply.finalizeInboundContext({
|
|
438
|
+
Body: event.body,
|
|
439
|
+
BodyForAgent: bodyForAgent,
|
|
440
|
+
RawBody: event.body,
|
|
441
|
+
CommandBody: event.body,
|
|
442
|
+
...(event.mediaFields ?? {}),
|
|
443
|
+
From: `pond:${event.senderId}`,
|
|
444
|
+
To: `pond:orchestrator`,
|
|
445
|
+
SessionKey: sessionKey,
|
|
446
|
+
AccountId: ctx.accountId,
|
|
447
|
+
ChatType: event.targetType ?? "unknown",
|
|
448
|
+
SenderName: event.senderName,
|
|
449
|
+
SenderId: event.senderId,
|
|
450
|
+
Provider: "pond" as const,
|
|
451
|
+
Surface: "pond" as const,
|
|
452
|
+
MessageSid: event.messageId,
|
|
453
|
+
Timestamp: Date.now(),
|
|
454
|
+
CommandAuthorized: true,
|
|
455
|
+
OriginatingChannel: "pond" as const,
|
|
456
|
+
OriginatingTo: `pond:orchestrator`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Core dispatch: route event to main or fork session
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
async function runDispatch(event: PondEvent, sessionKey: string, bodyForAgent: string) {
|
|
464
|
+
// Set provenance maps for wiki tools + no_reply suppression flag
|
|
465
|
+
setSessionChatId(sessionKey, event.targetId);
|
|
466
|
+
setSessionMessageId(sessionKey, event.messageId);
|
|
467
|
+
setDispatchMessageId(sessionKey, event.messageId);
|
|
468
|
+
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
469
|
+
|
|
470
|
+
const inboundCtx = buildInboundCtx(event, sessionKey, bodyForAgent);
|
|
471
|
+
const triggerType = event.type === "task" ? "task_assign" : "mention";
|
|
472
|
+
const triggerRef = event.type === "task"
|
|
473
|
+
? { task_id: event.targetId }
|
|
474
|
+
: { message_id: event.messageId };
|
|
475
|
+
const isTask = event.type === "task";
|
|
476
|
+
|
|
477
|
+
const ok = await dispatchToAgent({
|
|
478
|
+
core,
|
|
479
|
+
cfg: ctx.cfg,
|
|
480
|
+
client,
|
|
481
|
+
config,
|
|
482
|
+
agentUserId,
|
|
483
|
+
accountId: ctx.accountId,
|
|
484
|
+
sessionKey,
|
|
485
|
+
messageId: event.messageId,
|
|
486
|
+
inboundCtx,
|
|
487
|
+
triggerType,
|
|
488
|
+
triggerRef,
|
|
489
|
+
chatId: event.targetId.startsWith("cht_") ? event.targetId : undefined,
|
|
490
|
+
defaultTargetType: isTask ? "task" : undefined,
|
|
491
|
+
defaultTargetId: isTask ? event.targetId : undefined,
|
|
492
|
+
log,
|
|
493
|
+
});
|
|
494
|
+
if (!ok) throw new Error(`dispatch failed for ${event.messageId}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Drain buffered events one-by-one into main session.
|
|
499
|
+
* Each event gets its own dispatch (and thus its own AgentRun) to keep provenance correct.
|
|
500
|
+
*
|
|
501
|
+
* IMPORTANT: Caller must hold mainDispatching=true. This function keeps the flag
|
|
502
|
+
* raised throughout the drain to prevent re-entrance from concurrent event handlers.
|
|
503
|
+
* The flag is only lowered when the buffer is fully exhausted.
|
|
504
|
+
*/
|
|
505
|
+
let draining = false;
|
|
506
|
+
async function drainMainBuffer() {
|
|
507
|
+
// Re-entrance guard: if we're already draining (e.g. fork completed during
|
|
508
|
+
// a drain iteration), the outer loop will pick up new items naturally.
|
|
509
|
+
if (draining) return;
|
|
510
|
+
draining = true;
|
|
511
|
+
try {
|
|
512
|
+
while (dispatchState.mainBuffer.length > 0 || dispatchState.pendingForkResults.length > 0) {
|
|
513
|
+
// If we only have fork results but no events, create a synthetic wake-up
|
|
514
|
+
if (dispatchState.mainBuffer.length === 0 && dispatchState.pendingForkResults.length > 0) {
|
|
515
|
+
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
516
|
+
const syntheticEvent: PondEvent = {
|
|
517
|
+
type: "message",
|
|
518
|
+
targetId: "_orchestrator",
|
|
519
|
+
targetType: "system",
|
|
520
|
+
senderId: "system",
|
|
521
|
+
senderName: "system",
|
|
522
|
+
messageId: `synthetic-${Date.now()}`,
|
|
523
|
+
body: "[Orchestrator: fork session(s) completed — review results above]",
|
|
524
|
+
};
|
|
525
|
+
const bodyForAgent = forkPrefix + buildEventBody(syntheticEvent);
|
|
526
|
+
dispatchState.mainCurrentTargetId = undefined;
|
|
527
|
+
await runDispatch(syntheticEvent, orchestratorKey, bodyForAgent);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Take ONE event at a time for correct provenance
|
|
532
|
+
const event = dispatchState.mainBuffer.shift()!;
|
|
533
|
+
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
534
|
+
const bodyForAgent = forkPrefix + buildEventBody(event);
|
|
535
|
+
|
|
536
|
+
dispatchState.mainCurrentTargetId = event.targetId;
|
|
537
|
+
await runDispatch(event, orchestratorKey, bodyForAgent);
|
|
538
|
+
// Ack inbox after successful drain dispatch — these events were buffered
|
|
539
|
+
// without ack, so this is the first time they're confirmed processed.
|
|
540
|
+
const sourceType = event.type === "task" ? "task_activity" : "message";
|
|
541
|
+
client.ackInbox(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
|
|
542
|
+
}
|
|
543
|
+
} finally {
|
|
544
|
+
draining = false;
|
|
545
|
+
dispatchState.mainDispatching = false;
|
|
546
|
+
dispatchState.mainCurrentTargetId = undefined;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Route and dispatch an inbound event.
|
|
552
|
+
* Returns true if the event was dispatched (main or fork), false if only buffered.
|
|
553
|
+
* Callers should only ack inbox when this returns true — buffered events may be
|
|
554
|
+
* lost on crash and need to be replayed from inbox on the next catch-up.
|
|
555
|
+
*/
|
|
556
|
+
async function handleInboundEvent(event: PondEvent): Promise<boolean> {
|
|
557
|
+
const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
|
|
558
|
+
|
|
559
|
+
switch (disposition.action) {
|
|
560
|
+
case "main": {
|
|
561
|
+
// Main session is idle — dispatch directly.
|
|
562
|
+
// Set mainDispatching BEFORE the await and keep it raised through
|
|
563
|
+
// drainMainBuffer() so no concurrent event can enter this case.
|
|
564
|
+
// drainMainBuffer() lowers the flag when the buffer is fully exhausted.
|
|
565
|
+
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
566
|
+
const bodyForAgent = forkPrefix + buildEventBody(event);
|
|
567
|
+
|
|
568
|
+
dispatchState.mainDispatching = true;
|
|
569
|
+
dispatchState.mainCurrentTargetId = event.targetId;
|
|
570
|
+
try {
|
|
571
|
+
await runDispatch(event, orchestratorKey, bodyForAgent);
|
|
572
|
+
} finally {
|
|
573
|
+
// Drain any events that buffered while we were dispatching.
|
|
574
|
+
// mainDispatching stays true — drainMainBuffer owns releasing it.
|
|
575
|
+
await drainMainBuffer();
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
case "buffer-main":
|
|
581
|
+
dispatchState.mainBuffer.push(event);
|
|
582
|
+
return false;
|
|
583
|
+
|
|
584
|
+
case "buffer-fork": {
|
|
585
|
+
// Push event into the existing fork's queue. The fork drain loop will
|
|
586
|
+
// process it and resolve the promise when done. If the fork was cleaned
|
|
587
|
+
// up between the routing decision and here (race), fallback to buffer-main.
|
|
588
|
+
const activeFork = forkStates.get(event.targetId);
|
|
589
|
+
if (!activeFork) {
|
|
590
|
+
dispatchState.mainBuffer.push(event);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
return new Promise<boolean>((resolve) => {
|
|
594
|
+
activeFork.queue.push({ event, resolve });
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
case "new-fork": {
|
|
599
|
+
// Fork the orchestrator's transcript and dispatch in parallel.
|
|
600
|
+
const transcriptFile = resolveTranscriptFile(sessionsDir, orchestratorKey);
|
|
601
|
+
const fork = transcriptFile
|
|
602
|
+
? forkOrchestratorSession({
|
|
603
|
+
orchestratorSessionKey: orchestratorKey,
|
|
604
|
+
accountId: ctx.accountId,
|
|
605
|
+
transcriptFile,
|
|
606
|
+
sessionsDir,
|
|
607
|
+
})
|
|
608
|
+
: null;
|
|
609
|
+
|
|
610
|
+
if (fork) {
|
|
611
|
+
const activeFork: ActiveForkState = {
|
|
612
|
+
sessionKey: fork.sessionKey,
|
|
613
|
+
sessionFile: fork.sessionFile,
|
|
614
|
+
targetId: event.targetId,
|
|
615
|
+
queue: [],
|
|
616
|
+
processedEvents: [],
|
|
617
|
+
};
|
|
618
|
+
forkStates.set(event.targetId, activeFork);
|
|
619
|
+
dispatchState.activeForks.set(event.targetId, fork.sessionKey);
|
|
620
|
+
|
|
621
|
+
// Create promise for the first event, then start the drain loop
|
|
622
|
+
const firstEventPromise = new Promise<boolean>((resolve) => {
|
|
623
|
+
activeFork.queue.push({ event, resolve });
|
|
624
|
+
});
|
|
625
|
+
// Fire-and-forget — drain loop runs concurrently, callers await per-event promises
|
|
626
|
+
runForkDrainLoop(activeFork).catch((err) => {
|
|
627
|
+
log?.error(`pond[${ctx.accountId}]: fork drain loop error: ${String(err)}`);
|
|
628
|
+
});
|
|
629
|
+
return firstEventPromise;
|
|
630
|
+
} else {
|
|
631
|
+
log?.warn(`pond[${ctx.accountId}]: fork failed, buffering event for main session`);
|
|
632
|
+
dispatchState.mainBuffer.push(event);
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// WebSocket event handlers
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
|
|
276
644
|
ws.onStateChange((state) => {
|
|
277
645
|
log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
|
|
278
646
|
});
|
|
279
647
|
|
|
280
|
-
// On hello, cache chat types and start heartbeat with server-provided interval
|
|
281
648
|
ws.on("hello", async (data: HelloData) => {
|
|
282
649
|
sessionId = data.session_id ?? "";
|
|
283
650
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
284
651
|
try {
|
|
285
|
-
const count = await fetchAllChats(client, config.org_id,
|
|
652
|
+
const count = await fetchAllChats(client, config.org_id, chatInfoMap);
|
|
286
653
|
log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
|
|
287
654
|
|
|
288
|
-
// Cache client state only after successful connection
|
|
289
655
|
setPondAccountState(ctx.accountId, {
|
|
290
656
|
client,
|
|
291
657
|
orgId: config.org_id,
|
|
292
658
|
agentUserId,
|
|
293
659
|
activeRuns: new Map(),
|
|
660
|
+
wikiMountRoot,
|
|
661
|
+
ws,
|
|
662
|
+
orchestratorSessionKey: orchestratorKey,
|
|
294
663
|
});
|
|
295
664
|
|
|
296
|
-
// (Re)start heartbeat
|
|
665
|
+
// (Re)start heartbeat
|
|
297
666
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
298
667
|
lastHeartbeatAt = Date.now();
|
|
299
668
|
heartbeatTimer = setInterval(() => {
|
|
@@ -313,16 +682,31 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
313
682
|
uptime: os.uptime(),
|
|
314
683
|
});
|
|
315
684
|
}, intervalSec * 1000);
|
|
685
|
+
|
|
686
|
+
// Unified inbox catch-up (replaces separate mention/DM/task catch-up).
|
|
687
|
+
// On cold start, skip items older than COLD_START_WINDOW_MS to avoid
|
|
688
|
+
// replaying historical backlog from a previous process.
|
|
689
|
+
const isFirstHello = !hadSuccessfulHello;
|
|
690
|
+
hadSuccessfulHello = true;
|
|
691
|
+
catchUpFromInbox(isFirstHello).catch((err) => {
|
|
692
|
+
log?.warn(`pond[${ctx.accountId}]: inbox catch-up failed: ${String(err)}`);
|
|
693
|
+
});
|
|
316
694
|
} catch (err) {
|
|
317
695
|
log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
318
696
|
}
|
|
319
697
|
});
|
|
320
698
|
|
|
321
|
-
// Track chat type changes (new chats, renames, etc.)
|
|
322
699
|
ws.on("chat.update", (data: ChatUpdateData) => {
|
|
323
700
|
const changes = data.changes as Record<string, unknown> | undefined;
|
|
324
|
-
if (changes
|
|
325
|
-
|
|
701
|
+
if (!changes) return;
|
|
702
|
+
const existing = chatInfoMap.get(data.chat_id);
|
|
703
|
+
if (typeof changes.type === "string") {
|
|
704
|
+
chatInfoMap.set(data.chat_id, {
|
|
705
|
+
type: changes.type as Chat["type"],
|
|
706
|
+
name: (typeof changes.name === "string" ? changes.name : existing?.name) ?? null,
|
|
707
|
+
});
|
|
708
|
+
} else if (typeof changes.name === "string" && existing) {
|
|
709
|
+
chatInfoMap.set(data.chat_id, { ...existing, name: changes.name });
|
|
326
710
|
}
|
|
327
711
|
});
|
|
328
712
|
|
|
@@ -330,23 +714,25 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
330
714
|
ws.on("message.new", async (data: MessageNewData) => {
|
|
331
715
|
if (data.sender_id === agentUserId) return;
|
|
332
716
|
if (data.message_type !== "text" && data.message_type !== "file") return;
|
|
717
|
+
// Dedupe: claim this message so catch-up won't re-dispatch it
|
|
718
|
+
if (!tryClaimMessage(data.id)) return;
|
|
333
719
|
|
|
334
720
|
const chatId = data.chat_id;
|
|
335
721
|
|
|
336
|
-
// Resolve chat
|
|
337
|
-
let
|
|
338
|
-
if (!
|
|
722
|
+
// Resolve chat info — query API for unknown chats
|
|
723
|
+
let chatInfo = chatInfoMap.get(chatId);
|
|
724
|
+
if (!chatInfo) {
|
|
339
725
|
try {
|
|
340
726
|
const chat = await client.getChat(config.org_id, chatId);
|
|
341
|
-
|
|
342
|
-
|
|
727
|
+
chatInfo = { type: chat.type, name: chat.name ?? null };
|
|
728
|
+
chatInfoMap.set(chatId, chatInfo);
|
|
343
729
|
} catch (err) {
|
|
344
|
-
log?.warn(`pond[${ctx.accountId}]: failed to resolve chat
|
|
730
|
+
log?.warn(`pond[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
|
|
345
731
|
return;
|
|
346
732
|
}
|
|
347
733
|
}
|
|
348
734
|
|
|
349
|
-
// Build body text and optional media fields
|
|
735
|
+
// Build body text and optional media fields
|
|
350
736
|
let body = "";
|
|
351
737
|
let mediaFields: Record<string, string | undefined> = {};
|
|
352
738
|
|
|
@@ -356,118 +742,64 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
356
742
|
if (!body) return;
|
|
357
743
|
|
|
358
744
|
// In group chats, only respond when @mentioned or @all
|
|
359
|
-
if (
|
|
745
|
+
if (chatInfo.type === "group") {
|
|
360
746
|
const mentions = content.mentions ?? [];
|
|
361
747
|
const isMentioned = mentions.some(
|
|
362
|
-
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
|
|
748
|
+
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
|
|
363
749
|
);
|
|
364
750
|
if (!isMentioned) return;
|
|
365
751
|
}
|
|
366
752
|
} else {
|
|
367
|
-
// file message
|
|
368
753
|
const content = data.content as MediaContent;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (chatType === "group") return;
|
|
372
|
-
|
|
373
|
-
body = content.caption?.trim()
|
|
374
|
-
|| `[file: ${content.file_name || "attachment"}]`;
|
|
375
|
-
|
|
376
|
-
// Resolve presigned download URL for the attachment
|
|
754
|
+
if (chatInfo.type === "group") return;
|
|
755
|
+
body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
|
|
377
756
|
if (content.attachment_id) {
|
|
378
757
|
try {
|
|
379
758
|
const fileRes = await client.getFileUrl(content.attachment_id);
|
|
380
|
-
mediaFields = {
|
|
381
|
-
MediaUrl: fileRes.url,
|
|
382
|
-
MediaType: content.mime_type,
|
|
383
|
-
};
|
|
759
|
+
mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
|
|
384
760
|
} catch (err) {
|
|
385
761
|
log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
|
|
386
|
-
// Degrade gracefully — agent still gets the body text
|
|
387
762
|
}
|
|
388
763
|
}
|
|
389
764
|
}
|
|
390
765
|
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
//
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
client, config.org_id, chatId, data.id, agentUserId, 30, log,
|
|
412
|
-
data.thread_root_id,
|
|
413
|
-
);
|
|
414
|
-
const historyPrefix = await Promise.race([historyFetch, historyTimeout]);
|
|
415
|
-
|
|
416
|
-
// Build inbound context for OpenClaw agent
|
|
417
|
-
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
418
|
-
Body: body,
|
|
419
|
-
BodyForAgent: historyPrefix ? `${historyPrefix}${body}` : body,
|
|
420
|
-
RawBody: body,
|
|
421
|
-
CommandBody: body,
|
|
422
|
-
...mediaFields,
|
|
423
|
-
From: `pond:${data.sender_id}`,
|
|
424
|
-
To: `pond:${chatId}`,
|
|
425
|
-
SessionKey: sessionKey,
|
|
426
|
-
AccountId: ctx.accountId,
|
|
427
|
-
ChatType: chatType,
|
|
428
|
-
SenderName: data.sender?.display_name ?? data.sender_id,
|
|
429
|
-
SenderId: data.sender_id,
|
|
430
|
-
Provider: "pond" as const,
|
|
431
|
-
Surface: "pond" as const,
|
|
432
|
-
MessageSid: data.id,
|
|
433
|
-
Timestamp: Date.now(),
|
|
434
|
-
CommandAuthorized: true,
|
|
435
|
-
OriginatingChannel: "pond" as const,
|
|
436
|
-
OriginatingTo: `pond:${chatId}`,
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
// Extract no_reply hint from message
|
|
440
|
-
const noReply = data.hints?.no_reply ?? false;
|
|
441
|
-
|
|
766
|
+
const event: PondEvent = {
|
|
767
|
+
type: "message",
|
|
768
|
+
targetId: chatId,
|
|
769
|
+
targetName: chatInfo.name ?? undefined,
|
|
770
|
+
targetType: chatInfo.type,
|
|
771
|
+
senderId: data.sender_id,
|
|
772
|
+
senderName: data.sender?.display_name ?? data.sender_id,
|
|
773
|
+
messageId: data.id,
|
|
774
|
+
body,
|
|
775
|
+
threadRootId: data.thread_root_id ?? undefined,
|
|
776
|
+
noReply: data.hints?.no_reply ?? false,
|
|
777
|
+
mediaFields: Object.keys(mediaFields).length > 0 ? mediaFields : undefined,
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// Start typing for UX feedback. For buffer-main events (main is busy with
|
|
781
|
+
// same target), typing persists until the buffer drains.
|
|
782
|
+
// For fork events, typing persists until the fork dispatch completes.
|
|
783
|
+
// Both handleInboundEvent paths await their dispatches, so finally works correctly.
|
|
784
|
+
const willDispatch = !dispatchState.mainDispatching || dispatchState.mainCurrentTargetId !== chatId;
|
|
785
|
+
if (willDispatch) startTyping(chatId);
|
|
442
786
|
try {
|
|
443
|
-
await
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
accountId: ctx.accountId,
|
|
450
|
-
sessionKey,
|
|
451
|
-
chatId,
|
|
452
|
-
messageId: data.id,
|
|
453
|
-
inboundCtx,
|
|
454
|
-
noReply,
|
|
455
|
-
log,
|
|
456
|
-
});
|
|
457
|
-
} finally {
|
|
458
|
-
const dispatch = activeDispatches.get(chatId);
|
|
459
|
-
if (dispatch) {
|
|
460
|
-
dispatch.count--;
|
|
461
|
-
if (dispatch.count <= 0) {
|
|
462
|
-
clearInterval(dispatch.typingTimer);
|
|
463
|
-
activeDispatches.delete(chatId);
|
|
464
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
465
|
-
}
|
|
787
|
+
const dispatched = await handleInboundEvent(event);
|
|
788
|
+
if (dispatched) {
|
|
789
|
+
client.ackInbox(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
|
|
790
|
+
} else {
|
|
791
|
+
// Not dispatched (buffered or fork failed) — release claim so catch-up can retry
|
|
792
|
+
dispatchedMessages.delete(data.id);
|
|
466
793
|
}
|
|
794
|
+
} catch (err) {
|
|
795
|
+
log?.error(`pond[${ctx.accountId}]: event dispatch failed for ${data.id}: ${String(err)}`);
|
|
796
|
+
dispatchedMessages.delete(data.id);
|
|
797
|
+
} finally {
|
|
798
|
+
if (willDispatch) stopTyping(chatId);
|
|
467
799
|
}
|
|
468
800
|
});
|
|
469
801
|
|
|
470
|
-
// Re-apply platform config
|
|
802
|
+
// Re-apply platform config on server push
|
|
471
803
|
ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
|
|
472
804
|
log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
|
|
473
805
|
try {
|
|
@@ -477,23 +809,186 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
477
809
|
}
|
|
478
810
|
});
|
|
479
811
|
|
|
812
|
+
// Task assignment handler
|
|
813
|
+
async function handleTaskAssignment(task: Task): Promise<boolean> {
|
|
814
|
+
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
815
|
+
if (dispatchedTasks.has(dedupeKey)) {
|
|
816
|
+
log?.info(`pond[${ctx.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
dispatchedTasks.add(dedupeKey);
|
|
820
|
+
log?.info(`pond[${ctx.accountId}]: task assigned: ${task.identifier ?? task.id} "${task.title}"`);
|
|
821
|
+
|
|
822
|
+
const parts = [`Title: ${task.title}`];
|
|
823
|
+
parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
|
|
824
|
+
if (task.description) parts.push("", task.description);
|
|
825
|
+
|
|
826
|
+
const event: PondEvent = {
|
|
827
|
+
type: "task",
|
|
828
|
+
targetId: task.id,
|
|
829
|
+
targetName: task.identifier ?? undefined,
|
|
830
|
+
targetType: "task",
|
|
831
|
+
senderId: task.creator_id,
|
|
832
|
+
senderName: "system",
|
|
833
|
+
messageId: task.id,
|
|
834
|
+
body: parts.join("\n"),
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const dispatched = await handleInboundEvent(event);
|
|
838
|
+
if (!dispatched) {
|
|
839
|
+
// Buffered, not yet dispatched — release dedupe so catch-up can retry
|
|
840
|
+
dispatchedTasks.delete(dedupeKey);
|
|
841
|
+
}
|
|
842
|
+
return dispatched;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Server event buffer overflowed — events were lost, trigger full catch-up
|
|
846
|
+
ws.on("recovery.overflow", () => {
|
|
847
|
+
log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
|
|
848
|
+
catchUpFromInbox().catch((err) =>
|
|
849
|
+
log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
ws.on("task.assigned", async (data: TaskAssignedData) => {
|
|
853
|
+
if (data.assignee_id !== agentUserId) return;
|
|
854
|
+
if (data.status !== "todo" && data.status !== "in_progress") return;
|
|
855
|
+
try {
|
|
856
|
+
const dispatched = await handleTaskAssignment(data);
|
|
857
|
+
if (dispatched) {
|
|
858
|
+
client.ackInbox(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
|
|
859
|
+
}
|
|
860
|
+
} catch (err) {
|
|
861
|
+
log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// ── Unified inbox catch-up ──
|
|
866
|
+
// Replays missed mentions, DMs, and task assignments via the inbox API.
|
|
867
|
+
// Adapts each item into a PondEvent and routes through handleInboundEvent.
|
|
868
|
+
async function catchUpFromInbox(coldStart = false) {
|
|
869
|
+
const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
|
|
870
|
+
let cursor: string | undefined;
|
|
871
|
+
let processed = 0;
|
|
872
|
+
let skippedOld = 0;
|
|
873
|
+
|
|
874
|
+
do {
|
|
875
|
+
const page = await client.getInbox(config.org_id, {
|
|
876
|
+
type: "mention,agent_dm,task_assign",
|
|
877
|
+
read: "false",
|
|
878
|
+
limit: 50,
|
|
879
|
+
cursor,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
for (const item of page.data ?? []) {
|
|
883
|
+
if (minAge > 0 && new Date(item.updated_at).getTime() < minAge) {
|
|
884
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
885
|
+
skippedOld++;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
processed++;
|
|
890
|
+
try {
|
|
891
|
+
let dispatched = false;
|
|
892
|
+
if (item.type === "task_assign" && item.source_id) {
|
|
893
|
+
// Task catch-up: fetch full task and dispatch
|
|
894
|
+
const task = await client.getTask(config.org_id, item.source_id);
|
|
895
|
+
if (task) {
|
|
896
|
+
dispatched = await handleTaskAssignment(task);
|
|
897
|
+
} else {
|
|
898
|
+
dispatched = true; // task deleted — mark read to prevent infinite retry
|
|
899
|
+
}
|
|
900
|
+
} else if (item.source_id && item.chat_id) {
|
|
901
|
+
// Message catch-up (mention or agent_dm): build PondEvent from inbox item
|
|
902
|
+
if (!tryClaimMessage(item.source_id)) continue;
|
|
903
|
+
const msg = await client.getMessage(item.source_id).catch(() => null);
|
|
904
|
+
if (!msg || msg.sender_id === agentUserId) {
|
|
905
|
+
dispatchedMessages.delete(item.source_id);
|
|
906
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
let chatInfo = chatInfoMap.get(item.chat_id);
|
|
911
|
+
if (!chatInfo) {
|
|
912
|
+
try {
|
|
913
|
+
const chat = await client.getChat(config.org_id, item.chat_id);
|
|
914
|
+
chatInfo = { type: chat.type, name: chat.name ?? null };
|
|
915
|
+
chatInfoMap.set(item.chat_id, chatInfo);
|
|
916
|
+
} catch {
|
|
917
|
+
dispatchedMessages.delete(item.source_id);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let body = "";
|
|
923
|
+
let catchUpMediaFields: Record<string, string | undefined> = {};
|
|
924
|
+
if (msg.message_type === "text") {
|
|
925
|
+
body = (msg.content as TextContent).text?.trim() ?? "";
|
|
926
|
+
} else if (msg.message_type === "file") {
|
|
927
|
+
const fc = msg.content as MediaContent;
|
|
928
|
+
body = fc.caption?.trim() || `[file: ${fc.file_name || "attachment"}]`;
|
|
929
|
+
if (fc.attachment_id) {
|
|
930
|
+
try {
|
|
931
|
+
const fileRes = await client.getFileUrl(fc.attachment_id);
|
|
932
|
+
catchUpMediaFields = { MediaUrl: fileRes.url, MediaType: fc.mime_type };
|
|
933
|
+
} catch { /* degrade gracefully */ }
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (!body) {
|
|
937
|
+
dispatchedMessages.delete(item.source_id);
|
|
938
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const event: PondEvent = {
|
|
943
|
+
type: "message",
|
|
944
|
+
targetId: item.chat_id,
|
|
945
|
+
targetName: chatInfo.name ?? undefined,
|
|
946
|
+
targetType: chatInfo.type,
|
|
947
|
+
senderId: msg.sender_id,
|
|
948
|
+
senderName: msg.sender?.display_name ?? msg.sender_id,
|
|
949
|
+
messageId: msg.id,
|
|
950
|
+
body,
|
|
951
|
+
threadRootId: msg.thread_root_id ?? undefined,
|
|
952
|
+
noReply: msg.hints?.no_reply ?? false,
|
|
953
|
+
mediaFields: Object.keys(catchUpMediaFields).length > 0 ? catchUpMediaFields : undefined,
|
|
954
|
+
};
|
|
955
|
+
dispatched = await handleInboundEvent(event);
|
|
956
|
+
}
|
|
957
|
+
if (dispatched) {
|
|
958
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
959
|
+
}
|
|
960
|
+
} catch (err) {
|
|
961
|
+
log?.warn(`pond[${ctx.accountId}]: catch-up item ${item.id} (${item.type}) failed: ${String(err)}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
cursor = page.has_more ? page.next_cursor : undefined;
|
|
965
|
+
} while (cursor);
|
|
966
|
+
|
|
967
|
+
if (processed > 0 || skippedOld > 0) {
|
|
968
|
+
log?.info(`pond[${ctx.accountId}]: inbox catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
480
972
|
log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
|
|
481
973
|
await ws.connect();
|
|
482
974
|
|
|
483
975
|
// Clean up on abort
|
|
484
976
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
485
977
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
486
|
-
// Clean up all active typing timers to prevent leaks
|
|
487
978
|
for (const [, dispatch] of activeDispatches) {
|
|
488
979
|
clearInterval(dispatch.typingTimer);
|
|
489
980
|
}
|
|
490
981
|
activeDispatches.clear();
|
|
982
|
+
stopWikiHelper?.();
|
|
491
983
|
ws.disconnect();
|
|
492
984
|
removePondAccountState(ctx.accountId);
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
985
|
+
for (const [key, value] of Object.entries(previousEnv)) {
|
|
986
|
+
if (value === undefined) {
|
|
987
|
+
delete process.env[key];
|
|
988
|
+
} else {
|
|
989
|
+
process.env[key] = value;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
497
992
|
log?.info(`pond[${ctx.accountId}]: disconnected`);
|
|
498
993
|
});
|
|
499
994
|
|