@hywkp/test-openclaw-sider 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/README.zh_CN.md +106 -0
- package/index.ts +80 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +51 -0
- package/setup-entry.ts +6 -0
- package/src/account.ts +350 -0
- package/src/auth.ts +292 -0
- package/src/channel.ts +3864 -0
- package/src/config.ts +29 -0
- package/src/inbound-media.ts +196 -0
- package/src/media-upload.ts +983 -0
- package/src/remote-browser-support.ts +64 -0
- package/src/setup-core.ts +431 -0
- package/src/user-agent.ts +17 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,3864 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChannelPlugin,
|
|
3
|
+
type OpenClawConfig,
|
|
4
|
+
type PluginRuntime,
|
|
5
|
+
type ReplyPayload,
|
|
6
|
+
} from "openclaw/plugin-sdk";
|
|
7
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
8
|
+
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
|
|
9
|
+
import {
|
|
10
|
+
createChannelReplyPipeline,
|
|
11
|
+
type CreateTypingCallbacksParams,
|
|
12
|
+
} from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
13
|
+
import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
|
|
14
|
+
import {
|
|
15
|
+
getAgentScopedMediaLocalRoots,
|
|
16
|
+
resolveChannelMediaMaxBytes,
|
|
17
|
+
} from "openclaw/plugin-sdk/media-runtime";
|
|
18
|
+
import {
|
|
19
|
+
formatTextWithAttachmentLinks,
|
|
20
|
+
resolveOutboundMediaUrls,
|
|
21
|
+
} from "openclaw/plugin-sdk/reply-payload";
|
|
22
|
+
|
|
23
|
+
type ChannelMeta = ChannelPlugin["meta"];
|
|
24
|
+
import { appendTokenQuery } from "./auth.js";
|
|
25
|
+
import {
|
|
26
|
+
describeSiderAccountConfigurationError,
|
|
27
|
+
extractSiderToolSend,
|
|
28
|
+
isSiderAccountBootstrappable,
|
|
29
|
+
listSiderAccountIds,
|
|
30
|
+
looksLikeSiderTargetId,
|
|
31
|
+
normalizeSiderMessagingTarget,
|
|
32
|
+
resolveManagedSiderAccount,
|
|
33
|
+
resolveOutboundSessionId,
|
|
34
|
+
resolveSiderAccount,
|
|
35
|
+
type ResolvedSiderAccount,
|
|
36
|
+
siderSetupWizard,
|
|
37
|
+
} from "./account.js";
|
|
38
|
+
import {
|
|
39
|
+
SIDER_CHANNEL_ALIASES,
|
|
40
|
+
SIDER_CHANNEL_BLURB,
|
|
41
|
+
SIDER_CHANNEL_DOCS_LABEL,
|
|
42
|
+
SIDER_CHANNEL_DOCS_PATH,
|
|
43
|
+
SIDER_CHANNEL_ID,
|
|
44
|
+
SIDER_CHANNEL_LABEL,
|
|
45
|
+
SIDER_CHANNEL_SELECTION_LABEL,
|
|
46
|
+
} from "./config.js";
|
|
47
|
+
import { resolveInboundSiderMedia } from "./inbound-media.js";
|
|
48
|
+
import { buildSiderPartFromInlineAttachment, buildSiderPartsFromReplyPayload } from "./media-upload.js";
|
|
49
|
+
import {
|
|
50
|
+
applySiderSetupAccountConfig,
|
|
51
|
+
createSiderPairingPendingUpdateReporter,
|
|
52
|
+
formatSiderPairingInstructions,
|
|
53
|
+
requestSiderPairing,
|
|
54
|
+
SiderPairingExpiredError,
|
|
55
|
+
siderSetupAdapter,
|
|
56
|
+
waitForSiderPairing,
|
|
57
|
+
} from "./setup-core.js";
|
|
58
|
+
import { SIDER_PLUGIN_VERSION, SIDER_USER_AGENT } from "./user-agent.js";
|
|
59
|
+
const DEFAULT_MEDIA_SAVE_MAX_BYTES = 5 * 1024 * 1024;
|
|
60
|
+
const DEFAULT_MONITOR_KEEPALIVE_MS = 10_000;
|
|
61
|
+
const DEFAULT_MONITOR_PONG_TIMEOUT_MS = 30_000;
|
|
62
|
+
const DEFAULT_STRUCTURED_PAYLOAD_MAX_CHARS = 12_000;
|
|
63
|
+
|
|
64
|
+
type SiderPart = {
|
|
65
|
+
id?: string;
|
|
66
|
+
type: string;
|
|
67
|
+
spec_version?: number;
|
|
68
|
+
payload?: unknown;
|
|
69
|
+
meta?: unknown;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type SiderInboundAck = {
|
|
73
|
+
type: "ack";
|
|
74
|
+
session_id?: string;
|
|
75
|
+
id?: string;
|
|
76
|
+
client_req_id?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type SiderInboundRealtimeMessage = {
|
|
80
|
+
type: "message";
|
|
81
|
+
session_id?: string;
|
|
82
|
+
message_id?: string;
|
|
83
|
+
source_role?: string;
|
|
84
|
+
created_at?: number;
|
|
85
|
+
parts?: SiderPart[];
|
|
86
|
+
meta?: unknown;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type SiderInboundRealtimeEvent = {
|
|
90
|
+
type: "event";
|
|
91
|
+
session_id?: string;
|
|
92
|
+
event_id?: string;
|
|
93
|
+
event_type?: string;
|
|
94
|
+
source_role?: string;
|
|
95
|
+
created_at?: number;
|
|
96
|
+
payload?: unknown;
|
|
97
|
+
meta?: unknown;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type SiderSampleEvent = {
|
|
101
|
+
eventType: string;
|
|
102
|
+
payload: Record<string, unknown>;
|
|
103
|
+
meta?: Record<string, unknown>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type SiderUsageTotals = {
|
|
107
|
+
input?: number;
|
|
108
|
+
output?: number;
|
|
109
|
+
cacheRead?: number;
|
|
110
|
+
cacheWrite?: number;
|
|
111
|
+
total?: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type SiderParsedToolCall = {
|
|
115
|
+
toolName?: string;
|
|
116
|
+
toolCallId?: string;
|
|
117
|
+
toolArgs?: unknown;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type SiderParsedAssistantOutput = {
|
|
121
|
+
stopReason?: string;
|
|
122
|
+
thinkingText?: string;
|
|
123
|
+
toolCalls: SiderParsedToolCall[];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type SiderRunState = {
|
|
127
|
+
runId: string;
|
|
128
|
+
sessionKey?: string;
|
|
129
|
+
sessionId?: string;
|
|
130
|
+
accountId?: string;
|
|
131
|
+
provider?: string;
|
|
132
|
+
model?: string;
|
|
133
|
+
usage: SiderUsageTotals;
|
|
134
|
+
pendingStructuredParts: SiderPart[];
|
|
135
|
+
pendingFinalThinking?: string;
|
|
136
|
+
pendingFinalStopReason?: string;
|
|
137
|
+
updatedAt: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type SiderStreamState = {
|
|
141
|
+
active: boolean;
|
|
142
|
+
streamId?: string;
|
|
143
|
+
seq: number;
|
|
144
|
+
blockDeltaCount: number;
|
|
145
|
+
partialDeltaCount: number;
|
|
146
|
+
streamDeltaMode: "undecided" | "partial" | "block";
|
|
147
|
+
partialSnapshot: string;
|
|
148
|
+
accumulatedBlockText: string;
|
|
149
|
+
persistedFinalText: boolean;
|
|
150
|
+
pendingFinalPayload?: ReplyPayload;
|
|
151
|
+
sessionKey?: string;
|
|
152
|
+
currentRunId?: string;
|
|
153
|
+
streamEventQueue: Promise<void>;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type SiderSessionBinding = {
|
|
157
|
+
account: ResolvedSiderAccount;
|
|
158
|
+
sessionId: string;
|
|
159
|
+
lastSeenAt: number;
|
|
160
|
+
toolSeq: number;
|
|
161
|
+
currentRunId?: string;
|
|
162
|
+
currentToolCallId?: string;
|
|
163
|
+
callIdByToolCallId: Map<string, string>;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
type SiderToolHookPayload = {
|
|
167
|
+
sessionKey?: string;
|
|
168
|
+
phase: "start" | "end" | "error";
|
|
169
|
+
toolName?: string;
|
|
170
|
+
toolCallId?: string;
|
|
171
|
+
runId?: string;
|
|
172
|
+
params?: Record<string, unknown>;
|
|
173
|
+
result?: unknown;
|
|
174
|
+
error?: string;
|
|
175
|
+
durationMs?: number;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
type SiderInboundReplaced = {
|
|
179
|
+
type: "replaced";
|
|
180
|
+
reason?: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type SiderInboundPing = {
|
|
184
|
+
type: "ping";
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type SiderInboundPong = {
|
|
188
|
+
type: "pong";
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
type SiderInboundFrame =
|
|
192
|
+
| SiderInboundAck
|
|
193
|
+
| SiderInboundRealtimeMessage
|
|
194
|
+
| SiderInboundRealtimeEvent
|
|
195
|
+
| SiderInboundReplaced
|
|
196
|
+
| SiderInboundPing
|
|
197
|
+
| SiderInboundPong
|
|
198
|
+
|
|
199
|
+
type SiderPendingAck = {
|
|
200
|
+
sessionId: string;
|
|
201
|
+
clientReqId?: string;
|
|
202
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
203
|
+
resolve: (id: string) => void;
|
|
204
|
+
reject: (err: Error) => void;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
type SiderRelaySocketReadyWaiter = {
|
|
208
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
209
|
+
resolve: (ws: WebSocket) => void;
|
|
210
|
+
reject: (err: Error) => void;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
type SiderNodeWebSocketCtor = new (
|
|
214
|
+
url: string,
|
|
215
|
+
options?: {
|
|
216
|
+
headers?: Record<string, string>;
|
|
217
|
+
protocols?: string | string[];
|
|
218
|
+
},
|
|
219
|
+
) => WebSocket;
|
|
220
|
+
|
|
221
|
+
const SiderNodeWebSocket = WebSocket as unknown as SiderNodeWebSocketCtor;
|
|
222
|
+
|
|
223
|
+
function createSiderWebSocket(url: string): WebSocket {
|
|
224
|
+
return new SiderNodeWebSocket(url, {
|
|
225
|
+
headers: {
|
|
226
|
+
"User-Agent": SIDER_USER_AGENT,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type SiderRelaySocketState = {
|
|
232
|
+
ws: WebSocket | null;
|
|
233
|
+
accountSignature?: string;
|
|
234
|
+
relayId?: string;
|
|
235
|
+
filteredSubscription: boolean;
|
|
236
|
+
subscribedSessionIds: Set<string>;
|
|
237
|
+
pendingAcksBySession: Map<string, SiderPendingAck[]>;
|
|
238
|
+
pendingAcksByClientReqId: Map<string, SiderPendingAck>;
|
|
239
|
+
readyWaiters: Set<SiderRelaySocketReadyWaiter>;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
type SiderMonitorLogger = {
|
|
243
|
+
info: (msg: string) => void;
|
|
244
|
+
warn: (msg: string) => void;
|
|
245
|
+
error: (msg: string) => void;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
type SiderRelayMonitorRegistration = {
|
|
249
|
+
cfg: OpenClawConfig;
|
|
250
|
+
account: ResolvedSiderAccount;
|
|
251
|
+
monitorSignature: string;
|
|
252
|
+
subscribers: Set<string>;
|
|
253
|
+
controller: AbortController;
|
|
254
|
+
runner: Promise<void>;
|
|
255
|
+
restartRequested: boolean;
|
|
256
|
+
log?: SiderMonitorLogger;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
type ParsedInboundMedia = {
|
|
260
|
+
url?: string;
|
|
261
|
+
mimeType?: string;
|
|
262
|
+
fileName?: string;
|
|
263
|
+
objectKey?: string;
|
|
264
|
+
fileId?: string;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const meta: ChannelMeta = {
|
|
268
|
+
id: SIDER_CHANNEL_ID,
|
|
269
|
+
label: SIDER_CHANNEL_LABEL,
|
|
270
|
+
selectionLabel: SIDER_CHANNEL_SELECTION_LABEL,
|
|
271
|
+
docsPath: SIDER_CHANNEL_DOCS_PATH,
|
|
272
|
+
docsLabel: SIDER_CHANNEL_DOCS_LABEL,
|
|
273
|
+
blurb: SIDER_CHANNEL_BLURB,
|
|
274
|
+
aliases: [...SIDER_CHANNEL_ALIASES],
|
|
275
|
+
order: 97,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
let runtimeRef: PluginRuntime | null = null;
|
|
279
|
+
const SIDER_SESSION_BINDING_TTL_MS = 6 * 60 * 60 * 1000;
|
|
280
|
+
const SIDER_SESSION_BINDING_MAX = 512;
|
|
281
|
+
const siderSessionBindings = new Map<string, SiderSessionBinding>();
|
|
282
|
+
const SIDER_RUN_STATE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
283
|
+
const SIDER_RUN_STATE_MAX = 1024;
|
|
284
|
+
const siderRunStates = new Map<string, SiderRunState>();
|
|
285
|
+
const siderRelaySockets = new Map<string, SiderRelaySocketState>();
|
|
286
|
+
const siderRelayMonitors = new Map<string, SiderRelayMonitorRegistration>();
|
|
287
|
+
|
|
288
|
+
export function setSiderRuntime(runtime: PluginRuntime): void {
|
|
289
|
+
runtimeRef = runtime;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getSiderRuntime(): PluginRuntime {
|
|
293
|
+
if (!runtimeRef) {
|
|
294
|
+
throw new Error("sider runtime not initialized");
|
|
295
|
+
}
|
|
296
|
+
return runtimeRef;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatLogMessage(message: string, data?: Record<string, unknown>): string {
|
|
300
|
+
if (!data || Object.keys(data).length === 0) {
|
|
301
|
+
return message;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return `${message} ${JSON.stringify(data)}`;
|
|
305
|
+
} catch {
|
|
306
|
+
return message;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function logDebug(message: string, data?: Record<string, unknown>): void {
|
|
311
|
+
const core = getSiderRuntime();
|
|
312
|
+
if (!core.logging.shouldLogVerbose()) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
core.logging.getChildLogger({ channel: SIDER_CHANNEL_ID }).debug?.(formatLogMessage(message, data));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function logInfo(message: string, data?: Record<string, unknown>): void {
|
|
319
|
+
getSiderRuntime()
|
|
320
|
+
.logging.getChildLogger({ channel: SIDER_CHANNEL_ID })
|
|
321
|
+
.info(formatLogMessage(message, data));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function logWarn(message: string, data?: Record<string, unknown>): void {
|
|
325
|
+
getSiderRuntime()
|
|
326
|
+
.logging.getChildLogger({ channel: SIDER_CHANNEL_ID })
|
|
327
|
+
.warn(formatLogMessage(message, data));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function createMonitorLogger(log?: SiderMonitorLogger): {
|
|
331
|
+
info: (message: string, data?: Record<string, unknown>) => void;
|
|
332
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
333
|
+
} {
|
|
334
|
+
const emit = (level: "info" | "warn", message: string, data?: Record<string, unknown>) => {
|
|
335
|
+
if (log) {
|
|
336
|
+
log[level](formatLogMessage(message, data));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (level === "info") {
|
|
340
|
+
logInfo(message, data);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
logWarn(message, data);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
info: (message, data) => emit("info", message, data),
|
|
348
|
+
warn: (message, data) => emit("warn", message, data),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sleep(ms: number): Promise<void> {
|
|
353
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function normalizeSessionBindingKey(raw?: string): string | undefined {
|
|
357
|
+
const key = raw?.trim().toLowerCase();
|
|
358
|
+
return key || undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function pruneSiderSessionBindings(now = Date.now()): void {
|
|
362
|
+
for (const [key, binding] of siderSessionBindings) {
|
|
363
|
+
if (now - binding.lastSeenAt > SIDER_SESSION_BINDING_TTL_MS) {
|
|
364
|
+
siderSessionBindings.delete(key);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (siderSessionBindings.size <= SIDER_SESSION_BINDING_MAX) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const sorted = [...siderSessionBindings.entries()].sort(
|
|
371
|
+
(a, b) => a[1].lastSeenAt - b[1].lastSeenAt,
|
|
372
|
+
);
|
|
373
|
+
const overflow = siderSessionBindings.size - SIDER_SESSION_BINDING_MAX;
|
|
374
|
+
for (let index = 0; index < overflow; index += 1) {
|
|
375
|
+
const key = sorted[index]?.[0];
|
|
376
|
+
if (key) {
|
|
377
|
+
siderSessionBindings.delete(key);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function rememberSiderSessionBinding(params: {
|
|
383
|
+
sessionKey?: string;
|
|
384
|
+
account: ResolvedSiderAccount;
|
|
385
|
+
sessionId: string;
|
|
386
|
+
}): void {
|
|
387
|
+
const key = normalizeSessionBindingKey(params.sessionKey);
|
|
388
|
+
if (!key) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
const existing = siderSessionBindings.get(key);
|
|
393
|
+
if (existing) {
|
|
394
|
+
existing.account = params.account;
|
|
395
|
+
existing.sessionId = params.sessionId;
|
|
396
|
+
existing.lastSeenAt = now;
|
|
397
|
+
pruneSiderSessionBindings(now);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
siderSessionBindings.set(key, {
|
|
401
|
+
account: params.account,
|
|
402
|
+
sessionId: params.sessionId,
|
|
403
|
+
lastSeenAt: now,
|
|
404
|
+
toolSeq: 0,
|
|
405
|
+
currentRunId: undefined,
|
|
406
|
+
currentToolCallId: undefined,
|
|
407
|
+
callIdByToolCallId: new Map(),
|
|
408
|
+
});
|
|
409
|
+
pruneSiderSessionBindings(now);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function resolveSiderSessionBinding(sessionKey?: string): SiderSessionBinding | undefined {
|
|
413
|
+
const key = normalizeSessionBindingKey(sessionKey);
|
|
414
|
+
if (!key) {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
const binding = siderSessionBindings.get(key);
|
|
418
|
+
if (!binding) {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
binding.lastSeenAt = Date.now();
|
|
422
|
+
return binding;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function pruneSiderRunStates(now = Date.now()): void {
|
|
426
|
+
for (const [runId, state] of siderRunStates) {
|
|
427
|
+
if (now - state.updatedAt > SIDER_RUN_STATE_TTL_MS) {
|
|
428
|
+
siderRunStates.delete(runId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (siderRunStates.size <= SIDER_RUN_STATE_MAX) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const sorted = [...siderRunStates.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
435
|
+
const overflow = siderRunStates.size - SIDER_RUN_STATE_MAX;
|
|
436
|
+
for (let index = 0; index < overflow; index += 1) {
|
|
437
|
+
const runId = sorted[index]?.[0];
|
|
438
|
+
if (runId) {
|
|
439
|
+
siderRunStates.delete(runId);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getOrCreateSiderRunState(runId: string): SiderRunState {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
const existing = siderRunStates.get(runId);
|
|
447
|
+
if (existing) {
|
|
448
|
+
existing.updatedAt = now;
|
|
449
|
+
pruneSiderRunStates(now);
|
|
450
|
+
return existing;
|
|
451
|
+
}
|
|
452
|
+
const created: SiderRunState = {
|
|
453
|
+
runId,
|
|
454
|
+
usage: {},
|
|
455
|
+
pendingStructuredParts: [],
|
|
456
|
+
updatedAt: now,
|
|
457
|
+
};
|
|
458
|
+
siderRunStates.set(runId, created);
|
|
459
|
+
pruneSiderRunStates(now);
|
|
460
|
+
return created;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function resolveSiderRunState(runId?: string): SiderRunState | undefined {
|
|
464
|
+
if (!runId) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const state = siderRunStates.get(runId);
|
|
468
|
+
if (!state) {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
state.updatedAt = Date.now();
|
|
472
|
+
return state;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function discardSiderRunState(runId?: string): void {
|
|
476
|
+
if (!runId) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
siderRunStates.delete(runId);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function hasFiniteNumber(value: unknown): value is number {
|
|
483
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function hasFinitePositiveNumber(value: unknown): value is number {
|
|
487
|
+
return hasFiniteNumber(value) && value > 0;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function addUsageTotals(target: SiderUsageTotals, next?: SiderUsageTotals): void {
|
|
491
|
+
if (!next) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const fields: Array<keyof SiderUsageTotals> = [
|
|
495
|
+
"input",
|
|
496
|
+
"output",
|
|
497
|
+
"cacheRead",
|
|
498
|
+
"cacheWrite",
|
|
499
|
+
"total",
|
|
500
|
+
];
|
|
501
|
+
for (const field of fields) {
|
|
502
|
+
const value = next[field];
|
|
503
|
+
if (!hasFiniteNumber(value)) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
target[field] = hasFiniteNumber(target[field]) ? (target[field] as number) + value : value;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function hasUsageTotals(usage?: SiderUsageTotals): boolean {
|
|
511
|
+
if (!usage) {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
return (
|
|
515
|
+
hasFiniteNumber(usage.input) ||
|
|
516
|
+
hasFiniteNumber(usage.output) ||
|
|
517
|
+
hasFiniteNumber(usage.cacheRead) ||
|
|
518
|
+
hasFiniteNumber(usage.cacheWrite) ||
|
|
519
|
+
hasFiniteNumber(usage.total)
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function hasNonZeroUsageTotals(usage?: SiderUsageTotals): boolean {
|
|
524
|
+
if (!usage) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
return (
|
|
528
|
+
hasFinitePositiveNumber(usage.input) ||
|
|
529
|
+
hasFinitePositiveNumber(usage.output) ||
|
|
530
|
+
hasFinitePositiveNumber(usage.cacheRead) ||
|
|
531
|
+
hasFinitePositiveNumber(usage.cacheWrite) ||
|
|
532
|
+
hasFinitePositiveNumber(usage.total)
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function toUsageCount(value: unknown): number | undefined {
|
|
537
|
+
if (hasFiniteNumber(value)) {
|
|
538
|
+
return value < 0 ? 0 : value;
|
|
539
|
+
}
|
|
540
|
+
if (typeof value === "string") {
|
|
541
|
+
const trimmed = value.trim();
|
|
542
|
+
if (!trimmed) {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
const parsed = Number(trimmed);
|
|
546
|
+
if (!Number.isFinite(parsed)) {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
return parsed < 0 ? 0 : parsed;
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function firstUsageCount(...values: unknown[]): number | undefined {
|
|
555
|
+
let fallback: number | undefined;
|
|
556
|
+
for (const value of values) {
|
|
557
|
+
const normalized = toUsageCount(value);
|
|
558
|
+
if (!hasFiniteNumber(normalized)) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (normalized > 0) {
|
|
562
|
+
return normalized;
|
|
563
|
+
}
|
|
564
|
+
if (!hasFiniteNumber(fallback)) {
|
|
565
|
+
fallback = normalized;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return fallback;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function toJsonSafeValue(value: unknown): unknown {
|
|
572
|
+
if (value === undefined) {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
return JSON.parse(JSON.stringify(value));
|
|
577
|
+
} catch {
|
|
578
|
+
return String(value);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function clipText(text: string, maxChars = DEFAULT_STRUCTURED_PAYLOAD_MAX_CHARS): string {
|
|
583
|
+
if (text.length <= maxChars) {
|
|
584
|
+
return text;
|
|
585
|
+
}
|
|
586
|
+
return `${text.slice(0, maxChars)}...`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function appendStructuredPayloadField(
|
|
590
|
+
payload: Record<string, unknown>,
|
|
591
|
+
fieldName: string,
|
|
592
|
+
value: unknown,
|
|
593
|
+
maxChars = DEFAULT_STRUCTURED_PAYLOAD_MAX_CHARS,
|
|
594
|
+
): void {
|
|
595
|
+
const safeValue = toJsonSafeValue(value);
|
|
596
|
+
if (safeValue === undefined) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
let serialized = "";
|
|
600
|
+
try {
|
|
601
|
+
serialized = JSON.stringify(safeValue);
|
|
602
|
+
} catch {
|
|
603
|
+
serialized = String(safeValue);
|
|
604
|
+
}
|
|
605
|
+
if (!serialized) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (serialized.length <= maxChars) {
|
|
609
|
+
payload[fieldName] = safeValue;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
payload[`${fieldName}_preview`] = clipText(serialized, maxChars);
|
|
613
|
+
payload[`${fieldName}_truncated`] = true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function extractToolResultText(result: unknown): string {
|
|
617
|
+
const record = toRecord(result);
|
|
618
|
+
if (!record) {
|
|
619
|
+
return "";
|
|
620
|
+
}
|
|
621
|
+
const candidateKeys = ["text", "content", "message", "output", "stdout"] as const;
|
|
622
|
+
for (const key of candidateKeys) {
|
|
623
|
+
const value = record[key];
|
|
624
|
+
if (typeof value === "string" && value.trim()) {
|
|
625
|
+
return clipText(value.trim(), 4000);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
632
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return value as Record<string, unknown>;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function getSocketCloseInfo(event?: Event): { code?: number; reason?: string; wasClean?: boolean } {
|
|
639
|
+
if (!event || typeof event !== "object") {
|
|
640
|
+
return {};
|
|
641
|
+
}
|
|
642
|
+
const candidate = event as Partial<CloseEvent>;
|
|
643
|
+
return {
|
|
644
|
+
code: typeof candidate.code === "number" ? candidate.code : undefined,
|
|
645
|
+
reason: typeof candidate.reason === "string" ? candidate.reason : undefined,
|
|
646
|
+
wasClean: typeof candidate.wasClean === "boolean" ? candidate.wasClean : undefined,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function describeSocketClose(event?: Event): string {
|
|
651
|
+
const { code, reason, wasClean } = getSocketCloseInfo(event);
|
|
652
|
+
const parts: string[] = [];
|
|
653
|
+
if (typeof code === "number") {
|
|
654
|
+
parts.push(`code=${code}`);
|
|
655
|
+
}
|
|
656
|
+
if (typeof reason === "string" && reason) {
|
|
657
|
+
parts.push(`reason=${reason}`);
|
|
658
|
+
}
|
|
659
|
+
if (typeof wasClean === "boolean") {
|
|
660
|
+
parts.push(`clean=${wasClean}`);
|
|
661
|
+
}
|
|
662
|
+
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function decodeBase64AttachmentBuffer(raw: string): Buffer {
|
|
666
|
+
const normalized = raw.trim();
|
|
667
|
+
if (!normalized) {
|
|
668
|
+
throw new Error("sider sendAttachment requires non-empty buffer");
|
|
669
|
+
}
|
|
670
|
+
const buffer = Buffer.from(normalized, "base64");
|
|
671
|
+
if (buffer.length === 0) {
|
|
672
|
+
throw new Error("sider sendAttachment requires valid non-empty base64 buffer");
|
|
673
|
+
}
|
|
674
|
+
return buffer;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function persistInlineAttachmentBuffer(params: {
|
|
678
|
+
cfg: OpenClawConfig;
|
|
679
|
+
accountId?: string | null;
|
|
680
|
+
buffer: Buffer;
|
|
681
|
+
contentType?: string;
|
|
682
|
+
fileName?: string;
|
|
683
|
+
}): Promise<{ path: string; contentType?: string }> {
|
|
684
|
+
const configuredMaxBytes = resolveChannelMediaMaxBytes({
|
|
685
|
+
cfg: params.cfg,
|
|
686
|
+
accountId: params.accountId,
|
|
687
|
+
resolveChannelLimitMb: () => undefined,
|
|
688
|
+
});
|
|
689
|
+
const maxBytes = Math.max(
|
|
690
|
+
params.buffer.length,
|
|
691
|
+
configuredMaxBytes ?? 0,
|
|
692
|
+
DEFAULT_MEDIA_SAVE_MAX_BYTES,
|
|
693
|
+
);
|
|
694
|
+
return await getSiderRuntime().channel.media.saveMediaBuffer(
|
|
695
|
+
params.buffer,
|
|
696
|
+
params.contentType,
|
|
697
|
+
"outbound",
|
|
698
|
+
maxBytes,
|
|
699
|
+
params.fileName,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function resolveRelayWsUrl(gatewayUrl: string): string {
|
|
704
|
+
const url = new URL(gatewayUrl);
|
|
705
|
+
if (url.pathname === "" || url.pathname === "/") {
|
|
706
|
+
url.pathname = "/ws/relay";
|
|
707
|
+
} else if (!url.pathname.endsWith("/ws/relay")) {
|
|
708
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/ws/relay`;
|
|
709
|
+
}
|
|
710
|
+
if (url.protocol === "http:") {
|
|
711
|
+
url.protocol = "ws:";
|
|
712
|
+
} else if (url.protocol === "https:") {
|
|
713
|
+
url.protocol = "wss:";
|
|
714
|
+
} else if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
|
715
|
+
throw new Error(`Unsupported gateway URL protocol: ${url.protocol}`);
|
|
716
|
+
}
|
|
717
|
+
return url.toString();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function wsDataToText(data: unknown): Promise<string> {
|
|
721
|
+
if (typeof data === "string") {
|
|
722
|
+
return data;
|
|
723
|
+
}
|
|
724
|
+
if (data instanceof ArrayBuffer) {
|
|
725
|
+
return new TextDecoder().decode(data);
|
|
726
|
+
}
|
|
727
|
+
if (ArrayBuffer.isView(data)) {
|
|
728
|
+
return new TextDecoder().decode(data);
|
|
729
|
+
}
|
|
730
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
731
|
+
return await data.text();
|
|
732
|
+
}
|
|
733
|
+
return String(data ?? "");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function parseInboundFrame(rawText: string): SiderInboundFrame | null {
|
|
737
|
+
try {
|
|
738
|
+
const parsed = JSON.parse(rawText) as Record<string, unknown>;
|
|
739
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
if (typeof parsed.type !== "string" || !parsed.type.trim()) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
return parsed as unknown as SiderInboundFrame;
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function waitForSocketOpen(params: {
|
|
752
|
+
ws: WebSocket;
|
|
753
|
+
timeoutMs: number;
|
|
754
|
+
accountId: string;
|
|
755
|
+
}): Promise<void> {
|
|
756
|
+
const { ws, timeoutMs, accountId } = params;
|
|
757
|
+
await new Promise<void>((resolve, reject) => {
|
|
758
|
+
const timeout = setTimeout(() => {
|
|
759
|
+
cleanup();
|
|
760
|
+
reject(new Error(`[${accountId}] websocket open timeout (${timeoutMs}ms)`));
|
|
761
|
+
}, timeoutMs);
|
|
762
|
+
|
|
763
|
+
const cleanup = () => {
|
|
764
|
+
clearTimeout(timeout);
|
|
765
|
+
ws.removeEventListener("open", onOpen);
|
|
766
|
+
ws.removeEventListener("close", onClose);
|
|
767
|
+
ws.removeEventListener("error", onError);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const onOpen = () => {
|
|
771
|
+
cleanup();
|
|
772
|
+
resolve();
|
|
773
|
+
};
|
|
774
|
+
const onClose = (event: Event) => {
|
|
775
|
+
cleanup();
|
|
776
|
+
reject(new Error(`[${accountId}] websocket closed before open${describeSocketClose(event)}`));
|
|
777
|
+
};
|
|
778
|
+
const onError = () => {
|
|
779
|
+
cleanup();
|
|
780
|
+
reject(new Error(`[${accountId}] websocket failed before open`));
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
ws.addEventListener("open", onOpen);
|
|
784
|
+
ws.addEventListener("close", onClose);
|
|
785
|
+
ws.addEventListener("error", onError);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function closeSocketQuietly(ws: WebSocket): void {
|
|
790
|
+
try {
|
|
791
|
+
ws.close();
|
|
792
|
+
} catch {
|
|
793
|
+
// no-op
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function normalizeRelaySocketToken(token?: string): string | undefined {
|
|
798
|
+
const trimmed = token?.trim();
|
|
799
|
+
return trimmed ? trimmed : undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function getOrCreateRelaySocketState(accountId: string): SiderRelaySocketState {
|
|
803
|
+
const existing = siderRelaySockets.get(accountId);
|
|
804
|
+
if (existing) {
|
|
805
|
+
return existing;
|
|
806
|
+
}
|
|
807
|
+
const next: SiderRelaySocketState = {
|
|
808
|
+
ws: null,
|
|
809
|
+
accountSignature: undefined,
|
|
810
|
+
relayId: undefined,
|
|
811
|
+
filteredSubscription: false,
|
|
812
|
+
subscribedSessionIds: new Set<string>(),
|
|
813
|
+
pendingAcksBySession: new Map<string, SiderPendingAck[]>(),
|
|
814
|
+
pendingAcksByClientReqId: new Map<string, SiderPendingAck>(),
|
|
815
|
+
readyWaiters: new Set<SiderRelaySocketReadyWaiter>(),
|
|
816
|
+
};
|
|
817
|
+
siderRelaySockets.set(accountId, next);
|
|
818
|
+
return next;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function buildRelaySocketAccountSignature(account: ResolvedSiderAccount): string {
|
|
822
|
+
return `${account.gatewayUrl}|${account.relayId}|${normalizeRelaySocketToken(account.token) ?? ""}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function buildRelayMonitorAccountSignature(account: ResolvedSiderAccount): string {
|
|
826
|
+
const subscribeSessionKey = Array.from(
|
|
827
|
+
new Set((account.subscribeSessionIds ?? []).map((item) => item.trim()).filter(Boolean)),
|
|
828
|
+
)
|
|
829
|
+
.sort()
|
|
830
|
+
.join(",");
|
|
831
|
+
return [
|
|
832
|
+
buildRelaySocketAccountSignature(account),
|
|
833
|
+
subscribeSessionKey,
|
|
834
|
+
String(account.connectTimeoutMs),
|
|
835
|
+
String(account.reconnectDelayMs),
|
|
836
|
+
].join("|");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function waitForAbortSignal(signal: AbortSignal): Promise<void> {
|
|
840
|
+
if (signal.aborted) {
|
|
841
|
+
return Promise.resolve();
|
|
842
|
+
}
|
|
843
|
+
return new Promise((resolve) => {
|
|
844
|
+
const onAbort = () => {
|
|
845
|
+
signal.removeEventListener("abort", onAbort);
|
|
846
|
+
resolve();
|
|
847
|
+
};
|
|
848
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function clearRelaySocketState(params: {
|
|
853
|
+
accountId: string;
|
|
854
|
+
expectedWs?: WebSocket;
|
|
855
|
+
closeSocket?: boolean;
|
|
856
|
+
reason?: string;
|
|
857
|
+
}): void {
|
|
858
|
+
const state = siderRelaySockets.get(params.accountId);
|
|
859
|
+
if (!state) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (params.expectedWs && state.ws && state.ws !== params.expectedWs) {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (params.closeSocket && state.ws && state.ws.readyState !== WebSocket.CLOSED) {
|
|
866
|
+
closeSocketQuietly(state.ws);
|
|
867
|
+
}
|
|
868
|
+
state.ws = null;
|
|
869
|
+
state.relayId = undefined;
|
|
870
|
+
state.accountSignature = undefined;
|
|
871
|
+
state.filteredSubscription = false;
|
|
872
|
+
state.subscribedSessionIds.clear();
|
|
873
|
+
const ackError = new Error(
|
|
874
|
+
params.reason ? `[${params.accountId}] ${params.reason}` : `[${params.accountId}] relay socket closed`,
|
|
875
|
+
);
|
|
876
|
+
const rejected = new Set<SiderPendingAck>();
|
|
877
|
+
for (const pendingAcks of state.pendingAcksBySession.values()) {
|
|
878
|
+
for (const pendingAck of pendingAcks) {
|
|
879
|
+
if (rejected.has(pendingAck)) {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
rejected.add(pendingAck);
|
|
883
|
+
clearTimeout(pendingAck.timeout);
|
|
884
|
+
pendingAck.reject(ackError);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
state.pendingAcksBySession.clear();
|
|
888
|
+
state.pendingAcksByClientReqId.clear();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function setRelaySocketConnected(params: {
|
|
892
|
+
account: ResolvedSiderAccount;
|
|
893
|
+
ws: WebSocket;
|
|
894
|
+
relayId: string;
|
|
895
|
+
subscribeSessionIds?: string[];
|
|
896
|
+
}): void {
|
|
897
|
+
const state = getOrCreateRelaySocketState(params.account.accountId);
|
|
898
|
+
const nextSubscribedSessionIds = new Set(
|
|
899
|
+
(params.subscribeSessionIds ?? []).map((sessionId) => sessionId.trim()).filter(Boolean),
|
|
900
|
+
);
|
|
901
|
+
state.ws = params.ws;
|
|
902
|
+
state.relayId = params.relayId;
|
|
903
|
+
state.accountSignature = buildRelaySocketAccountSignature(params.account);
|
|
904
|
+
state.filteredSubscription = nextSubscribedSessionIds.size > 0;
|
|
905
|
+
state.subscribedSessionIds = nextSubscribedSessionIds;
|
|
906
|
+
for (const waiter of state.readyWaiters) {
|
|
907
|
+
clearTimeout(waiter.timeout);
|
|
908
|
+
waiter.resolve(params.ws);
|
|
909
|
+
}
|
|
910
|
+
state.readyWaiters.clear();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function waitForRelaySocketReady(params: {
|
|
914
|
+
account: ResolvedSiderAccount;
|
|
915
|
+
timeoutMs: number;
|
|
916
|
+
}): Promise<{ ws: WebSocket; state: SiderRelaySocketState }> {
|
|
917
|
+
const state = getOrCreateRelaySocketState(params.account.accountId);
|
|
918
|
+
const expectedSignature = buildRelaySocketAccountSignature(params.account);
|
|
919
|
+
const liveSocket = state.ws;
|
|
920
|
+
if (
|
|
921
|
+
liveSocket &&
|
|
922
|
+
liveSocket.readyState === WebSocket.OPEN &&
|
|
923
|
+
state.accountSignature === expectedSignature
|
|
924
|
+
) {
|
|
925
|
+
return { ws: liveSocket, state };
|
|
926
|
+
}
|
|
927
|
+
if (liveSocket && state.accountSignature !== expectedSignature) {
|
|
928
|
+
closeSocketQuietly(liveSocket);
|
|
929
|
+
clearRelaySocketState({
|
|
930
|
+
accountId: params.account.accountId,
|
|
931
|
+
expectedWs: liveSocket,
|
|
932
|
+
closeSocket: false,
|
|
933
|
+
reason: "relay socket account settings changed",
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
937
|
+
const timeout = setTimeout(() => {
|
|
938
|
+
state.readyWaiters.delete(waiter);
|
|
939
|
+
reject(new Error(`[${params.account.accountId}] timeout waiting managed relay socket`));
|
|
940
|
+
}, params.timeoutMs);
|
|
941
|
+
const waiter: SiderRelaySocketReadyWaiter = {
|
|
942
|
+
timeout,
|
|
943
|
+
resolve,
|
|
944
|
+
reject,
|
|
945
|
+
};
|
|
946
|
+
state.readyWaiters.add(waiter);
|
|
947
|
+
});
|
|
948
|
+
if (state.accountSignature !== expectedSignature) {
|
|
949
|
+
throw new Error(`[${params.account.accountId}] managed relay socket account signature mismatch`);
|
|
950
|
+
}
|
|
951
|
+
return { ws, state };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function ensureRelaySocketSessionSubscription(params: {
|
|
955
|
+
account: ResolvedSiderAccount;
|
|
956
|
+
sessionId: string;
|
|
957
|
+
ws: WebSocket;
|
|
958
|
+
state: SiderRelaySocketState;
|
|
959
|
+
}): void {
|
|
960
|
+
if (!params.state.filteredSubscription || params.state.subscribedSessionIds.has(params.sessionId)) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (params.ws.readyState !== WebSocket.OPEN) {
|
|
964
|
+
throw new Error(`[${params.account.accountId}] managed relay socket is not open`);
|
|
965
|
+
}
|
|
966
|
+
params.ws.send(
|
|
967
|
+
JSON.stringify({
|
|
968
|
+
type: "subscribe",
|
|
969
|
+
session_ids: [params.sessionId],
|
|
970
|
+
}),
|
|
971
|
+
);
|
|
972
|
+
params.state.subscribedSessionIds.add(params.sessionId);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function waitForManagedAck(params: {
|
|
976
|
+
state: SiderRelaySocketState;
|
|
977
|
+
accountId: string;
|
|
978
|
+
sessionId: string;
|
|
979
|
+
timeoutMs: number;
|
|
980
|
+
clientReqId?: string;
|
|
981
|
+
}): Promise<string> {
|
|
982
|
+
return new Promise<string>((resolve, reject) => {
|
|
983
|
+
const pendingAck: SiderPendingAck = {
|
|
984
|
+
sessionId: params.sessionId,
|
|
985
|
+
clientReqId: params.clientReqId,
|
|
986
|
+
timeout: setTimeout(() => {
|
|
987
|
+
const queue = params.state.pendingAcksBySession.get(params.sessionId);
|
|
988
|
+
if (!queue) {
|
|
989
|
+
if (params.clientReqId) {
|
|
990
|
+
params.state.pendingAcksByClientReqId.delete(params.clientReqId);
|
|
991
|
+
}
|
|
992
|
+
reject(new Error(`[${params.accountId}] timeout waiting sider ack`));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const idx = queue.indexOf(pendingAck);
|
|
996
|
+
if (idx >= 0) {
|
|
997
|
+
queue.splice(idx, 1);
|
|
998
|
+
if (queue.length === 0) {
|
|
999
|
+
params.state.pendingAcksBySession.delete(params.sessionId);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (params.clientReqId) {
|
|
1003
|
+
params.state.pendingAcksByClientReqId.delete(params.clientReqId);
|
|
1004
|
+
}
|
|
1005
|
+
reject(new Error(`[${params.accountId}] timeout waiting sider ack`));
|
|
1006
|
+
}, params.timeoutMs),
|
|
1007
|
+
resolve,
|
|
1008
|
+
reject,
|
|
1009
|
+
};
|
|
1010
|
+
const queue = params.state.pendingAcksBySession.get(params.sessionId) ?? [];
|
|
1011
|
+
queue.push(pendingAck);
|
|
1012
|
+
params.state.pendingAcksBySession.set(params.sessionId, queue);
|
|
1013
|
+
if (params.clientReqId) {
|
|
1014
|
+
params.state.pendingAcksByClientReqId.set(params.clientReqId, pendingAck);
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function readAckToken(value: unknown): string | undefined {
|
|
1020
|
+
if (typeof value === "string") {
|
|
1021
|
+
const trimmed = value.trim();
|
|
1022
|
+
return trimmed || undefined;
|
|
1023
|
+
}
|
|
1024
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1025
|
+
return String(value);
|
|
1026
|
+
}
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function resolveAckSessionId(frame: SiderInboundAck): string | undefined {
|
|
1031
|
+
const record = frame as Record<string, unknown>;
|
|
1032
|
+
return (
|
|
1033
|
+
readAckToken(record.session_id) ??
|
|
1034
|
+
readAckToken(record.sessionId) ??
|
|
1035
|
+
readAckToken(record.conversation_id) ??
|
|
1036
|
+
readAckToken(record.conversationId)
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function resolveAckClientReqId(frame: SiderInboundAck): string | undefined {
|
|
1041
|
+
const record = frame as Record<string, unknown>;
|
|
1042
|
+
return (
|
|
1043
|
+
readAckToken(record.client_req_id) ??
|
|
1044
|
+
readAckToken(record.clientReqId) ??
|
|
1045
|
+
readAckToken(record.request_id) ??
|
|
1046
|
+
readAckToken(record.requestId)
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function resolveAckId(frame: SiderInboundAck): string | undefined {
|
|
1051
|
+
const record = frame as Record<string, unknown>;
|
|
1052
|
+
return (
|
|
1053
|
+
readAckToken(record.id) ??
|
|
1054
|
+
readAckToken(record.message_id) ??
|
|
1055
|
+
readAckToken(record.messageId) ??
|
|
1056
|
+
readAckToken(record.event_id) ??
|
|
1057
|
+
readAckToken(record.eventId) ??
|
|
1058
|
+
readAckToken(record.ack_id) ??
|
|
1059
|
+
readAckToken(record.ackId)
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function countPendingAcks(state: SiderRelaySocketState): number {
|
|
1064
|
+
let count = 0;
|
|
1065
|
+
for (const queue of state.pendingAcksBySession.values()) {
|
|
1066
|
+
count += queue.length;
|
|
1067
|
+
}
|
|
1068
|
+
return count;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function detachPendingAck(state: SiderRelaySocketState, pendingAck: SiderPendingAck): void {
|
|
1072
|
+
const queue = state.pendingAcksBySession.get(pendingAck.sessionId);
|
|
1073
|
+
if (queue) {
|
|
1074
|
+
const index = queue.indexOf(pendingAck);
|
|
1075
|
+
if (index >= 0) {
|
|
1076
|
+
queue.splice(index, 1);
|
|
1077
|
+
if (queue.length === 0) {
|
|
1078
|
+
state.pendingAcksBySession.delete(pendingAck.sessionId);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (pendingAck.clientReqId) {
|
|
1083
|
+
const existing = state.pendingAcksByClientReqId.get(pendingAck.clientReqId);
|
|
1084
|
+
if (existing === pendingAck) {
|
|
1085
|
+
state.pendingAcksByClientReqId.delete(pendingAck.clientReqId);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function settlePendingAck(params: {
|
|
1091
|
+
accountId: string;
|
|
1092
|
+
state: SiderRelaySocketState;
|
|
1093
|
+
pendingAck: SiderPendingAck;
|
|
1094
|
+
ackId?: string;
|
|
1095
|
+
ackClientReqId?: string;
|
|
1096
|
+
}): boolean {
|
|
1097
|
+
detachPendingAck(params.state, params.pendingAck);
|
|
1098
|
+
clearTimeout(params.pendingAck.timeout);
|
|
1099
|
+
const resolvedId = params.ackId ?? params.ackClientReqId ?? params.pendingAck.clientReqId;
|
|
1100
|
+
if (!resolvedId) {
|
|
1101
|
+
params.pendingAck.reject(
|
|
1102
|
+
new Error(
|
|
1103
|
+
`[${params.accountId}] ack missing id for session ${params.pendingAck.sessionId}`,
|
|
1104
|
+
),
|
|
1105
|
+
);
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
params.pendingAck.resolve(resolvedId);
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function resolveManagedAck(params: {
|
|
1113
|
+
accountId: string;
|
|
1114
|
+
frame: SiderInboundAck;
|
|
1115
|
+
}): boolean {
|
|
1116
|
+
const state = siderRelaySockets.get(params.accountId);
|
|
1117
|
+
if (!state || countPendingAcks(state) === 0) {
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
const ackSession = resolveAckSessionId(params.frame);
|
|
1121
|
+
const ackId = resolveAckId(params.frame);
|
|
1122
|
+
const ackClientReqId = resolveAckClientReqId(params.frame);
|
|
1123
|
+
|
|
1124
|
+
if (ackClientReqId) {
|
|
1125
|
+
const pendingByReq = state.pendingAcksByClientReqId.get(ackClientReqId);
|
|
1126
|
+
if (pendingByReq) {
|
|
1127
|
+
return settlePendingAck({
|
|
1128
|
+
accountId: params.accountId,
|
|
1129
|
+
state,
|
|
1130
|
+
pendingAck: pendingByReq,
|
|
1131
|
+
ackId,
|
|
1132
|
+
ackClientReqId,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (ackSession) {
|
|
1138
|
+
const queue = state.pendingAcksBySession.get(ackSession);
|
|
1139
|
+
if (queue && queue.length > 0) {
|
|
1140
|
+
const pendingAck = queue[0]!;
|
|
1141
|
+
return settlePendingAck({
|
|
1142
|
+
accountId: params.accountId,
|
|
1143
|
+
state,
|
|
1144
|
+
pendingAck,
|
|
1145
|
+
ackId,
|
|
1146
|
+
ackClientReqId,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (countPendingAcks(state) === 1) {
|
|
1152
|
+
for (const queue of state.pendingAcksBySession.values()) {
|
|
1153
|
+
const pendingAck = queue[0];
|
|
1154
|
+
if (pendingAck) {
|
|
1155
|
+
return settlePendingAck({
|
|
1156
|
+
accountId: params.accountId,
|
|
1157
|
+
state,
|
|
1158
|
+
pendingAck,
|
|
1159
|
+
ackId,
|
|
1160
|
+
ackClientReqId,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function resolveFrameClientReqId(frame: Record<string, unknown>): string | undefined {
|
|
1170
|
+
return readAckToken(frame.client_req_id) ?? readAckToken(frame.clientReqId);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function isAckTimeoutError(error: unknown): boolean {
|
|
1174
|
+
const text = String(error ?? "");
|
|
1175
|
+
return text.includes("timeout waiting sider ack");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function sendRelayFrameWithAck(params: {
|
|
1179
|
+
account: ResolvedSiderAccount;
|
|
1180
|
+
sessionId: string;
|
|
1181
|
+
frame: Record<string, unknown>;
|
|
1182
|
+
timeoutMs: number;
|
|
1183
|
+
}): Promise<string> {
|
|
1184
|
+
const { ws, state } = await waitForRelaySocketReady({
|
|
1185
|
+
account: params.account,
|
|
1186
|
+
timeoutMs: params.account.connectTimeoutMs,
|
|
1187
|
+
});
|
|
1188
|
+
ensureRelaySocketSessionSubscription({
|
|
1189
|
+
account: params.account,
|
|
1190
|
+
sessionId: params.sessionId,
|
|
1191
|
+
ws,
|
|
1192
|
+
state,
|
|
1193
|
+
});
|
|
1194
|
+
const ackPromise = waitForManagedAck({
|
|
1195
|
+
state,
|
|
1196
|
+
accountId: params.account.accountId,
|
|
1197
|
+
sessionId: params.sessionId,
|
|
1198
|
+
timeoutMs: params.timeoutMs,
|
|
1199
|
+
clientReqId: resolveFrameClientReqId(params.frame),
|
|
1200
|
+
});
|
|
1201
|
+
try {
|
|
1202
|
+
ws.send(JSON.stringify(params.frame));
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
clearRelaySocketState({
|
|
1205
|
+
accountId: params.account.accountId,
|
|
1206
|
+
expectedWs: ws,
|
|
1207
|
+
closeSocket: true,
|
|
1208
|
+
reason: `relay send failed: ${String(err)}`,
|
|
1209
|
+
});
|
|
1210
|
+
throw err;
|
|
1211
|
+
}
|
|
1212
|
+
return await ackPromise;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function sendRelayFrameWithoutAck(params: {
|
|
1216
|
+
account: ResolvedSiderAccount;
|
|
1217
|
+
sessionId: string;
|
|
1218
|
+
frame: Record<string, unknown>;
|
|
1219
|
+
}): Promise<void> {
|
|
1220
|
+
const { ws, state } = await waitForRelaySocketReady({
|
|
1221
|
+
account: params.account,
|
|
1222
|
+
timeoutMs: params.account.connectTimeoutMs,
|
|
1223
|
+
});
|
|
1224
|
+
ensureRelaySocketSessionSubscription({
|
|
1225
|
+
account: params.account,
|
|
1226
|
+
sessionId: params.sessionId,
|
|
1227
|
+
ws,
|
|
1228
|
+
state,
|
|
1229
|
+
});
|
|
1230
|
+
try {
|
|
1231
|
+
ws.send(JSON.stringify(params.frame));
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
clearRelaySocketState({
|
|
1234
|
+
accountId: params.account.accountId,
|
|
1235
|
+
expectedWs: ws,
|
|
1236
|
+
closeSocket: true,
|
|
1237
|
+
reason: `relay send failed: ${String(err)}`,
|
|
1238
|
+
});
|
|
1239
|
+
throw err;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function connectRelaySocket(params: {
|
|
1244
|
+
account: ResolvedSiderAccount;
|
|
1245
|
+
relayId: string;
|
|
1246
|
+
subscribeSessionIds?: string[];
|
|
1247
|
+
}): Promise<WebSocket> {
|
|
1248
|
+
const wsUrl = resolveRelayWsUrl(params.account.gatewayUrl);
|
|
1249
|
+
const authorizedWsUrl = appendTokenQuery(wsUrl, params.account.token);
|
|
1250
|
+
const subscribeSessionIds = Array.from(
|
|
1251
|
+
new Set((params.subscribeSessionIds ?? []).map((item) => item.trim()).filter(Boolean)),
|
|
1252
|
+
);
|
|
1253
|
+
logDebug("opening sider relay websocket", {
|
|
1254
|
+
accountId: params.account.accountId,
|
|
1255
|
+
wsUrl,
|
|
1256
|
+
relayId: params.relayId,
|
|
1257
|
+
subscribeSessionCount: subscribeSessionIds.length,
|
|
1258
|
+
hasTokenQuery: Boolean(params.account.token?.trim()),
|
|
1259
|
+
});
|
|
1260
|
+
const ws = createSiderWebSocket(authorizedWsUrl);
|
|
1261
|
+
await waitForSocketOpen({
|
|
1262
|
+
ws,
|
|
1263
|
+
timeoutMs: params.account.connectTimeoutMs,
|
|
1264
|
+
accountId: params.account.accountId,
|
|
1265
|
+
});
|
|
1266
|
+
const registerPayload: Record<string, unknown> = {
|
|
1267
|
+
type: "register",
|
|
1268
|
+
relay_id: params.relayId,
|
|
1269
|
+
};
|
|
1270
|
+
if (params.account.token) {
|
|
1271
|
+
registerPayload.token = params.account.token;
|
|
1272
|
+
}
|
|
1273
|
+
ws.send(JSON.stringify(registerPayload));
|
|
1274
|
+
if (subscribeSessionIds.length > 0) {
|
|
1275
|
+
ws.send(
|
|
1276
|
+
JSON.stringify({
|
|
1277
|
+
type: "subscribe",
|
|
1278
|
+
session_ids: subscribeSessionIds,
|
|
1279
|
+
}),
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
return ws;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function sendMessageToSider(params: {
|
|
1286
|
+
account: ResolvedSiderAccount;
|
|
1287
|
+
sessionId: string;
|
|
1288
|
+
parts: SiderPart[];
|
|
1289
|
+
meta?: Record<string, unknown>;
|
|
1290
|
+
}): Promise<{ messageId: string; conversationId: string }> {
|
|
1291
|
+
if (params.parts.length === 0) {
|
|
1292
|
+
throw new Error("Cannot send sider message with empty parts");
|
|
1293
|
+
}
|
|
1294
|
+
const clientReqId = crypto.randomUUID();
|
|
1295
|
+
let messageId: string = clientReqId;
|
|
1296
|
+
let acked = false;
|
|
1297
|
+
const frame: Record<string, unknown> = {
|
|
1298
|
+
type: "message",
|
|
1299
|
+
session_id: params.sessionId,
|
|
1300
|
+
client_req_id: clientReqId,
|
|
1301
|
+
parts: params.parts,
|
|
1302
|
+
meta: params.meta ?? {},
|
|
1303
|
+
};
|
|
1304
|
+
try {
|
|
1305
|
+
messageId = await sendRelayFrameWithAck({
|
|
1306
|
+
account: params.account,
|
|
1307
|
+
sessionId: params.sessionId,
|
|
1308
|
+
timeoutMs: params.account.sendTimeoutMs,
|
|
1309
|
+
frame,
|
|
1310
|
+
});
|
|
1311
|
+
acked = true;
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
if (!isAckTimeoutError(err)) {
|
|
1314
|
+
throw err;
|
|
1315
|
+
}
|
|
1316
|
+
// Some gateway builds do not emit ack for message/event frames.
|
|
1317
|
+
// The frame has already been sent on the socket; treat timeout as
|
|
1318
|
+
// unconfirmed success to avoid dropping post-stream final messages.
|
|
1319
|
+
logWarn("sider outbound ack timeout; treating message as sent", {
|
|
1320
|
+
accountId: params.account.accountId,
|
|
1321
|
+
sessionId: params.sessionId,
|
|
1322
|
+
clientReqId,
|
|
1323
|
+
timeoutMs: params.account.sendTimeoutMs,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
logDebug("sider outbound message dispatched", {
|
|
1327
|
+
accountId: params.account.accountId,
|
|
1328
|
+
sessionId: params.sessionId,
|
|
1329
|
+
clientReqId,
|
|
1330
|
+
messageId,
|
|
1331
|
+
acked,
|
|
1332
|
+
});
|
|
1333
|
+
return { messageId, conversationId: params.sessionId };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
async function sendEventToSider(params: {
|
|
1337
|
+
account: ResolvedSiderAccount;
|
|
1338
|
+
sessionId: string;
|
|
1339
|
+
eventType: string;
|
|
1340
|
+
payload: Record<string, unknown>;
|
|
1341
|
+
meta?: Record<string, unknown>;
|
|
1342
|
+
}): Promise<{ eventId: string; conversationId: string }> {
|
|
1343
|
+
const clientReqId = crypto.randomUUID();
|
|
1344
|
+
await sendRelayFrameWithoutAck({
|
|
1345
|
+
account: params.account,
|
|
1346
|
+
sessionId: params.sessionId,
|
|
1347
|
+
frame: {
|
|
1348
|
+
type: "event",
|
|
1349
|
+
session_id: params.sessionId,
|
|
1350
|
+
client_req_id: clientReqId,
|
|
1351
|
+
event_type: params.eventType,
|
|
1352
|
+
payload: params.payload,
|
|
1353
|
+
meta: params.meta ?? {},
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
return { eventId: clientReqId, conversationId: params.sessionId };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function sendSiderEventBestEffort(params: {
|
|
1360
|
+
account: ResolvedSiderAccount;
|
|
1361
|
+
sessionId: string;
|
|
1362
|
+
event: SiderSampleEvent;
|
|
1363
|
+
context: string;
|
|
1364
|
+
}): Promise<void> {
|
|
1365
|
+
try {
|
|
1366
|
+
await sendEventToSider({
|
|
1367
|
+
account: params.account,
|
|
1368
|
+
sessionId: params.sessionId,
|
|
1369
|
+
eventType: params.event.eventType,
|
|
1370
|
+
payload: params.event.payload,
|
|
1371
|
+
meta: params.event.meta,
|
|
1372
|
+
});
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
logWarn("sider outbound event failed", {
|
|
1375
|
+
accountId: params.account.accountId,
|
|
1376
|
+
sessionId: params.sessionId,
|
|
1377
|
+
eventType: params.event.eventType,
|
|
1378
|
+
context: params.context,
|
|
1379
|
+
error: String(err),
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
async function sendSiderMessageBestEffort(params: {
|
|
1385
|
+
account: ResolvedSiderAccount;
|
|
1386
|
+
sessionId: string;
|
|
1387
|
+
parts: SiderPart[];
|
|
1388
|
+
meta?: Record<string, unknown>;
|
|
1389
|
+
context: string;
|
|
1390
|
+
}): Promise<void> {
|
|
1391
|
+
try {
|
|
1392
|
+
await sendMessageToSider({
|
|
1393
|
+
account: params.account,
|
|
1394
|
+
sessionId: params.sessionId,
|
|
1395
|
+
parts: params.parts,
|
|
1396
|
+
meta: params.meta,
|
|
1397
|
+
});
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
logWarn("sider outbound message failed", {
|
|
1400
|
+
accountId: params.account.accountId,
|
|
1401
|
+
sessionId: params.sessionId,
|
|
1402
|
+
partTypes: params.parts.map((part) => part.type),
|
|
1403
|
+
context: params.context,
|
|
1404
|
+
error: String(err),
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function parseTextFromPart(part: SiderPart): string | null {
|
|
1410
|
+
if (part.type !== "core.text") {
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
const payload = toRecord(part.payload);
|
|
1414
|
+
if (payload?.text && typeof payload.text === "string") {
|
|
1415
|
+
const text = payload.text.trim();
|
|
1416
|
+
return text || null;
|
|
1417
|
+
}
|
|
1418
|
+
if (typeof part.payload === "string") {
|
|
1419
|
+
const text = part.payload.trim();
|
|
1420
|
+
return text || null;
|
|
1421
|
+
}
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function parseMediaFromPart(part: SiderPart): ParsedInboundMedia | null {
|
|
1426
|
+
if (part.type !== "core.media" && part.type !== "core.file") {
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
const payload = toRecord(part.payload);
|
|
1430
|
+
if (!payload) {
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
const readString = (value: unknown): string | undefined => {
|
|
1434
|
+
if (typeof value !== "string") {
|
|
1435
|
+
return undefined;
|
|
1436
|
+
}
|
|
1437
|
+
const trimmed = value.trim();
|
|
1438
|
+
return trimmed || undefined;
|
|
1439
|
+
};
|
|
1440
|
+
const url =
|
|
1441
|
+
readString(payload.download_url) ??
|
|
1442
|
+
readString(payload.downloadUrl) ??
|
|
1443
|
+
readString(payload.url) ??
|
|
1444
|
+
readString(payload.resource_url) ??
|
|
1445
|
+
readString(payload.resourceUrl) ??
|
|
1446
|
+
readString(payload.media_url) ??
|
|
1447
|
+
readString(payload.mediaUrl);
|
|
1448
|
+
const objectKey = readString(payload.object_key) ?? readString(payload.objectKey);
|
|
1449
|
+
const fileId = readString(payload.file_id) ?? readString(payload.fileId);
|
|
1450
|
+
if (!url && !objectKey && !fileId) {
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
const mimeType =
|
|
1454
|
+
readString(payload.mime) ??
|
|
1455
|
+
readString(payload.mime_type) ??
|
|
1456
|
+
readString(payload.mimeType) ??
|
|
1457
|
+
readString(payload.content_type) ??
|
|
1458
|
+
readString(payload.contentType);
|
|
1459
|
+
const fileName =
|
|
1460
|
+
readString(payload.name) ?? readString(payload.file_name) ?? readString(payload.fileName);
|
|
1461
|
+
return {
|
|
1462
|
+
...(url ? { url } : {}),
|
|
1463
|
+
...(objectKey ? { objectKey } : {}),
|
|
1464
|
+
...(fileId ? { fileId } : {}),
|
|
1465
|
+
...(mimeType ? { mimeType } : {}),
|
|
1466
|
+
...(fileName ? { fileName } : {}),
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function describeInboundMediaReference(item: ParsedInboundMedia): string | undefined {
|
|
1471
|
+
if (item.url?.trim()) {
|
|
1472
|
+
return item.url.trim();
|
|
1473
|
+
}
|
|
1474
|
+
if (item.objectKey?.trim()) {
|
|
1475
|
+
return `sider-object-key:${item.objectKey.trim()}`;
|
|
1476
|
+
}
|
|
1477
|
+
if (item.fileId?.trim()) {
|
|
1478
|
+
return `sider-file-id:${item.fileId.trim()}`;
|
|
1479
|
+
}
|
|
1480
|
+
return undefined;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function buildEventMeta(params: { accountId: string }): Record<string, unknown> {
|
|
1484
|
+
return {
|
|
1485
|
+
channel: SIDER_CHANNEL_ID,
|
|
1486
|
+
account_id: params.accountId,
|
|
1487
|
+
plugin_version: SIDER_PLUGIN_VERSION,
|
|
1488
|
+
schema_version: 1,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function formatUsageForMessageMeta(usage?: SiderUsageTotals): Record<string, unknown> | undefined {
|
|
1493
|
+
if (!hasNonZeroUsageTotals(usage)) {
|
|
1494
|
+
return undefined;
|
|
1495
|
+
}
|
|
1496
|
+
return {
|
|
1497
|
+
...(hasFiniteNumber(usage?.input) ? { input: usage.input } : {}),
|
|
1498
|
+
...(hasFiniteNumber(usage?.output) ? { output: usage.output } : {}),
|
|
1499
|
+
...(hasFiniteNumber(usage?.cacheRead) ? { cache_read: usage.cacheRead } : {}),
|
|
1500
|
+
...(hasFiniteNumber(usage?.cacheWrite) ? { cache_write: usage.cacheWrite } : {}),
|
|
1501
|
+
...(hasFiniteNumber(usage?.total) ? { total: usage.total } : {}),
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function parseUsageTotals(value: unknown): SiderUsageTotals | undefined {
|
|
1506
|
+
const record = toRecord(value);
|
|
1507
|
+
if (!record) {
|
|
1508
|
+
return undefined;
|
|
1509
|
+
}
|
|
1510
|
+
const promptTokensDetails =
|
|
1511
|
+
toRecord(record.prompt_tokens_details) ?? toRecord(record.promptTokensDetails);
|
|
1512
|
+
const input = firstUsageCount(
|
|
1513
|
+
record.input,
|
|
1514
|
+
record.inputTokens,
|
|
1515
|
+
record.input_tokens,
|
|
1516
|
+
record.promptTokens,
|
|
1517
|
+
record.prompt_tokens,
|
|
1518
|
+
);
|
|
1519
|
+
const output = firstUsageCount(
|
|
1520
|
+
record.output,
|
|
1521
|
+
record.outputTokens,
|
|
1522
|
+
record.output_tokens,
|
|
1523
|
+
record.completionTokens,
|
|
1524
|
+
record.completion_tokens,
|
|
1525
|
+
);
|
|
1526
|
+
const cacheRead = firstUsageCount(
|
|
1527
|
+
record.cacheRead,
|
|
1528
|
+
record.cache_read,
|
|
1529
|
+
record.cacheReadTokens,
|
|
1530
|
+
record.cache_read_tokens,
|
|
1531
|
+
record.cache_read_input_tokens,
|
|
1532
|
+
record.cached_input_tokens,
|
|
1533
|
+
record.cached_tokens,
|
|
1534
|
+
promptTokensDetails?.cached_tokens,
|
|
1535
|
+
promptTokensDetails?.cachedTokens,
|
|
1536
|
+
);
|
|
1537
|
+
const cacheWrite = firstUsageCount(
|
|
1538
|
+
record.cacheWrite,
|
|
1539
|
+
record.cache_write,
|
|
1540
|
+
record.cacheWriteTokens,
|
|
1541
|
+
record.cache_write_tokens,
|
|
1542
|
+
record.cacheWriteInputTokens,
|
|
1543
|
+
record.cache_write_input_tokens,
|
|
1544
|
+
record.cache_creation_input_tokens,
|
|
1545
|
+
);
|
|
1546
|
+
const total = firstUsageCount(record.total, record.totalTokens, record.total_tokens);
|
|
1547
|
+
|
|
1548
|
+
const usage: SiderUsageTotals = {
|
|
1549
|
+
...(hasFiniteNumber(input) ? { input } : {}),
|
|
1550
|
+
...(hasFiniteNumber(output) ? { output } : {}),
|
|
1551
|
+
...(hasFiniteNumber(cacheRead) ? { cacheRead } : {}),
|
|
1552
|
+
...(hasFiniteNumber(cacheWrite) ? { cacheWrite } : {}),
|
|
1553
|
+
...(hasFiniteNumber(total) ? { total } : {}),
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
if (!hasFiniteNumber(usage.total) && hasUsageTotals(usage)) {
|
|
1557
|
+
usage.total =
|
|
1558
|
+
(hasFiniteNumber(usage.input) ? usage.input : 0) +
|
|
1559
|
+
(hasFiniteNumber(usage.output) ? usage.output : 0) +
|
|
1560
|
+
(hasFiniteNumber(usage.cacheRead) ? usage.cacheRead : 0) +
|
|
1561
|
+
(hasFiniteNumber(usage.cacheWrite) ? usage.cacheWrite : 0);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
return hasUsageTotals(usage) ? usage : undefined;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function choosePreferredUsageTotals(
|
|
1568
|
+
...candidates: Array<SiderUsageTotals | undefined>
|
|
1569
|
+
): SiderUsageTotals | undefined {
|
|
1570
|
+
for (const candidate of candidates) {
|
|
1571
|
+
if (candidate && hasNonZeroUsageTotals(candidate)) {
|
|
1572
|
+
return candidate;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
for (const candidate of candidates) {
|
|
1576
|
+
if (candidate && hasUsageTotals(candidate)) {
|
|
1577
|
+
return candidate;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return undefined;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function parseUsageFromAssistantLike(value: unknown): SiderUsageTotals | undefined {
|
|
1584
|
+
const record = toRecord(value);
|
|
1585
|
+
if (!record) {
|
|
1586
|
+
return undefined;
|
|
1587
|
+
}
|
|
1588
|
+
const meta = toRecord(record.meta);
|
|
1589
|
+
const agentMeta =
|
|
1590
|
+
toRecord(meta?.agentMeta) ??
|
|
1591
|
+
toRecord(meta?.agent_meta) ??
|
|
1592
|
+
toRecord(record.agentMeta) ??
|
|
1593
|
+
toRecord(record.agent_meta);
|
|
1594
|
+
return choosePreferredUsageTotals(
|
|
1595
|
+
parseUsageTotals(record.usage),
|
|
1596
|
+
parseUsageTotals(meta?.usage),
|
|
1597
|
+
parseUsageTotals(meta?.lastCallUsage),
|
|
1598
|
+
parseUsageTotals(meta?.last_call_usage),
|
|
1599
|
+
parseUsageTotals(agentMeta?.usage),
|
|
1600
|
+
parseUsageTotals(agentMeta?.lastCallUsage),
|
|
1601
|
+
parseUsageTotals(agentMeta?.last_call_usage),
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function buildMessageMeta(params: {
|
|
1606
|
+
accountId: string;
|
|
1607
|
+
runState?: SiderRunState;
|
|
1608
|
+
stopReason?: string;
|
|
1609
|
+
}): Record<string, unknown> {
|
|
1610
|
+
const meta = buildEventMeta({ accountId: params.accountId });
|
|
1611
|
+
if (params.runState?.runId) {
|
|
1612
|
+
meta.run_id = params.runState.runId;
|
|
1613
|
+
}
|
|
1614
|
+
if (params.runState?.provider) {
|
|
1615
|
+
meta.provider = params.runState.provider;
|
|
1616
|
+
}
|
|
1617
|
+
if (params.runState?.model) {
|
|
1618
|
+
meta.model = params.runState.model;
|
|
1619
|
+
}
|
|
1620
|
+
if (params.stopReason) {
|
|
1621
|
+
meta.stop_reason = params.stopReason;
|
|
1622
|
+
}
|
|
1623
|
+
const usage = formatUsageForMessageMeta(params.runState?.usage);
|
|
1624
|
+
if (usage) {
|
|
1625
|
+
meta.usage = usage;
|
|
1626
|
+
}
|
|
1627
|
+
return meta;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function buildThinkingPart(text: string): SiderPart {
|
|
1631
|
+
return {
|
|
1632
|
+
type: "thinking",
|
|
1633
|
+
spec_version: 1,
|
|
1634
|
+
payload: { text },
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function buildToolCallPart(params: {
|
|
1639
|
+
callId: string;
|
|
1640
|
+
toolName?: string;
|
|
1641
|
+
toolCallId?: string;
|
|
1642
|
+
runId?: string;
|
|
1643
|
+
toolArgs?: unknown;
|
|
1644
|
+
}): SiderPart {
|
|
1645
|
+
const payload: Record<string, unknown> = {
|
|
1646
|
+
call_id: params.callId,
|
|
1647
|
+
...(params.toolName ? { tool_name: params.toolName } : {}),
|
|
1648
|
+
...(params.toolCallId ? { tool_call_id: params.toolCallId } : {}),
|
|
1649
|
+
...(params.runId ? { run_id: params.runId } : {}),
|
|
1650
|
+
};
|
|
1651
|
+
appendStructuredPayloadField(payload, "tool_args", params.toolArgs);
|
|
1652
|
+
return {
|
|
1653
|
+
type: "tool.call",
|
|
1654
|
+
spec_version: 1,
|
|
1655
|
+
payload,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function buildToolResultPart(params: {
|
|
1660
|
+
callId: string;
|
|
1661
|
+
toolName?: string;
|
|
1662
|
+
toolCallId?: string;
|
|
1663
|
+
runId?: string;
|
|
1664
|
+
toolArgs?: unknown;
|
|
1665
|
+
result?: unknown;
|
|
1666
|
+
error?: string;
|
|
1667
|
+
durationMs?: number;
|
|
1668
|
+
}): SiderPart {
|
|
1669
|
+
const text = extractToolResultText(params.result);
|
|
1670
|
+
const payload: Record<string, unknown> = {
|
|
1671
|
+
call_id: params.callId,
|
|
1672
|
+
...(params.toolName ? { tool_name: params.toolName } : {}),
|
|
1673
|
+
...(params.toolCallId ? { tool_call_id: params.toolCallId } : {}),
|
|
1674
|
+
...(params.runId ? { run_id: params.runId } : {}),
|
|
1675
|
+
...(params.error ? { error: params.error } : {}),
|
|
1676
|
+
...(hasFiniteNumber(params.durationMs) ? { duration_ms: params.durationMs } : {}),
|
|
1677
|
+
...(text ? { text } : {}),
|
|
1678
|
+
has_text: text.length > 0,
|
|
1679
|
+
is_error: Boolean(params.error),
|
|
1680
|
+
};
|
|
1681
|
+
appendStructuredPayloadField(payload, "tool_args", params.toolArgs);
|
|
1682
|
+
appendStructuredPayloadField(payload, "result", params.result);
|
|
1683
|
+
return {
|
|
1684
|
+
type: "tool.result",
|
|
1685
|
+
spec_version: 1,
|
|
1686
|
+
payload,
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function parseAssistantOutput(lastAssistant: unknown): SiderParsedAssistantOutput | null {
|
|
1691
|
+
const assistant = toRecord(lastAssistant);
|
|
1692
|
+
if (!assistant) {
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
const content = Array.isArray(assistant.content) ? assistant.content : [];
|
|
1696
|
+
const thinkingChunks: string[] = [];
|
|
1697
|
+
const toolCalls: SiderParsedToolCall[] = [];
|
|
1698
|
+
for (const item of content) {
|
|
1699
|
+
const part = toRecord(item);
|
|
1700
|
+
if (!part) {
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
const partType = typeof part.type === "string" ? part.type.trim() : "";
|
|
1704
|
+
if (partType === "thinking") {
|
|
1705
|
+
const thinking = typeof part.thinking === "string" ? part.thinking.trim() : "";
|
|
1706
|
+
if (thinking) {
|
|
1707
|
+
thinkingChunks.push(thinking);
|
|
1708
|
+
}
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
if (partType !== "toolCall" && partType !== "toolUse") {
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
const toolName =
|
|
1715
|
+
typeof part.toolName === "string"
|
|
1716
|
+
? part.toolName.trim()
|
|
1717
|
+
: typeof part.name === "string"
|
|
1718
|
+
? part.name.trim()
|
|
1719
|
+
: "";
|
|
1720
|
+
const toolCallId =
|
|
1721
|
+
typeof part.toolCallId === "string"
|
|
1722
|
+
? part.toolCallId.trim()
|
|
1723
|
+
: typeof part.toolUseId === "string"
|
|
1724
|
+
? part.toolUseId.trim()
|
|
1725
|
+
: typeof part.id === "string"
|
|
1726
|
+
? part.id.trim()
|
|
1727
|
+
: undefined;
|
|
1728
|
+
toolCalls.push({
|
|
1729
|
+
toolName: toolName || undefined,
|
|
1730
|
+
toolCallId: toolCallId || undefined,
|
|
1731
|
+
toolArgs: part.arguments ?? part.input ?? part.args ?? part.parameters,
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
const stopReason = typeof assistant.stopReason === "string" ? assistant.stopReason.trim() : "";
|
|
1735
|
+
return {
|
|
1736
|
+
stopReason: stopReason || undefined,
|
|
1737
|
+
thinkingText: thinkingChunks.join("\n\n").trim() || undefined,
|
|
1738
|
+
toolCalls,
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function buildTypingEvent(params: {
|
|
1743
|
+
state: "typing" | "idle";
|
|
1744
|
+
sessionId: string;
|
|
1745
|
+
accountId: string;
|
|
1746
|
+
}): SiderSampleEvent {
|
|
1747
|
+
return {
|
|
1748
|
+
eventType: "typing",
|
|
1749
|
+
payload: {
|
|
1750
|
+
on: params.state === "typing",
|
|
1751
|
+
state: params.state,
|
|
1752
|
+
session_id: params.sessionId,
|
|
1753
|
+
ts: Date.now(),
|
|
1754
|
+
},
|
|
1755
|
+
meta: buildEventMeta({ accountId: params.accountId }),
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function buildStreamingStartEvent(params: {
|
|
1760
|
+
sessionId: string;
|
|
1761
|
+
streamId: string;
|
|
1762
|
+
accountId: string;
|
|
1763
|
+
}): SiderSampleEvent {
|
|
1764
|
+
return {
|
|
1765
|
+
eventType: "stream.start",
|
|
1766
|
+
payload: {
|
|
1767
|
+
session_id: params.sessionId,
|
|
1768
|
+
stream_id: params.streamId,
|
|
1769
|
+
ts: Date.now(),
|
|
1770
|
+
},
|
|
1771
|
+
meta: buildEventMeta({ accountId: params.accountId }),
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function buildStreamingDeltaEvent(params: {
|
|
1776
|
+
sessionId: string;
|
|
1777
|
+
streamId: string;
|
|
1778
|
+
seq: number;
|
|
1779
|
+
delta: string;
|
|
1780
|
+
text: string;
|
|
1781
|
+
accountId: string;
|
|
1782
|
+
}): SiderSampleEvent {
|
|
1783
|
+
return {
|
|
1784
|
+
eventType: "stream.delta",
|
|
1785
|
+
payload: {
|
|
1786
|
+
session_id: params.sessionId,
|
|
1787
|
+
stream_id: params.streamId,
|
|
1788
|
+
seq: params.seq,
|
|
1789
|
+
delta: params.delta,
|
|
1790
|
+
text: params.text,
|
|
1791
|
+
done: false,
|
|
1792
|
+
chunk_chars: params.delta.length,
|
|
1793
|
+
ts: Date.now(),
|
|
1794
|
+
},
|
|
1795
|
+
meta: buildEventMeta({ accountId: params.accountId }),
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function buildStreamingDoneEvent(params: {
|
|
1800
|
+
sessionId: string;
|
|
1801
|
+
streamId: string;
|
|
1802
|
+
seq: number;
|
|
1803
|
+
accountId: string;
|
|
1804
|
+
reason: "final" | "interrupted";
|
|
1805
|
+
}): SiderSampleEvent {
|
|
1806
|
+
return {
|
|
1807
|
+
eventType: "stream.done",
|
|
1808
|
+
payload: {
|
|
1809
|
+
session_id: params.sessionId,
|
|
1810
|
+
stream_id: params.streamId,
|
|
1811
|
+
seq: params.seq,
|
|
1812
|
+
done: true,
|
|
1813
|
+
reason: params.reason,
|
|
1814
|
+
ts: Date.now(),
|
|
1815
|
+
},
|
|
1816
|
+
meta: buildEventMeta({ accountId: params.accountId }),
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function buildToolCallEvent(params: {
|
|
1821
|
+
sessionId: string;
|
|
1822
|
+
accountId: string;
|
|
1823
|
+
seq: number;
|
|
1824
|
+
callId: string;
|
|
1825
|
+
phase: "start";
|
|
1826
|
+
toolName?: string;
|
|
1827
|
+
toolCallId?: string;
|
|
1828
|
+
runId?: string;
|
|
1829
|
+
sessionKey?: string;
|
|
1830
|
+
toolArgs?: Record<string, unknown>;
|
|
1831
|
+
error?: string;
|
|
1832
|
+
durationMs?: number;
|
|
1833
|
+
}): SiderSampleEvent {
|
|
1834
|
+
return {
|
|
1835
|
+
eventType: "tool.call",
|
|
1836
|
+
payload: {
|
|
1837
|
+
session_id: params.sessionId,
|
|
1838
|
+
seq: params.seq,
|
|
1839
|
+
call_id: params.callId,
|
|
1840
|
+
phase: params.phase,
|
|
1841
|
+
tool_name: params.toolName,
|
|
1842
|
+
tool_call_id: params.toolCallId,
|
|
1843
|
+
run_id: params.runId,
|
|
1844
|
+
session_key: params.sessionKey,
|
|
1845
|
+
tool_args: params.toolArgs,
|
|
1846
|
+
error: params.error,
|
|
1847
|
+
duration_ms: params.durationMs,
|
|
1848
|
+
ts: Date.now(),
|
|
1849
|
+
},
|
|
1850
|
+
meta: buildEventMeta({ accountId: params.accountId }),
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function buildToolResultEvent(params: {
|
|
1855
|
+
sessionId: string;
|
|
1856
|
+
accountId: string;
|
|
1857
|
+
seq: number;
|
|
1858
|
+
callId: string;
|
|
1859
|
+
toolName?: string;
|
|
1860
|
+
toolCallId?: string;
|
|
1861
|
+
runId?: string;
|
|
1862
|
+
sessionKey?: string;
|
|
1863
|
+
toolArgs?: Record<string, unknown>;
|
|
1864
|
+
result?: unknown;
|
|
1865
|
+
error?: string;
|
|
1866
|
+
durationMs?: number;
|
|
1867
|
+
}): SiderSampleEvent {
|
|
1868
|
+
const text = extractToolResultText(params.result);
|
|
1869
|
+
const safeResult = toJsonSafeValue(params.result);
|
|
1870
|
+
const safeToolArgs = toJsonSafeValue(params.toolArgs);
|
|
1871
|
+
return {
|
|
1872
|
+
eventType: "tool.result",
|
|
1873
|
+
payload: {
|
|
1874
|
+
session_id: params.sessionId,
|
|
1875
|
+
seq: params.seq,
|
|
1876
|
+
call_id: params.callId,
|
|
1877
|
+
tool_name: params.toolName,
|
|
1878
|
+
tool_call_id: params.toolCallId,
|
|
1879
|
+
run_id: params.runId,
|
|
1880
|
+
session_key: params.sessionKey,
|
|
1881
|
+
tool_args: safeToolArgs,
|
|
1882
|
+
result: safeResult,
|
|
1883
|
+
error: params.error,
|
|
1884
|
+
duration_ms: params.durationMs,
|
|
1885
|
+
text,
|
|
1886
|
+
has_text: text.trim().length > 0,
|
|
1887
|
+
media_urls: [],
|
|
1888
|
+
media_count: 0,
|
|
1889
|
+
is_error: Boolean(params.error),
|
|
1890
|
+
ts: Date.now(),
|
|
1891
|
+
},
|
|
1892
|
+
meta: buildEventMeta({ accountId: params.accountId }),
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function resolveRelayCallIdForToolEvent(params: {
|
|
1897
|
+
binding: SiderSessionBinding;
|
|
1898
|
+
phase: "start" | "end" | "error";
|
|
1899
|
+
toolCallId?: string;
|
|
1900
|
+
}): string {
|
|
1901
|
+
if (params.toolCallId) {
|
|
1902
|
+
const existing = params.binding.callIdByToolCallId.get(params.toolCallId);
|
|
1903
|
+
if (existing) {
|
|
1904
|
+
params.binding.currentToolCallId = existing;
|
|
1905
|
+
return existing;
|
|
1906
|
+
}
|
|
1907
|
+
const created = crypto.randomUUID();
|
|
1908
|
+
params.binding.callIdByToolCallId.set(params.toolCallId, created);
|
|
1909
|
+
params.binding.currentToolCallId = created;
|
|
1910
|
+
return created;
|
|
1911
|
+
}
|
|
1912
|
+
if (params.phase === "start" || !params.binding.currentToolCallId) {
|
|
1913
|
+
params.binding.currentToolCallId = crypto.randomUUID();
|
|
1914
|
+
}
|
|
1915
|
+
return params.binding.currentToolCallId;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function clearRelayCallIdForToolEvent(params: {
|
|
1919
|
+
binding: SiderSessionBinding;
|
|
1920
|
+
callId: string;
|
|
1921
|
+
toolCallId?: string;
|
|
1922
|
+
}): void {
|
|
1923
|
+
if (params.toolCallId) {
|
|
1924
|
+
params.binding.callIdByToolCallId.delete(params.toolCallId);
|
|
1925
|
+
}
|
|
1926
|
+
if (params.binding.currentToolCallId === params.callId) {
|
|
1927
|
+
params.binding.currentToolCallId = undefined;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function resolveRunStateForOutbound(params: {
|
|
1932
|
+
runId?: string;
|
|
1933
|
+
sessionKey?: string;
|
|
1934
|
+
}): SiderRunState | undefined {
|
|
1935
|
+
const direct = resolveSiderRunState(params.runId);
|
|
1936
|
+
if (direct) {
|
|
1937
|
+
return direct;
|
|
1938
|
+
}
|
|
1939
|
+
const binding = resolveSiderSessionBinding(params.sessionKey);
|
|
1940
|
+
return resolveSiderRunState(binding?.currentRunId);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function buildPendingMessageContext(params: {
|
|
1944
|
+
accountId: string;
|
|
1945
|
+
runId?: string;
|
|
1946
|
+
sessionKey?: string;
|
|
1947
|
+
includeFinalThinking?: boolean;
|
|
1948
|
+
}): { parts: SiderPart[]; meta?: Record<string, unknown>; runId?: string } {
|
|
1949
|
+
const runState = resolveRunStateForOutbound({
|
|
1950
|
+
runId: params.runId,
|
|
1951
|
+
sessionKey: params.sessionKey,
|
|
1952
|
+
});
|
|
1953
|
+
const parts: SiderPart[] = [];
|
|
1954
|
+
if (runState?.pendingStructuredParts.length) {
|
|
1955
|
+
parts.push(...runState.pendingStructuredParts);
|
|
1956
|
+
}
|
|
1957
|
+
if (params.includeFinalThinking && runState?.pendingFinalThinking) {
|
|
1958
|
+
parts.push(buildThinkingPart(runState.pendingFinalThinking));
|
|
1959
|
+
}
|
|
1960
|
+
return {
|
|
1961
|
+
parts,
|
|
1962
|
+
meta: runState
|
|
1963
|
+
? buildMessageMeta({
|
|
1964
|
+
accountId: params.accountId,
|
|
1965
|
+
runState,
|
|
1966
|
+
stopReason: params.includeFinalThinking ? runState.pendingFinalStopReason : undefined,
|
|
1967
|
+
})
|
|
1968
|
+
: undefined,
|
|
1969
|
+
runId: runState?.runId,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function consumePendingMessageContext(params: {
|
|
1974
|
+
runId?: string;
|
|
1975
|
+
sessionKey?: string;
|
|
1976
|
+
consumeFinalThinking?: boolean;
|
|
1977
|
+
}): void {
|
|
1978
|
+
const runState = resolveRunStateForOutbound({
|
|
1979
|
+
runId: params.runId,
|
|
1980
|
+
sessionKey: params.sessionKey,
|
|
1981
|
+
});
|
|
1982
|
+
if (!runState) {
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
runState.pendingStructuredParts = [];
|
|
1986
|
+
if (params.consumeFinalThinking) {
|
|
1987
|
+
runState.pendingFinalThinking = undefined;
|
|
1988
|
+
runState.pendingFinalStopReason = undefined;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function clearBindingRunId(sessionKey?: string, runId?: string): void {
|
|
1993
|
+
if (!sessionKey || !runId) {
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
const binding = resolveSiderSessionBinding(sessionKey);
|
|
1997
|
+
if (!binding) {
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (binding.currentRunId === runId) {
|
|
2001
|
+
binding.currentRunId = undefined;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
export function recordSiderPersistedAgentMessage(params: {
|
|
2006
|
+
sessionKey?: string;
|
|
2007
|
+
message: unknown;
|
|
2008
|
+
}): void {
|
|
2009
|
+
const binding = resolveSiderSessionBinding(params.sessionKey);
|
|
2010
|
+
const runId = binding?.currentRunId;
|
|
2011
|
+
if (!runId) {
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
const message = toRecord(params.message);
|
|
2015
|
+
if (!message) {
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
const role = typeof message.role === "string" ? message.role.trim() : "";
|
|
2019
|
+
if (role !== "assistant") {
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const runState = getOrCreateSiderRunState(runId);
|
|
2023
|
+
runState.sessionKey = normalizeSessionBindingKey(params.sessionKey) ?? runState.sessionKey;
|
|
2024
|
+
runState.sessionId = binding?.sessionId ?? runState.sessionId;
|
|
2025
|
+
runState.accountId = binding?.account.accountId ?? runState.accountId;
|
|
2026
|
+
runState.provider = typeof message.provider === "string" ? message.provider.trim() : runState.provider;
|
|
2027
|
+
runState.model = typeof message.model === "string" ? message.model.trim() : runState.model;
|
|
2028
|
+
const directUsage = parseUsageTotals(message.usage);
|
|
2029
|
+
if (directUsage && hasNonZeroUsageTotals(directUsage)) {
|
|
2030
|
+
addUsageTotals(runState.usage, directUsage);
|
|
2031
|
+
} else if (!hasNonZeroUsageTotals(runState.usage)) {
|
|
2032
|
+
const fallbackUsage = parseUsageFromAssistantLike(message);
|
|
2033
|
+
if (fallbackUsage && hasNonZeroUsageTotals(fallbackUsage)) {
|
|
2034
|
+
runState.usage = fallbackUsage;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const parsed = parseAssistantOutput(message);
|
|
2039
|
+
if (!parsed) {
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
if (parsed.toolCalls.length > 0 || parsed.stopReason === "toolUse") {
|
|
2043
|
+
const parts: SiderPart[] = [];
|
|
2044
|
+
if (parsed.thinkingText) {
|
|
2045
|
+
parts.push(buildThinkingPart(parsed.thinkingText));
|
|
2046
|
+
}
|
|
2047
|
+
if (binding) {
|
|
2048
|
+
for (const toolCall of parsed.toolCalls) {
|
|
2049
|
+
const callId = resolveRelayCallIdForToolEvent({
|
|
2050
|
+
binding,
|
|
2051
|
+
phase: "start",
|
|
2052
|
+
toolCallId: toolCall.toolCallId,
|
|
2053
|
+
});
|
|
2054
|
+
parts.push(
|
|
2055
|
+
buildToolCallPart({
|
|
2056
|
+
callId,
|
|
2057
|
+
toolName: toolCall.toolName,
|
|
2058
|
+
toolCallId: toolCall.toolCallId,
|
|
2059
|
+
runId,
|
|
2060
|
+
toolArgs: toolCall.toolArgs,
|
|
2061
|
+
}),
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
if (parts.length > 0) {
|
|
2066
|
+
runState.pendingStructuredParts.push(...parts);
|
|
2067
|
+
}
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
runState.pendingFinalThinking = parsed.thinkingText;
|
|
2072
|
+
runState.pendingFinalStopReason = parsed.stopReason;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
export function recordSiderLlmOutputUsage(params: {
|
|
2076
|
+
sessionKey?: string;
|
|
2077
|
+
runId?: string;
|
|
2078
|
+
provider?: string;
|
|
2079
|
+
model?: string;
|
|
2080
|
+
usage?: unknown;
|
|
2081
|
+
lastAssistant?: unknown;
|
|
2082
|
+
}): void {
|
|
2083
|
+
const binding = resolveSiderSessionBinding(params.sessionKey);
|
|
2084
|
+
const runId = params.runId || binding?.currentRunId;
|
|
2085
|
+
if (!runId) {
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const runState = getOrCreateSiderRunState(runId);
|
|
2089
|
+
runState.sessionKey = normalizeSessionBindingKey(params.sessionKey) ?? runState.sessionKey;
|
|
2090
|
+
runState.sessionId = binding?.sessionId ?? runState.sessionId;
|
|
2091
|
+
runState.accountId = binding?.account.accountId ?? runState.accountId;
|
|
2092
|
+
if (params.provider && typeof params.provider === "string") {
|
|
2093
|
+
runState.provider = params.provider.trim();
|
|
2094
|
+
}
|
|
2095
|
+
if (params.model && typeof params.model === "string") {
|
|
2096
|
+
runState.model = params.model.trim();
|
|
2097
|
+
}
|
|
2098
|
+
const parsed = choosePreferredUsageTotals(
|
|
2099
|
+
parseUsageTotals(params.usage),
|
|
2100
|
+
parseUsageFromAssistantLike(params.lastAssistant),
|
|
2101
|
+
);
|
|
2102
|
+
if (parsed && hasNonZeroUsageTotals(parsed)) {
|
|
2103
|
+
// llm_output provides the accumulated run usage — overwrite rather than add
|
|
2104
|
+
// to avoid double-counting with before_message_write contributions.
|
|
2105
|
+
runState.usage = parsed;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
export async function emitSiderToolHookEvent(params: SiderToolHookPayload): Promise<void> {
|
|
2110
|
+
const binding = resolveSiderSessionBinding(params.sessionKey);
|
|
2111
|
+
if (!binding) {
|
|
2112
|
+
logDebug("skip sider tool hook event: session binding not found", {
|
|
2113
|
+
sessionKey: params.sessionKey,
|
|
2114
|
+
toolName: params.toolName,
|
|
2115
|
+
toolCallId: params.toolCallId,
|
|
2116
|
+
phase: params.phase,
|
|
2117
|
+
});
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (params.runId) {
|
|
2121
|
+
binding.currentRunId = params.runId;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// sessions_send has a few easy-to-miss misuse patterns that look like
|
|
2125
|
+
// channel/plugin failures in logs. Emit explicit diagnostics for triage.
|
|
2126
|
+
const normalizedToolName = (params.toolName ?? "").trim().toLowerCase();
|
|
2127
|
+
const toolArgs = toRecord(params.params);
|
|
2128
|
+
const currentSessionKey = normalizeSessionBindingKey(params.sessionKey);
|
|
2129
|
+
const targetSessionKeyRaw = typeof toolArgs?.sessionKey === "string" ? toolArgs.sessionKey : undefined;
|
|
2130
|
+
const targetSessionKey = normalizeSessionBindingKey(targetSessionKeyRaw);
|
|
2131
|
+
const hasAttachmentLikeArgs = Boolean(
|
|
2132
|
+
toolArgs &&
|
|
2133
|
+
[
|
|
2134
|
+
"attachments",
|
|
2135
|
+
"attachment",
|
|
2136
|
+
"media",
|
|
2137
|
+
"mediaUrl",
|
|
2138
|
+
"mediaUrls",
|
|
2139
|
+
"file",
|
|
2140
|
+
"files",
|
|
2141
|
+
"buffer",
|
|
2142
|
+
"content",
|
|
2143
|
+
"contentBase64",
|
|
2144
|
+
].some((field) => Object.prototype.hasOwnProperty.call(toolArgs, field)),
|
|
2145
|
+
);
|
|
2146
|
+
|
|
2147
|
+
if (normalizedToolName === "sessions_send" && params.phase === "start") {
|
|
2148
|
+
if (hasAttachmentLikeArgs) {
|
|
2149
|
+
logWarn("sider observed sessions_send with attachment-like args; sessions_send only forwards text/message args", {
|
|
2150
|
+
accountId: binding.account.accountId,
|
|
2151
|
+
sessionId: binding.sessionId,
|
|
2152
|
+
runId: params.runId,
|
|
2153
|
+
sessionKey: params.sessionKey,
|
|
2154
|
+
targetSessionKey: targetSessionKeyRaw,
|
|
2155
|
+
toolCallId: params.toolCallId,
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
if (currentSessionKey && targetSessionKey && currentSessionKey === targetSessionKey) {
|
|
2159
|
+
logWarn("sider observed sessions_send targeting current session; this often times out while current run is still active", {
|
|
2160
|
+
accountId: binding.account.accountId,
|
|
2161
|
+
sessionId: binding.sessionId,
|
|
2162
|
+
runId: params.runId,
|
|
2163
|
+
sessionKey: params.sessionKey,
|
|
2164
|
+
targetSessionKey: targetSessionKeyRaw,
|
|
2165
|
+
toolCallId: params.toolCallId,
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const callId = resolveRelayCallIdForToolEvent({
|
|
2171
|
+
binding,
|
|
2172
|
+
phase: params.phase,
|
|
2173
|
+
toolCallId: params.toolCallId,
|
|
2174
|
+
});
|
|
2175
|
+
if (params.phase === "start") {
|
|
2176
|
+
binding.toolSeq += 1;
|
|
2177
|
+
const toolCallEvent = buildToolCallEvent({
|
|
2178
|
+
sessionId: binding.sessionId,
|
|
2179
|
+
accountId: binding.account.accountId,
|
|
2180
|
+
seq: binding.toolSeq,
|
|
2181
|
+
callId,
|
|
2182
|
+
phase: "start",
|
|
2183
|
+
toolName: params.toolName,
|
|
2184
|
+
toolCallId: params.toolCallId,
|
|
2185
|
+
runId: params.runId,
|
|
2186
|
+
sessionKey: params.sessionKey,
|
|
2187
|
+
toolArgs: params.params,
|
|
2188
|
+
error: params.error,
|
|
2189
|
+
durationMs: params.durationMs,
|
|
2190
|
+
});
|
|
2191
|
+
await sendSiderEventBestEffort({
|
|
2192
|
+
account: binding.account,
|
|
2193
|
+
sessionId: binding.sessionId,
|
|
2194
|
+
event: toolCallEvent,
|
|
2195
|
+
context: "tool.call.hook.start",
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
if (params.phase !== "start") {
|
|
2200
|
+
if (normalizedToolName === "sessions_send") {
|
|
2201
|
+
const resultRecord = toRecord(params.result);
|
|
2202
|
+
const status =
|
|
2203
|
+
typeof resultRecord?.status === "string" ? resultRecord.status.trim().toLowerCase() : "";
|
|
2204
|
+
if (status === "timeout") {
|
|
2205
|
+
logWarn("sider observed sessions_send timeout", {
|
|
2206
|
+
accountId: binding.account.accountId,
|
|
2207
|
+
sessionId: binding.sessionId,
|
|
2208
|
+
runId: params.runId,
|
|
2209
|
+
sessionKey: params.sessionKey,
|
|
2210
|
+
targetSessionKey: targetSessionKeyRaw,
|
|
2211
|
+
toolCallId: params.toolCallId,
|
|
2212
|
+
error: params.error,
|
|
2213
|
+
durationMs: params.durationMs,
|
|
2214
|
+
selfTargeted: Boolean(currentSessionKey && targetSessionKey && currentSessionKey === targetSessionKey),
|
|
2215
|
+
hasAttachmentLikeArgs,
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
binding.toolSeq += 1;
|
|
2220
|
+
const toolResultEvent = buildToolResultEvent({
|
|
2221
|
+
sessionId: binding.sessionId,
|
|
2222
|
+
accountId: binding.account.accountId,
|
|
2223
|
+
seq: binding.toolSeq,
|
|
2224
|
+
callId,
|
|
2225
|
+
toolName: params.toolName,
|
|
2226
|
+
toolCallId: params.toolCallId,
|
|
2227
|
+
runId: params.runId,
|
|
2228
|
+
sessionKey: params.sessionKey,
|
|
2229
|
+
toolArgs: params.params,
|
|
2230
|
+
result: params.result,
|
|
2231
|
+
error: params.error,
|
|
2232
|
+
durationMs: params.durationMs,
|
|
2233
|
+
});
|
|
2234
|
+
await sendSiderEventBestEffort({
|
|
2235
|
+
account: binding.account,
|
|
2236
|
+
sessionId: binding.sessionId,
|
|
2237
|
+
event: toolResultEvent,
|
|
2238
|
+
context: `tool.result.hook.${params.phase}`,
|
|
2239
|
+
});
|
|
2240
|
+
if (params.runId) {
|
|
2241
|
+
const runState = getOrCreateSiderRunState(params.runId);
|
|
2242
|
+
runState.sessionKey = normalizeSessionBindingKey(params.sessionKey) ?? runState.sessionKey;
|
|
2243
|
+
runState.sessionId = binding.sessionId;
|
|
2244
|
+
runState.accountId = binding.account.accountId;
|
|
2245
|
+
runState.pendingStructuredParts.push(
|
|
2246
|
+
buildToolResultPart({
|
|
2247
|
+
callId,
|
|
2248
|
+
toolName: params.toolName,
|
|
2249
|
+
toolCallId: params.toolCallId,
|
|
2250
|
+
runId: params.runId,
|
|
2251
|
+
toolArgs: params.params,
|
|
2252
|
+
result: params.result,
|
|
2253
|
+
error: params.error,
|
|
2254
|
+
durationMs: params.durationMs,
|
|
2255
|
+
}),
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
clearRelayCallIdForToolEvent({
|
|
2259
|
+
binding,
|
|
2260
|
+
callId,
|
|
2261
|
+
toolCallId: params.toolCallId,
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
async function openStreamingSessionIfNeeded(params: {
|
|
2267
|
+
account: ResolvedSiderAccount;
|
|
2268
|
+
sessionId: string;
|
|
2269
|
+
streamState: SiderStreamState;
|
|
2270
|
+
}): Promise<void> {
|
|
2271
|
+
if (params.streamState.active && params.streamState.streamId) {
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
params.streamState.active = true;
|
|
2275
|
+
params.streamState.streamId = crypto.randomUUID();
|
|
2276
|
+
params.streamState.seq = 0;
|
|
2277
|
+
const streamStartEvent = buildStreamingStartEvent({
|
|
2278
|
+
sessionId: params.sessionId,
|
|
2279
|
+
streamId: params.streamState.streamId,
|
|
2280
|
+
accountId: params.account.accountId,
|
|
2281
|
+
});
|
|
2282
|
+
await sendSiderEventBestEffort({
|
|
2283
|
+
account: params.account,
|
|
2284
|
+
sessionId: params.sessionId,
|
|
2285
|
+
event: streamStartEvent,
|
|
2286
|
+
context: "stream.start",
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function sendStreamingDeltaEvent(params: {
|
|
2291
|
+
account: ResolvedSiderAccount;
|
|
2292
|
+
sessionId: string;
|
|
2293
|
+
streamState: SiderStreamState;
|
|
2294
|
+
delta: string;
|
|
2295
|
+
text: string;
|
|
2296
|
+
context: string;
|
|
2297
|
+
}): Promise<void> {
|
|
2298
|
+
if (!params.delta) {
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
await openStreamingSessionIfNeeded({
|
|
2302
|
+
account: params.account,
|
|
2303
|
+
sessionId: params.sessionId,
|
|
2304
|
+
streamState: params.streamState,
|
|
2305
|
+
});
|
|
2306
|
+
params.streamState.seq += 1;
|
|
2307
|
+
const streamDeltaEvent = buildStreamingDeltaEvent({
|
|
2308
|
+
sessionId: params.sessionId,
|
|
2309
|
+
streamId: params.streamState.streamId!,
|
|
2310
|
+
seq: params.streamState.seq,
|
|
2311
|
+
delta: params.delta,
|
|
2312
|
+
text: params.text,
|
|
2313
|
+
accountId: params.account.accountId,
|
|
2314
|
+
});
|
|
2315
|
+
logDebug("sending stream delta event", {
|
|
2316
|
+
accountId: params.account.accountId,
|
|
2317
|
+
sessionId: params.sessionId,
|
|
2318
|
+
eventType: streamDeltaEvent.eventType,
|
|
2319
|
+
streamId: params.streamState.streamId,
|
|
2320
|
+
seq: params.streamState.seq,
|
|
2321
|
+
deltaLength: params.delta.length,
|
|
2322
|
+
textLength: params.text.length,
|
|
2323
|
+
context: params.context,
|
|
2324
|
+
});
|
|
2325
|
+
await sendSiderEventBestEffort({
|
|
2326
|
+
account: params.account,
|
|
2327
|
+
sessionId: params.sessionId,
|
|
2328
|
+
event: streamDeltaEvent,
|
|
2329
|
+
context: params.context,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
async function closeStreamingSessionIfActive(params: {
|
|
2334
|
+
account: ResolvedSiderAccount;
|
|
2335
|
+
sessionId: string;
|
|
2336
|
+
streamState: SiderStreamState;
|
|
2337
|
+
reason: "final" | "interrupted";
|
|
2338
|
+
context: string;
|
|
2339
|
+
}): Promise<void> {
|
|
2340
|
+
if (!params.streamState.active || !params.streamState.streamId) {
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
params.streamState.seq += 1;
|
|
2344
|
+
const streamDoneEvent = buildStreamingDoneEvent({
|
|
2345
|
+
sessionId: params.sessionId,
|
|
2346
|
+
streamId: params.streamState.streamId,
|
|
2347
|
+
seq: params.streamState.seq,
|
|
2348
|
+
accountId: params.account.accountId,
|
|
2349
|
+
reason: params.reason,
|
|
2350
|
+
});
|
|
2351
|
+
await sendSiderEventBestEffort({
|
|
2352
|
+
account: params.account,
|
|
2353
|
+
sessionId: params.sessionId,
|
|
2354
|
+
event: streamDoneEvent,
|
|
2355
|
+
context: params.context,
|
|
2356
|
+
});
|
|
2357
|
+
params.streamState.active = false;
|
|
2358
|
+
params.streamState.streamId = undefined;
|
|
2359
|
+
params.streamState.seq = 0;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function enqueueStreamEventTask(params: {
|
|
2363
|
+
streamState: SiderStreamState;
|
|
2364
|
+
task: () => Promise<void>;
|
|
2365
|
+
}): Promise<void> {
|
|
2366
|
+
const next = params.streamState.streamEventQueue.then(params.task).catch((err) => {
|
|
2367
|
+
logWarn("sider stream event queue task failed", {
|
|
2368
|
+
error: String(err),
|
|
2369
|
+
});
|
|
2370
|
+
});
|
|
2371
|
+
params.streamState.streamEventQueue = next;
|
|
2372
|
+
return next;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
async function flushStreamEventQueue(streamState: SiderStreamState): Promise<void> {
|
|
2376
|
+
await streamState.streamEventQueue;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
function derivePartialDelta(params: { previous: string; next: string }): string {
|
|
2380
|
+
const prev = params.previous;
|
|
2381
|
+
const next = params.next;
|
|
2382
|
+
if (!next) {
|
|
2383
|
+
return "";
|
|
2384
|
+
}
|
|
2385
|
+
if (!prev) {
|
|
2386
|
+
return next;
|
|
2387
|
+
}
|
|
2388
|
+
if (next === prev) {
|
|
2389
|
+
return "";
|
|
2390
|
+
}
|
|
2391
|
+
if (next.startsWith(prev)) {
|
|
2392
|
+
return next.slice(prev.length);
|
|
2393
|
+
}
|
|
2394
|
+
if (prev.startsWith(next)) {
|
|
2395
|
+
// Snapshot regressed (restart/truncation); wait for next stable snapshot.
|
|
2396
|
+
return "";
|
|
2397
|
+
}
|
|
2398
|
+
const maxOverlap = Math.min(prev.length, next.length);
|
|
2399
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
2400
|
+
if (prev.endsWith(next.slice(0, overlap))) {
|
|
2401
|
+
return next.slice(overlap);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
return next;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function mergeBlockTextIntoStreamState(params: {
|
|
2408
|
+
streamState: SiderStreamState;
|
|
2409
|
+
text: string;
|
|
2410
|
+
}): void {
|
|
2411
|
+
const next = params.text;
|
|
2412
|
+
if (!next) {
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
const prev = params.streamState.accumulatedBlockText;
|
|
2416
|
+
if (!prev) {
|
|
2417
|
+
params.streamState.accumulatedBlockText = next;
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
if (next.startsWith(prev)) {
|
|
2421
|
+
// Some runtimes emit cumulative text snapshots.
|
|
2422
|
+
params.streamState.accumulatedBlockText = next;
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if (prev.endsWith(next)) {
|
|
2426
|
+
// Avoid duplicating identical trailing chunks.
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
params.streamState.accumulatedBlockText += next;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
async function handleStreamingPartialSnapshot(params: {
|
|
2433
|
+
account: ResolvedSiderAccount;
|
|
2434
|
+
sessionId: string;
|
|
2435
|
+
streamState: SiderStreamState;
|
|
2436
|
+
snapshot: string;
|
|
2437
|
+
}): Promise<void> {
|
|
2438
|
+
if (params.streamState.streamDeltaMode === "block") {
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
if (params.streamState.streamDeltaMode === "undecided") {
|
|
2442
|
+
params.streamState.streamDeltaMode = "partial";
|
|
2443
|
+
}
|
|
2444
|
+
if (!params.snapshot) {
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
const delta = derivePartialDelta({
|
|
2448
|
+
previous: params.streamState.partialSnapshot,
|
|
2449
|
+
next: params.snapshot,
|
|
2450
|
+
});
|
|
2451
|
+
params.streamState.partialSnapshot = params.snapshot;
|
|
2452
|
+
if (!delta) {
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
// Partial callbacks carry the latest visible assistant snapshot.
|
|
2456
|
+
params.streamState.accumulatedBlockText = params.snapshot;
|
|
2457
|
+
await sendStreamingDeltaEvent({
|
|
2458
|
+
account: params.account,
|
|
2459
|
+
sessionId: params.sessionId,
|
|
2460
|
+
streamState: params.streamState,
|
|
2461
|
+
delta,
|
|
2462
|
+
text: params.snapshot,
|
|
2463
|
+
context: "stream.delta.partial",
|
|
2464
|
+
});
|
|
2465
|
+
params.streamState.partialDeltaCount += 1;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
async function deliverReplyPayloadToSider(params: {
|
|
2469
|
+
account: ResolvedSiderAccount;
|
|
2470
|
+
sessionId: string;
|
|
2471
|
+
streamState: SiderStreamState;
|
|
2472
|
+
payload: ReplyPayload;
|
|
2473
|
+
kind: "tool" | "block" | "final";
|
|
2474
|
+
mediaLocalRoots?: readonly string[];
|
|
2475
|
+
}): Promise<void> {
|
|
2476
|
+
const payloadMediaUrls = resolveOutboundMediaUrls(params.payload);
|
|
2477
|
+
if (params.kind === "block") {
|
|
2478
|
+
const delta = typeof params.payload.text === "string" ? params.payload.text : "";
|
|
2479
|
+
if (delta.length > 0) {
|
|
2480
|
+
params.streamState.blockDeltaCount += 1;
|
|
2481
|
+
mergeBlockTextIntoStreamState({
|
|
2482
|
+
streamState: params.streamState,
|
|
2483
|
+
text: delta,
|
|
2484
|
+
});
|
|
2485
|
+
if (params.streamState.streamDeltaMode !== "partial") {
|
|
2486
|
+
if (params.streamState.streamDeltaMode === "undecided") {
|
|
2487
|
+
params.streamState.streamDeltaMode = "block";
|
|
2488
|
+
}
|
|
2489
|
+
await sendStreamingDeltaEvent({
|
|
2490
|
+
account: params.account,
|
|
2491
|
+
sessionId: params.sessionId,
|
|
2492
|
+
streamState: params.streamState,
|
|
2493
|
+
delta,
|
|
2494
|
+
text: params.streamState.accumulatedBlockText,
|
|
2495
|
+
context: "stream.delta.block",
|
|
2496
|
+
});
|
|
2497
|
+
} else {
|
|
2498
|
+
logDebug("skip block stream delta because partial streaming mode is active", {
|
|
2499
|
+
accountId: params.account.accountId,
|
|
2500
|
+
sessionId: params.sessionId,
|
|
2501
|
+
deltaLength: delta.length,
|
|
2502
|
+
partialDeltaCount: params.streamState.partialDeltaCount,
|
|
2503
|
+
streamDeltaMode: params.streamState.streamDeltaMode,
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
if (payloadMediaUrls.length === 0) {
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
logDebug("block payload contains media; sending persisted sider message", {
|
|
2511
|
+
accountId: params.account.accountId,
|
|
2512
|
+
sessionId: params.sessionId,
|
|
2513
|
+
mediaCount: payloadMediaUrls.length,
|
|
2514
|
+
hasText: delta.length > 0,
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
if (params.kind === "final") {
|
|
2519
|
+
await flushStreamEventQueue(params.streamState);
|
|
2520
|
+
if (!params.streamState.active && params.streamState.blockDeltaCount === 0) {
|
|
2521
|
+
const finalText = typeof params.payload.text === "string" ? params.payload.text : "";
|
|
2522
|
+
if (finalText.length > 0) {
|
|
2523
|
+
logDebug("no block delta observed; sending synthetic stream events from final payload", {
|
|
2524
|
+
accountId: params.account.accountId,
|
|
2525
|
+
sessionId: params.sessionId,
|
|
2526
|
+
textLength: finalText.length,
|
|
2527
|
+
});
|
|
2528
|
+
params.streamState.accumulatedBlockText = finalText;
|
|
2529
|
+
await sendStreamingDeltaEvent({
|
|
2530
|
+
account: params.account,
|
|
2531
|
+
sessionId: params.sessionId,
|
|
2532
|
+
streamState: params.streamState,
|
|
2533
|
+
delta: finalText,
|
|
2534
|
+
text: finalText,
|
|
2535
|
+
context: "stream.delta.final-fallback",
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
await closeStreamingSessionIfActive({
|
|
2541
|
+
account: params.account,
|
|
2542
|
+
sessionId: params.sessionId,
|
|
2543
|
+
streamState: params.streamState,
|
|
2544
|
+
reason: "final",
|
|
2545
|
+
context: "stream.done",
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
// Do not persist final message inside the deliver callback. `llm_output`
|
|
2549
|
+
// is emitted at run tail (after callbacks complete), so persisting later
|
|
2550
|
+
// avoids racing usage/meta population.
|
|
2551
|
+
params.streamState.pendingFinalPayload = params.payload;
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
const baseParts = await buildSiderPartsFromReplyPayload({
|
|
2556
|
+
account: params.account,
|
|
2557
|
+
sessionId: params.sessionId,
|
|
2558
|
+
payload: params.payload,
|
|
2559
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
2560
|
+
logger: {
|
|
2561
|
+
debug: logDebug,
|
|
2562
|
+
warn: logWarn,
|
|
2563
|
+
},
|
|
2564
|
+
});
|
|
2565
|
+
const messageContext = buildPendingMessageContext({
|
|
2566
|
+
accountId: params.account.accountId,
|
|
2567
|
+
runId: params.streamState.currentRunId,
|
|
2568
|
+
sessionKey: params.streamState.sessionKey,
|
|
2569
|
+
includeFinalThinking: false,
|
|
2570
|
+
});
|
|
2571
|
+
if (baseParts.length === 0) {
|
|
2572
|
+
logDebug("skip sider outbound message: base parts empty while pending structured parts are buffered", {
|
|
2573
|
+
accountId: params.account.accountId,
|
|
2574
|
+
sessionId: params.sessionId,
|
|
2575
|
+
kind: params.kind,
|
|
2576
|
+
pendingPartTypes: messageContext.parts.map((part) => part.type),
|
|
2577
|
+
});
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
const parts = [...messageContext.parts, ...baseParts];
|
|
2581
|
+
if (parts.length === 0) {
|
|
2582
|
+
logDebug("skip sider outbound message: empty parts", {
|
|
2583
|
+
accountId: params.account.accountId,
|
|
2584
|
+
sessionId: params.sessionId,
|
|
2585
|
+
kind: params.kind,
|
|
2586
|
+
hasText: typeof params.payload.text === "string" && params.payload.text.length > 0,
|
|
2587
|
+
mediaCount: payloadMediaUrls.length,
|
|
2588
|
+
});
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
logDebug("sending sider outbound message from payload", {
|
|
2592
|
+
accountId: params.account.accountId,
|
|
2593
|
+
sessionId: params.sessionId,
|
|
2594
|
+
kind: params.kind,
|
|
2595
|
+
partTypes: parts.map((part) => part.type),
|
|
2596
|
+
mediaCount: payloadMediaUrls.length,
|
|
2597
|
+
});
|
|
2598
|
+
await sendMessageToSider({
|
|
2599
|
+
account: params.account,
|
|
2600
|
+
sessionId: params.sessionId,
|
|
2601
|
+
parts,
|
|
2602
|
+
meta: messageContext.meta,
|
|
2603
|
+
});
|
|
2604
|
+
consumePendingMessageContext({
|
|
2605
|
+
runId: messageContext.runId,
|
|
2606
|
+
sessionKey: params.streamState.sessionKey,
|
|
2607
|
+
consumeFinalThinking: false,
|
|
2608
|
+
});
|
|
2609
|
+
|
|
2610
|
+
if (payloadMediaUrls.length > 0) {
|
|
2611
|
+
params.streamState.persistedFinalText = true;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
async function persistDeferredFinalReplyPayloadToSider(params: {
|
|
2616
|
+
account: ResolvedSiderAccount;
|
|
2617
|
+
sessionId: string;
|
|
2618
|
+
streamState: SiderStreamState;
|
|
2619
|
+
payload: ReplyPayload;
|
|
2620
|
+
mediaLocalRoots?: readonly string[];
|
|
2621
|
+
}): Promise<void> {
|
|
2622
|
+
const payloadMediaUrls = resolveOutboundMediaUrls(params.payload);
|
|
2623
|
+
const baseParts = await buildSiderPartsFromReplyPayload({
|
|
2624
|
+
account: params.account,
|
|
2625
|
+
sessionId: params.sessionId,
|
|
2626
|
+
payload: params.payload,
|
|
2627
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
2628
|
+
logger: {
|
|
2629
|
+
debug: logDebug,
|
|
2630
|
+
warn: logWarn,
|
|
2631
|
+
},
|
|
2632
|
+
});
|
|
2633
|
+
const messageContext = buildPendingMessageContext({
|
|
2634
|
+
accountId: params.account.accountId,
|
|
2635
|
+
runId: params.streamState.currentRunId,
|
|
2636
|
+
sessionKey: params.streamState.sessionKey,
|
|
2637
|
+
includeFinalThinking: true,
|
|
2638
|
+
});
|
|
2639
|
+
if (
|
|
2640
|
+
baseParts.length === 0 &&
|
|
2641
|
+
messageContext.parts.length > 0 &&
|
|
2642
|
+
params.streamState.accumulatedBlockText.trim()
|
|
2643
|
+
) {
|
|
2644
|
+
logDebug("deferred final payload has no base parts; keeping pending context for text fallback", {
|
|
2645
|
+
accountId: params.account.accountId,
|
|
2646
|
+
sessionId: params.sessionId,
|
|
2647
|
+
runId: params.streamState.currentRunId,
|
|
2648
|
+
pendingPartTypes: messageContext.parts.map((part) => part.type),
|
|
2649
|
+
});
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
const parts = [...messageContext.parts, ...baseParts];
|
|
2653
|
+
if (parts.length === 0) {
|
|
2654
|
+
logDebug("skip deferred final sider message: empty parts", {
|
|
2655
|
+
accountId: params.account.accountId,
|
|
2656
|
+
sessionId: params.sessionId,
|
|
2657
|
+
runId: params.streamState.currentRunId,
|
|
2658
|
+
hasAccumulatedText: params.streamState.accumulatedBlockText.trim().length > 0,
|
|
2659
|
+
});
|
|
2660
|
+
if (!params.streamState.accumulatedBlockText.trim()) {
|
|
2661
|
+
clearBindingRunId(params.streamState.sessionKey, messageContext.runId);
|
|
2662
|
+
discardSiderRunState(messageContext.runId);
|
|
2663
|
+
params.streamState.currentRunId = undefined;
|
|
2664
|
+
}
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
logDebug("sending deferred final sider outbound message from payload", {
|
|
2668
|
+
accountId: params.account.accountId,
|
|
2669
|
+
sessionId: params.sessionId,
|
|
2670
|
+
runId: params.streamState.currentRunId,
|
|
2671
|
+
partTypes: parts.map((part) => part.type),
|
|
2672
|
+
mediaCount: payloadMediaUrls.length,
|
|
2673
|
+
});
|
|
2674
|
+
await sendMessageToSider({
|
|
2675
|
+
account: params.account,
|
|
2676
|
+
sessionId: params.sessionId,
|
|
2677
|
+
parts,
|
|
2678
|
+
meta: messageContext.meta,
|
|
2679
|
+
});
|
|
2680
|
+
consumePendingMessageContext({
|
|
2681
|
+
runId: messageContext.runId,
|
|
2682
|
+
sessionKey: params.streamState.sessionKey,
|
|
2683
|
+
consumeFinalThinking: true,
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
if (baseParts.length > 0 || payloadMediaUrls.length > 0) {
|
|
2687
|
+
params.streamState.persistedFinalText = true;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
clearBindingRunId(params.streamState.sessionKey, messageContext.runId);
|
|
2691
|
+
discardSiderRunState(messageContext.runId);
|
|
2692
|
+
params.streamState.currentRunId = undefined;
|
|
2693
|
+
params.streamState.accumulatedBlockText = "";
|
|
2694
|
+
params.streamState.blockDeltaCount = 0;
|
|
2695
|
+
params.streamState.partialDeltaCount = 0;
|
|
2696
|
+
params.streamState.streamDeltaMode = "undecided";
|
|
2697
|
+
params.streamState.partialSnapshot = "";
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
async function handleInboundRealtimeMessage(params: {
|
|
2701
|
+
cfg: OpenClawConfig;
|
|
2702
|
+
account: ResolvedSiderAccount;
|
|
2703
|
+
event: SiderInboundRealtimeMessage;
|
|
2704
|
+
}): Promise<void> {
|
|
2705
|
+
const { cfg, account, event } = params;
|
|
2706
|
+
const core = getSiderRuntime();
|
|
2707
|
+
const sessionId = typeof event.session_id === "string" ? event.session_id.trim() : "";
|
|
2708
|
+
if (!sessionId) {
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
if ((event.source_role ?? "").toLowerCase() === "relay") {
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
const parts = Array.isArray(event.parts) ? event.parts : [];
|
|
2715
|
+
const textChunks: string[] = [];
|
|
2716
|
+
const mediaItems: ParsedInboundMedia[] = [];
|
|
2717
|
+
|
|
2718
|
+
for (const part of parts) {
|
|
2719
|
+
const text = parseTextFromPart(part);
|
|
2720
|
+
if (text) {
|
|
2721
|
+
textChunks.push(text);
|
|
2722
|
+
continue;
|
|
2723
|
+
}
|
|
2724
|
+
const media = parseMediaFromPart(part);
|
|
2725
|
+
if (media) {
|
|
2726
|
+
mediaItems.push(media);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
const rawText = textChunks.join("\n").trim();
|
|
2731
|
+
const mediaUrls = mediaItems.map((item) => item.url).filter((item): item is string => Boolean(item));
|
|
2732
|
+
const parsedMediaTypes = mediaItems
|
|
2733
|
+
.map((item) => item.mimeType)
|
|
2734
|
+
.filter((item): item is string => Boolean(item));
|
|
2735
|
+
const hasControlCommand = rawText ? core.channel.text.hasControlCommand(rawText, cfg) : false;
|
|
2736
|
+
const shouldComputeCommandAuthorized = rawText
|
|
2737
|
+
? core.channel.commands.shouldComputeCommandAuthorized(rawText, cfg)
|
|
2738
|
+
: false;
|
|
2739
|
+
const commandAuthorized = shouldComputeCommandAuthorized
|
|
2740
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
2741
|
+
useAccessGroups: cfg.commands?.useAccessGroups !== false,
|
|
2742
|
+
// Sider currently has no per-sender allowlist. Treat the authenticated relay session as trusted.
|
|
2743
|
+
authorizers: [{ configured: true, allowed: true }],
|
|
2744
|
+
})
|
|
2745
|
+
: undefined;
|
|
2746
|
+
|
|
2747
|
+
if (!rawText && mediaItems.length === 0) {
|
|
2748
|
+
logDebug("drop inbound sider message: no text/media", {
|
|
2749
|
+
accountId: account.accountId,
|
|
2750
|
+
sessionId,
|
|
2751
|
+
messageId: event.message_id,
|
|
2752
|
+
partTypes: parts.map((part) => part.type),
|
|
2753
|
+
});
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
if (hasControlCommand) {
|
|
2758
|
+
logInfo("sider control command inbound", {
|
|
2759
|
+
accountId: account.accountId,
|
|
2760
|
+
sessionId,
|
|
2761
|
+
messageId: event.message_id,
|
|
2762
|
+
commandAuthorized,
|
|
2763
|
+
});
|
|
2764
|
+
logDebug("detected sider control command", {
|
|
2765
|
+
accountId: account.accountId,
|
|
2766
|
+
sessionId,
|
|
2767
|
+
messageId: event.message_id,
|
|
2768
|
+
commandBody: rawText,
|
|
2769
|
+
shouldComputeCommandAuthorized,
|
|
2770
|
+
commandAuthorized,
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
const resolvedInboundMedia = await resolveInboundSiderMedia({
|
|
2775
|
+
runtime: core,
|
|
2776
|
+
cfg,
|
|
2777
|
+
accountId: account.accountId,
|
|
2778
|
+
mediaItems,
|
|
2779
|
+
gatewayUrl: account.gatewayUrl,
|
|
2780
|
+
token: account.token,
|
|
2781
|
+
requestTimeoutMs: account.sendTimeoutMs,
|
|
2782
|
+
logger: {
|
|
2783
|
+
debug: logDebug,
|
|
2784
|
+
warn: logWarn,
|
|
2785
|
+
},
|
|
2786
|
+
});
|
|
2787
|
+
const unresolvedMediaUrls = resolvedInboundMedia.unresolvedMedia
|
|
2788
|
+
.map((item) => item.url)
|
|
2789
|
+
.filter((item): item is string => Boolean(item));
|
|
2790
|
+
const unresolvedMediaRefs = resolvedInboundMedia.unresolvedMedia
|
|
2791
|
+
.map((item) => describeInboundMediaReference(item))
|
|
2792
|
+
.filter((item): item is string => Boolean(item));
|
|
2793
|
+
if (resolvedInboundMedia.unresolvedMedia.length > 0) {
|
|
2794
|
+
logWarn("sider inbound media fallback to attachment url", {
|
|
2795
|
+
accountId: account.accountId,
|
|
2796
|
+
sessionId,
|
|
2797
|
+
unresolvedCount: resolvedInboundMedia.unresolvedMedia.length,
|
|
2798
|
+
unresolvedMediaRefs,
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
const bodyForAgent = formatTextWithAttachmentLinks(rawText, unresolvedMediaRefs);
|
|
2802
|
+
const mediaPayload = resolvedInboundMedia.mediaPayload;
|
|
2803
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
2804
|
+
cfg,
|
|
2805
|
+
channel: SIDER_CHANNEL_ID,
|
|
2806
|
+
accountId: account.accountId,
|
|
2807
|
+
peer: {
|
|
2808
|
+
kind: "direct",
|
|
2809
|
+
id: sessionId,
|
|
2810
|
+
},
|
|
2811
|
+
});
|
|
2812
|
+
const to = `session:${sessionId}`;
|
|
2813
|
+
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
|
2814
|
+
const timestamp =
|
|
2815
|
+
typeof event.created_at === "number" && Number.isFinite(event.created_at)
|
|
2816
|
+
? event.created_at
|
|
2817
|
+
: Date.now();
|
|
2818
|
+
|
|
2819
|
+
const envelopeBody = core.channel.reply.formatAgentEnvelope({
|
|
2820
|
+
channel: "Sider",
|
|
2821
|
+
from: sessionId,
|
|
2822
|
+
timestamp,
|
|
2823
|
+
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
2824
|
+
body: bodyForAgent,
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
2828
|
+
Body: envelopeBody,
|
|
2829
|
+
BodyForAgent: bodyForAgent,
|
|
2830
|
+
RawBody: bodyForAgent,
|
|
2831
|
+
CommandBody: rawText,
|
|
2832
|
+
BodyForCommands: rawText,
|
|
2833
|
+
meta: event.meta,
|
|
2834
|
+
From: `${SIDER_CHANNEL_ID}:client:${sessionId}`,
|
|
2835
|
+
To: to,
|
|
2836
|
+
SessionKey: route.sessionKey,
|
|
2837
|
+
AccountId: route.accountId,
|
|
2838
|
+
ChatType: "direct",
|
|
2839
|
+
ConversationLabel: sessionId,
|
|
2840
|
+
SenderId: "client",
|
|
2841
|
+
SenderName: "client",
|
|
2842
|
+
Provider: SIDER_CHANNEL_ID,
|
|
2843
|
+
Surface: SIDER_CHANNEL_ID,
|
|
2844
|
+
MessageSid: typeof event.message_id === "string" ? event.message_id : undefined,
|
|
2845
|
+
Timestamp: timestamp,
|
|
2846
|
+
WasMentioned: true,
|
|
2847
|
+
OriginatingChannel: SIDER_CHANNEL_ID,
|
|
2848
|
+
OriginatingTo: to,
|
|
2849
|
+
...mediaPayload,
|
|
2850
|
+
MediaUrl:
|
|
2851
|
+
mediaPayload.MediaUrl ||
|
|
2852
|
+
(unresolvedMediaUrls.length > 0 ? unresolvedMediaUrls[0] : undefined),
|
|
2853
|
+
MediaUrls:
|
|
2854
|
+
mediaPayload.MediaUrls ||
|
|
2855
|
+
(unresolvedMediaUrls.length > 0 ? unresolvedMediaUrls : undefined),
|
|
2856
|
+
MediaType: mediaPayload.MediaType || parsedMediaTypes[0],
|
|
2857
|
+
MediaTypes:
|
|
2858
|
+
mediaPayload.MediaTypes ||
|
|
2859
|
+
(parsedMediaTypes.length > 0 ? parsedMediaTypes : undefined),
|
|
2860
|
+
CommandAuthorized: commandAuthorized,
|
|
2861
|
+
});
|
|
2862
|
+
rememberSiderSessionBinding({
|
|
2863
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
2864
|
+
account,
|
|
2865
|
+
sessionId,
|
|
2866
|
+
});
|
|
2867
|
+
|
|
2868
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
2869
|
+
agentId: route.agentId,
|
|
2870
|
+
});
|
|
2871
|
+
await core.channel.session.recordInboundSession({
|
|
2872
|
+
storePath,
|
|
2873
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
2874
|
+
ctx: ctxPayload,
|
|
2875
|
+
onRecordError: (err) => {
|
|
2876
|
+
core.logging.getChildLogger({ channel: SIDER_CHANNEL_ID }).warn("sider failed to record session", {
|
|
2877
|
+
error: String(err),
|
|
2878
|
+
});
|
|
2879
|
+
},
|
|
2880
|
+
});
|
|
2881
|
+
|
|
2882
|
+
const typingParams: CreateTypingCallbacksParams = {
|
|
2883
|
+
start: async () => {
|
|
2884
|
+
const typingEvent = buildTypingEvent({
|
|
2885
|
+
state: "typing",
|
|
2886
|
+
sessionId,
|
|
2887
|
+
accountId: account.accountId,
|
|
2888
|
+
});
|
|
2889
|
+
logDebug("sending typing start event", {
|
|
2890
|
+
accountId: account.accountId,
|
|
2891
|
+
sessionId,
|
|
2892
|
+
eventType: typingEvent.eventType,
|
|
2893
|
+
});
|
|
2894
|
+
await sendSiderEventBestEffort({
|
|
2895
|
+
account,
|
|
2896
|
+
sessionId,
|
|
2897
|
+
event: typingEvent,
|
|
2898
|
+
context: "typing.start",
|
|
2899
|
+
});
|
|
2900
|
+
},
|
|
2901
|
+
stop: async () => {
|
|
2902
|
+
const typingEvent = buildTypingEvent({
|
|
2903
|
+
state: "idle",
|
|
2904
|
+
sessionId,
|
|
2905
|
+
accountId: account.accountId,
|
|
2906
|
+
});
|
|
2907
|
+
logDebug("sending typing stop event", {
|
|
2908
|
+
accountId: account.accountId,
|
|
2909
|
+
sessionId,
|
|
2910
|
+
eventType: typingEvent.eventType,
|
|
2911
|
+
});
|
|
2912
|
+
await sendSiderEventBestEffort({
|
|
2913
|
+
account,
|
|
2914
|
+
sessionId,
|
|
2915
|
+
event: typingEvent,
|
|
2916
|
+
context: "typing.stop",
|
|
2917
|
+
});
|
|
2918
|
+
},
|
|
2919
|
+
onStartError: (err: unknown) => {
|
|
2920
|
+
logWarn("sider typing start failed", {
|
|
2921
|
+
accountId: account.accountId,
|
|
2922
|
+
sessionId,
|
|
2923
|
+
error: String(err),
|
|
2924
|
+
});
|
|
2925
|
+
},
|
|
2926
|
+
onStopError: (err: unknown) => {
|
|
2927
|
+
logWarn("sider typing stop failed", {
|
|
2928
|
+
accountId: account.accountId,
|
|
2929
|
+
sessionId,
|
|
2930
|
+
error: String(err),
|
|
2931
|
+
});
|
|
2932
|
+
},
|
|
2933
|
+
};
|
|
2934
|
+
|
|
2935
|
+
const { onModelSelected, typingCallbacks, ...prefixOptions } = createChannelReplyPipeline({
|
|
2936
|
+
cfg,
|
|
2937
|
+
agentId: route.agentId,
|
|
2938
|
+
channel: SIDER_CHANNEL_ID,
|
|
2939
|
+
accountId: account.accountId,
|
|
2940
|
+
typing: typingParams,
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
logDebug("dispatching sider inbound message", {
|
|
2944
|
+
accountId: account.accountId,
|
|
2945
|
+
sessionId,
|
|
2946
|
+
messageId: event.message_id,
|
|
2947
|
+
routeSessionKey: route.sessionKey,
|
|
2948
|
+
hasControlCommand,
|
|
2949
|
+
commandAuthorized,
|
|
2950
|
+
textLength: rawText.length,
|
|
2951
|
+
mediaCount: mediaUrls.length,
|
|
2952
|
+
mediaDownloadedCount: mediaPayload.MediaPaths?.length ?? 0,
|
|
2953
|
+
mediaDownloadFailedCount: resolvedInboundMedia.unresolvedMedia.length,
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
const streamState: SiderStreamState = {
|
|
2957
|
+
active: false,
|
|
2958
|
+
streamId: undefined,
|
|
2959
|
+
seq: 0,
|
|
2960
|
+
blockDeltaCount: 0,
|
|
2961
|
+
partialDeltaCount: 0,
|
|
2962
|
+
streamDeltaMode: "undecided",
|
|
2963
|
+
partialSnapshot: "",
|
|
2964
|
+
accumulatedBlockText: "",
|
|
2965
|
+
persistedFinalText: false,
|
|
2966
|
+
pendingFinalPayload: undefined,
|
|
2967
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
2968
|
+
currentRunId: undefined,
|
|
2969
|
+
streamEventQueue: Promise.resolve(),
|
|
2970
|
+
};
|
|
2971
|
+
|
|
2972
|
+
let dispatchCompleted = false;
|
|
2973
|
+
try {
|
|
2974
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2975
|
+
ctx: ctxPayload,
|
|
2976
|
+
cfg,
|
|
2977
|
+
dispatcherOptions: {
|
|
2978
|
+
...prefixOptions,
|
|
2979
|
+
typingCallbacks,
|
|
2980
|
+
deliver: async (payload, info) => {
|
|
2981
|
+
// Skip compaction status notices — they are informational UI signals
|
|
2982
|
+
// that should not be persisted as message content or accumulated into
|
|
2983
|
+
// the outbound text.
|
|
2984
|
+
if ((payload as Record<string, unknown>).isCompactionNotice === true) {
|
|
2985
|
+
logDebug("skip sider deliver: compaction notice", {
|
|
2986
|
+
accountId: account.accountId,
|
|
2987
|
+
sessionId,
|
|
2988
|
+
kind: info.kind,
|
|
2989
|
+
});
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
logDebug("sider dispatch deliver payload", {
|
|
2993
|
+
accountId: account.accountId,
|
|
2994
|
+
sessionId,
|
|
2995
|
+
kind: info.kind,
|
|
2996
|
+
hasText: typeof payload.text === "string" && payload.text.length > 0,
|
|
2997
|
+
mediaCount: resolveOutboundMediaUrls(payload).length,
|
|
2998
|
+
});
|
|
2999
|
+
await deliverReplyPayloadToSider({
|
|
3000
|
+
account,
|
|
3001
|
+
sessionId,
|
|
3002
|
+
streamState,
|
|
3003
|
+
payload,
|
|
3004
|
+
kind: info.kind,
|
|
3005
|
+
mediaLocalRoots,
|
|
3006
|
+
});
|
|
3007
|
+
},
|
|
3008
|
+
onError: (err, info) => {
|
|
3009
|
+
core.logging
|
|
3010
|
+
.getChildLogger({ channel: SIDER_CHANNEL_ID })
|
|
3011
|
+
.error(`sider ${info.kind} reply failed`, { error: String(err) });
|
|
3012
|
+
},
|
|
3013
|
+
},
|
|
3014
|
+
replyOptions: {
|
|
3015
|
+
disableBlockStreaming: false,
|
|
3016
|
+
onAgentRunStart: (runId) => {
|
|
3017
|
+
streamState.currentRunId = runId;
|
|
3018
|
+
const binding = resolveSiderSessionBinding(streamState.sessionKey);
|
|
3019
|
+
if (binding) {
|
|
3020
|
+
binding.currentRunId = runId;
|
|
3021
|
+
}
|
|
3022
|
+
},
|
|
3023
|
+
onPartialReply: async (payload) => {
|
|
3024
|
+
const snapshot = typeof payload.text === "string" ? payload.text : "";
|
|
3025
|
+
if (!snapshot) {
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
if (streamState.streamDeltaMode === "block") {
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
if (streamState.streamDeltaMode === "undecided") {
|
|
3032
|
+
streamState.streamDeltaMode = "partial";
|
|
3033
|
+
}
|
|
3034
|
+
await enqueueStreamEventTask({
|
|
3035
|
+
streamState,
|
|
3036
|
+
task: async () => {
|
|
3037
|
+
await handleStreamingPartialSnapshot({
|
|
3038
|
+
account,
|
|
3039
|
+
sessionId,
|
|
3040
|
+
streamState,
|
|
3041
|
+
snapshot,
|
|
3042
|
+
});
|
|
3043
|
+
},
|
|
3044
|
+
});
|
|
3045
|
+
},
|
|
3046
|
+
onCompactionStart: async () => {
|
|
3047
|
+
// Flush any in-flight stream events and close the current streaming
|
|
3048
|
+
// session so the gateway sees a clean break. Do NOT clear
|
|
3049
|
+
// accumulatedBlockText — the deliver callback already filters
|
|
3050
|
+
// compaction notices via isCompactionNotice, and clearing the
|
|
3051
|
+
// accumulated text would lose the deferred final message fallback.
|
|
3052
|
+
await flushStreamEventQueue(streamState);
|
|
3053
|
+
await closeStreamingSessionIfActive({
|
|
3054
|
+
account,
|
|
3055
|
+
sessionId,
|
|
3056
|
+
streamState,
|
|
3057
|
+
reason: "interrupted",
|
|
3058
|
+
context: "stream.done.compaction-start",
|
|
3059
|
+
});
|
|
3060
|
+
// Reset stream delta tracking so post-compaction output starts
|
|
3061
|
+
// a fresh streaming session without duplicating earlier deltas.
|
|
3062
|
+
streamState.blockDeltaCount = 0;
|
|
3063
|
+
streamState.partialDeltaCount = 0;
|
|
3064
|
+
streamState.streamDeltaMode = "undecided";
|
|
3065
|
+
streamState.partialSnapshot = "";
|
|
3066
|
+
logDebug("sider stream state reset for compaction", {
|
|
3067
|
+
accountId: account.accountId,
|
|
3068
|
+
sessionId,
|
|
3069
|
+
});
|
|
3070
|
+
},
|
|
3071
|
+
onModelSelected,
|
|
3072
|
+
},
|
|
3073
|
+
});
|
|
3074
|
+
dispatchCompleted = true;
|
|
3075
|
+
logDebug("completed sider inbound dispatch", {
|
|
3076
|
+
accountId: account.accountId,
|
|
3077
|
+
sessionId,
|
|
3078
|
+
messageId: event.message_id,
|
|
3079
|
+
});
|
|
3080
|
+
} finally {
|
|
3081
|
+
await flushStreamEventQueue(streamState);
|
|
3082
|
+
await closeStreamingSessionIfActive({
|
|
3083
|
+
account,
|
|
3084
|
+
sessionId,
|
|
3085
|
+
streamState,
|
|
3086
|
+
reason: dispatchCompleted ? "final" : "interrupted",
|
|
3087
|
+
context: dispatchCompleted ? "stream.done.dispatch-end" : "stream.done.interrupted",
|
|
3088
|
+
});
|
|
3089
|
+
streamState.blockDeltaCount = 0;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
if (!dispatchCompleted) {
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
const pendingFinalPayload = streamState.pendingFinalPayload;
|
|
3097
|
+
streamState.pendingFinalPayload = undefined;
|
|
3098
|
+
if (pendingFinalPayload) {
|
|
3099
|
+
await persistDeferredFinalReplyPayloadToSider({
|
|
3100
|
+
account,
|
|
3101
|
+
sessionId,
|
|
3102
|
+
streamState,
|
|
3103
|
+
payload: pendingFinalPayload,
|
|
3104
|
+
mediaLocalRoots,
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
if (!streamState.persistedFinalText && streamState.accumulatedBlockText.trim()) {
|
|
3109
|
+
const text = streamState.accumulatedBlockText;
|
|
3110
|
+
const messageContext = buildPendingMessageContext({
|
|
3111
|
+
accountId: account.accountId,
|
|
3112
|
+
runId: streamState.currentRunId,
|
|
3113
|
+
sessionKey: streamState.sessionKey,
|
|
3114
|
+
includeFinalThinking: true,
|
|
3115
|
+
});
|
|
3116
|
+
const baseParts = await buildSiderPartsFromReplyPayload({
|
|
3117
|
+
account,
|
|
3118
|
+
sessionId,
|
|
3119
|
+
payload: {
|
|
3120
|
+
text,
|
|
3121
|
+
},
|
|
3122
|
+
mediaLocalRoots,
|
|
3123
|
+
logger: {
|
|
3124
|
+
debug: logDebug,
|
|
3125
|
+
warn: logWarn,
|
|
3126
|
+
},
|
|
3127
|
+
});
|
|
3128
|
+
const parts = [...messageContext.parts, ...baseParts];
|
|
3129
|
+
logDebug("no final message payload observed; persisting accumulated block text as final message", {
|
|
3130
|
+
accountId: account.accountId,
|
|
3131
|
+
sessionId,
|
|
3132
|
+
textLength: text.length,
|
|
3133
|
+
partTypes: parts.map((part) => part.type),
|
|
3134
|
+
extraPartTypes: messageContext.parts.map((part) => part.type),
|
|
3135
|
+
});
|
|
3136
|
+
if (parts.length === 0) {
|
|
3137
|
+
logDebug("skip fallback final sider message: empty parts", {
|
|
3138
|
+
accountId: account.accountId,
|
|
3139
|
+
sessionId,
|
|
3140
|
+
});
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
await sendMessageToSider({
|
|
3144
|
+
account,
|
|
3145
|
+
sessionId,
|
|
3146
|
+
parts,
|
|
3147
|
+
meta: messageContext.meta,
|
|
3148
|
+
});
|
|
3149
|
+
consumePendingMessageContext({
|
|
3150
|
+
runId: messageContext.runId,
|
|
3151
|
+
sessionKey: streamState.sessionKey,
|
|
3152
|
+
consumeFinalThinking: true,
|
|
3153
|
+
});
|
|
3154
|
+
clearBindingRunId(streamState.sessionKey, messageContext.runId);
|
|
3155
|
+
discardSiderRunState(messageContext.runId);
|
|
3156
|
+
streamState.persistedFinalText = true;
|
|
3157
|
+
streamState.currentRunId = undefined;
|
|
3158
|
+
streamState.accumulatedBlockText = "";
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
async function monitorSiderSession(params: {
|
|
3163
|
+
cfg: OpenClawConfig;
|
|
3164
|
+
account: ResolvedSiderAccount;
|
|
3165
|
+
abortSignal: AbortSignal;
|
|
3166
|
+
log?: SiderMonitorLogger;
|
|
3167
|
+
}): Promise<void> {
|
|
3168
|
+
const { cfg, account, abortSignal, log } = params;
|
|
3169
|
+
let currentCfg = cfg;
|
|
3170
|
+
let currentAccount = account;
|
|
3171
|
+
const monitorLog = createMonitorLogger(log);
|
|
3172
|
+
|
|
3173
|
+
while (!abortSignal.aborted) {
|
|
3174
|
+
let ws: WebSocket | null = null;
|
|
3175
|
+
let skipSleep = false;
|
|
3176
|
+
const subscribeSessionIds = currentAccount.subscribeSessionIds;
|
|
3177
|
+
try {
|
|
3178
|
+
monitorLog.info(`[${currentAccount.accountId}] opening sider relay websocket`, {
|
|
3179
|
+
accountId: currentAccount.accountId,
|
|
3180
|
+
relayId: currentAccount.relayId,
|
|
3181
|
+
subscribeSessionIds,
|
|
3182
|
+
});
|
|
3183
|
+
ws = await connectRelaySocket({
|
|
3184
|
+
account: currentAccount,
|
|
3185
|
+
relayId: currentAccount.relayId,
|
|
3186
|
+
subscribeSessionIds,
|
|
3187
|
+
});
|
|
3188
|
+
setRelaySocketConnected({
|
|
3189
|
+
account: currentAccount,
|
|
3190
|
+
ws,
|
|
3191
|
+
relayId: currentAccount.relayId,
|
|
3192
|
+
subscribeSessionIds,
|
|
3193
|
+
});
|
|
3194
|
+
monitorLog.info(
|
|
3195
|
+
subscribeSessionIds && subscribeSessionIds.length > 0
|
|
3196
|
+
? `[${currentAccount.accountId}] sider relay connected (session subscriptions: ${subscribeSessionIds.join(", ")})`
|
|
3197
|
+
: `[${currentAccount.accountId}] sider relay connected`,
|
|
3198
|
+
{
|
|
3199
|
+
accountId: currentAccount.accountId,
|
|
3200
|
+
relayId: currentAccount.relayId,
|
|
3201
|
+
subscribeSessionIds,
|
|
3202
|
+
},
|
|
3203
|
+
);
|
|
3204
|
+
|
|
3205
|
+
await new Promise<void>((resolve) => {
|
|
3206
|
+
const socket = ws;
|
|
3207
|
+
if (!socket) {
|
|
3208
|
+
resolve();
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
let settled = false;
|
|
3212
|
+
let pendingPingSince = 0;
|
|
3213
|
+
|
|
3214
|
+
const finish = (params: {
|
|
3215
|
+
reason: string;
|
|
3216
|
+
closeSocket?: boolean;
|
|
3217
|
+
logMessage?: string;
|
|
3218
|
+
warn?: boolean;
|
|
3219
|
+
}) => {
|
|
3220
|
+
if (settled) {
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
settled = true;
|
|
3224
|
+
if (params.logMessage) {
|
|
3225
|
+
const data = {
|
|
3226
|
+
accountId: currentAccount.accountId,
|
|
3227
|
+
relayId: currentAccount.relayId,
|
|
3228
|
+
};
|
|
3229
|
+
const writeLog = params.warn ? monitorLog.warn : monitorLog.info;
|
|
3230
|
+
writeLog(params.logMessage, data);
|
|
3231
|
+
}
|
|
3232
|
+
if (params.closeSocket) {
|
|
3233
|
+
closeSocketQuietly(socket);
|
|
3234
|
+
}
|
|
3235
|
+
cleanup();
|
|
3236
|
+
clearRelaySocketState({
|
|
3237
|
+
accountId: currentAccount.accountId,
|
|
3238
|
+
expectedWs: socket,
|
|
3239
|
+
closeSocket: false,
|
|
3240
|
+
reason: params.reason,
|
|
3241
|
+
});
|
|
3242
|
+
resolve();
|
|
3243
|
+
};
|
|
3244
|
+
|
|
3245
|
+
const keepalive = setInterval(() => {
|
|
3246
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3249
|
+
const now = Date.now();
|
|
3250
|
+
if (pendingPingSince > 0 && now - pendingPingSince >= DEFAULT_MONITOR_PONG_TIMEOUT_MS) {
|
|
3251
|
+
finish({
|
|
3252
|
+
reason: `relay keepalive timeout after ${DEFAULT_MONITOR_PONG_TIMEOUT_MS}ms`,
|
|
3253
|
+
closeSocket: true,
|
|
3254
|
+
logMessage: `[${currentAccount.accountId}] sider relay keepalive timeout; reconnecting`,
|
|
3255
|
+
warn: true,
|
|
3256
|
+
});
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
try {
|
|
3260
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
3261
|
+
if (pendingPingSince === 0) {
|
|
3262
|
+
pendingPingSince = now;
|
|
3263
|
+
}
|
|
3264
|
+
logDebug("sent sider relay keepalive ping", {
|
|
3265
|
+
accountId: currentAccount.accountId,
|
|
3266
|
+
relayId: currentAccount.relayId,
|
|
3267
|
+
subscribeSessionIds,
|
|
3268
|
+
});
|
|
3269
|
+
} catch (err) {
|
|
3270
|
+
monitorLog.warn(`[${currentAccount.accountId}] sider relay keepalive failed: ${String(err)}`, {
|
|
3271
|
+
accountId: currentAccount.accountId,
|
|
3272
|
+
relayId: currentAccount.relayId,
|
|
3273
|
+
error: String(err),
|
|
3274
|
+
});
|
|
3275
|
+
closeSocketQuietly(socket);
|
|
3276
|
+
}
|
|
3277
|
+
}, DEFAULT_MONITOR_KEEPALIVE_MS);
|
|
3278
|
+
|
|
3279
|
+
const cleanup = () => {
|
|
3280
|
+
clearInterval(keepalive);
|
|
3281
|
+
socket.removeEventListener("message", onMessage);
|
|
3282
|
+
socket.removeEventListener("close", onClose);
|
|
3283
|
+
socket.removeEventListener("error", onError);
|
|
3284
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
3285
|
+
};
|
|
3286
|
+
|
|
3287
|
+
const onMessage = (event: MessageEvent) => {
|
|
3288
|
+
void (async () => {
|
|
3289
|
+
const text = await wsDataToText(event.data);
|
|
3290
|
+
const frame = parseInboundFrame(text);
|
|
3291
|
+
if (!frame) {
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
if (frame.type === "event") {
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
pendingPingSince = 0;
|
|
3298
|
+
const frameRecord = toRecord(frame);
|
|
3299
|
+
monitorLog.info("ws frame message", {
|
|
3300
|
+
type: frame.type,
|
|
3301
|
+
sessionId: typeof frameRecord?.session_id === "string" ? frameRecord.session_id : undefined,
|
|
3302
|
+
messageId: typeof frameRecord?.message_id === "string" ? frameRecord.message_id : undefined,
|
|
3303
|
+
eventId: typeof frameRecord?.event_id === "string" ? frameRecord.event_id : undefined,
|
|
3304
|
+
});
|
|
3305
|
+
if (frame.type === "ping") {
|
|
3306
|
+
socket.send(JSON.stringify({ type: "pong" }));
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
if (frame.type === "pong") {
|
|
3310
|
+
logDebug("received sider relay keepalive pong", {
|
|
3311
|
+
accountId: currentAccount.accountId,
|
|
3312
|
+
relayId: currentAccount.relayId,
|
|
3313
|
+
});
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3316
|
+
if (frame.type === "replaced") {
|
|
3317
|
+
monitorLog.warn(`[${currentAccount.accountId}] sider relay replaced by a newer connection`, {
|
|
3318
|
+
accountId: currentAccount.accountId,
|
|
3319
|
+
relayId: currentAccount.relayId,
|
|
3320
|
+
});
|
|
3321
|
+
closeSocketQuietly(socket);
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
if (frame.type === "ack") {
|
|
3325
|
+
const ackFrame = frame as SiderInboundAck;
|
|
3326
|
+
const resolved = resolveManagedAck({
|
|
3327
|
+
accountId: currentAccount.accountId,
|
|
3328
|
+
frame: ackFrame,
|
|
3329
|
+
});
|
|
3330
|
+
logDebug("received sider ack on monitor socket", {
|
|
3331
|
+
accountId: currentAccount.accountId,
|
|
3332
|
+
sessionId: resolveAckSessionId(ackFrame),
|
|
3333
|
+
id: resolveAckId(ackFrame),
|
|
3334
|
+
clientReqId: resolveAckClientReqId(ackFrame),
|
|
3335
|
+
resolved,
|
|
3336
|
+
});
|
|
3337
|
+
return;
|
|
3338
|
+
}
|
|
3339
|
+
if (frame.type !== "message") {
|
|
3340
|
+
return;
|
|
3341
|
+
}
|
|
3342
|
+
await handleInboundRealtimeMessage({
|
|
3343
|
+
cfg: currentCfg,
|
|
3344
|
+
account: currentAccount,
|
|
3345
|
+
event: frame,
|
|
3346
|
+
});
|
|
3347
|
+
})().catch((err) => {
|
|
3348
|
+
log?.error(`[${currentAccount.accountId}] sider inbound handling failed: ${String(err)}`);
|
|
3349
|
+
});
|
|
3350
|
+
};
|
|
3351
|
+
|
|
3352
|
+
const onClose = (event: Event) => {
|
|
3353
|
+
const closeInfo = getSocketCloseInfo(event);
|
|
3354
|
+
if (closeInfo.code === 1008 && closeInfo.reason === "relay replaced") {
|
|
3355
|
+
finish({
|
|
3356
|
+
reason: `relay socket closed${describeSocketClose(event)}`,
|
|
3357
|
+
logMessage: `[${currentAccount.accountId}] sider relay closed by gateway: relay replaced`,
|
|
3358
|
+
warn: true,
|
|
3359
|
+
});
|
|
3360
|
+
} else {
|
|
3361
|
+
finish({
|
|
3362
|
+
reason: `relay socket closed${describeSocketClose(event)}`,
|
|
3363
|
+
logMessage: `[${currentAccount.accountId}] sider relay socket closed${describeSocketClose(event)}`,
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
};
|
|
3367
|
+
const onError = () => {
|
|
3368
|
+
finish({
|
|
3369
|
+
reason: "relay socket error",
|
|
3370
|
+
logMessage: `[${currentAccount.accountId}] sider relay socket error`,
|
|
3371
|
+
warn: true,
|
|
3372
|
+
});
|
|
3373
|
+
};
|
|
3374
|
+
const onAbort = () => {
|
|
3375
|
+
finish({
|
|
3376
|
+
reason: "relay monitor aborted",
|
|
3377
|
+
closeSocket: true,
|
|
3378
|
+
});
|
|
3379
|
+
};
|
|
3380
|
+
|
|
3381
|
+
socket.addEventListener("message", onMessage);
|
|
3382
|
+
socket.addEventListener("close", onClose);
|
|
3383
|
+
socket.addEventListener("error", onError);
|
|
3384
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
3385
|
+
|
|
3386
|
+
if (abortSignal.aborted) {
|
|
3387
|
+
onAbort();
|
|
3388
|
+
}
|
|
3389
|
+
});
|
|
3390
|
+
} catch (err) {
|
|
3391
|
+
monitorLog.warn(`[${currentAccount.accountId}] sider relay loop error: ${String(err)}`, {
|
|
3392
|
+
accountId: currentAccount.accountId,
|
|
3393
|
+
relayId: currentAccount.relayId,
|
|
3394
|
+
error: String(err),
|
|
3395
|
+
});
|
|
3396
|
+
} finally {
|
|
3397
|
+
if (ws) {
|
|
3398
|
+
clearRelaySocketState({
|
|
3399
|
+
accountId: currentAccount.accountId,
|
|
3400
|
+
expectedWs: ws,
|
|
3401
|
+
closeSocket: false,
|
|
3402
|
+
reason: "relay reconnecting",
|
|
3403
|
+
});
|
|
3404
|
+
closeSocketQuietly(ws);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
if (!abortSignal.aborted && !skipSleep) {
|
|
3409
|
+
await sleep(currentAccount.reconnectDelayMs);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
clearRelaySocketState({
|
|
3413
|
+
accountId: currentAccount.accountId,
|
|
3414
|
+
closeSocket: true,
|
|
3415
|
+
});
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function launchManagedRelayMonitor(registration: SiderRelayMonitorRegistration): void {
|
|
3419
|
+
const controller = new AbortController();
|
|
3420
|
+
registration.controller = controller;
|
|
3421
|
+
const runCfg = registration.cfg;
|
|
3422
|
+
const runAccount = registration.account;
|
|
3423
|
+
const runLog = registration.log;
|
|
3424
|
+
registration.runner = (async () => {
|
|
3425
|
+
try {
|
|
3426
|
+
await monitorSiderSession({
|
|
3427
|
+
cfg: runCfg,
|
|
3428
|
+
account: runAccount,
|
|
3429
|
+
abortSignal: controller.signal,
|
|
3430
|
+
log: runLog,
|
|
3431
|
+
});
|
|
3432
|
+
} finally {
|
|
3433
|
+
const current = siderRelayMonitors.get(runAccount.accountId);
|
|
3434
|
+
if (current !== registration || current.controller !== controller) {
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
3437
|
+
if (current.restartRequested && current.subscribers.size > 0) {
|
|
3438
|
+
current.restartRequested = false;
|
|
3439
|
+
launchManagedRelayMonitor(current);
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
if (current.subscribers.size > 0) {
|
|
3443
|
+
current.log?.warn(
|
|
3444
|
+
`[${current.account.accountId}] managed sider relay monitor stopped unexpectedly; restarting`,
|
|
3445
|
+
);
|
|
3446
|
+
launchManagedRelayMonitor(current);
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
siderRelayMonitors.delete(runAccount.accountId);
|
|
3450
|
+
}
|
|
3451
|
+
})();
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
async function runManagedSiderRelayMonitor(params: {
|
|
3455
|
+
cfg: OpenClawConfig;
|
|
3456
|
+
account: ResolvedSiderAccount;
|
|
3457
|
+
abortSignal: AbortSignal;
|
|
3458
|
+
log?: SiderMonitorLogger;
|
|
3459
|
+
}): Promise<void> {
|
|
3460
|
+
const { account, abortSignal, log } = params;
|
|
3461
|
+
const subscriberId = crypto.randomUUID();
|
|
3462
|
+
const monitorSignature = buildRelayMonitorAccountSignature(account);
|
|
3463
|
+
let registration = siderRelayMonitors.get(account.accountId);
|
|
3464
|
+
|
|
3465
|
+
if (!registration) {
|
|
3466
|
+
registration = {
|
|
3467
|
+
cfg: params.cfg,
|
|
3468
|
+
account,
|
|
3469
|
+
monitorSignature,
|
|
3470
|
+
subscribers: new Set([subscriberId]),
|
|
3471
|
+
controller: new AbortController(),
|
|
3472
|
+
runner: Promise.resolve(),
|
|
3473
|
+
restartRequested: false,
|
|
3474
|
+
log,
|
|
3475
|
+
};
|
|
3476
|
+
siderRelayMonitors.set(account.accountId, registration);
|
|
3477
|
+
launchManagedRelayMonitor(registration);
|
|
3478
|
+
} else {
|
|
3479
|
+
registration.subscribers.add(subscriberId);
|
|
3480
|
+
registration.log = log ?? registration.log;
|
|
3481
|
+
if (registration.monitorSignature !== monitorSignature) {
|
|
3482
|
+
registration.cfg = params.cfg;
|
|
3483
|
+
registration.account = account;
|
|
3484
|
+
registration.monitorSignature = monitorSignature;
|
|
3485
|
+
registration.restartRequested = true;
|
|
3486
|
+
log?.info(`[${account.accountId}] restarting sider relay monitor with updated settings`);
|
|
3487
|
+
registration.controller.abort();
|
|
3488
|
+
} else {
|
|
3489
|
+
log?.info(`[${account.accountId}] reusing existing sider relay monitor`);
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
try {
|
|
3494
|
+
await waitForAbortSignal(abortSignal);
|
|
3495
|
+
} finally {
|
|
3496
|
+
const current = siderRelayMonitors.get(account.accountId);
|
|
3497
|
+
if (!current) {
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
current.subscribers.delete(subscriberId);
|
|
3501
|
+
if (current.subscribers.size > 0) {
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
current.controller.abort();
|
|
3505
|
+
await current.runner;
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
export const siderPlugin: ChannelPlugin<ResolvedSiderAccount> = {
|
|
3510
|
+
id: SIDER_CHANNEL_ID,
|
|
3511
|
+
meta,
|
|
3512
|
+
setupWizard: siderSetupWizard,
|
|
3513
|
+
setup: siderSetupAdapter,
|
|
3514
|
+
capabilities: {
|
|
3515
|
+
chatTypes: ["direct"],
|
|
3516
|
+
threads: false,
|
|
3517
|
+
media: true,
|
|
3518
|
+
blockStreaming: true,
|
|
3519
|
+
},
|
|
3520
|
+
agentPrompt: {
|
|
3521
|
+
messageToolHints: () => [
|
|
3522
|
+
"- Sider targeting: omit `to` to reply in the current conversation (auto-inferred).",
|
|
3523
|
+
"- To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote HTTPS URL. You do not need to specify 'to' — the current conversation recipient is used automatically.",
|
|
3524
|
+
"- When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.",
|
|
3525
|
+
"- IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.",
|
|
3526
|
+
"- For `sessions_send`, use OpenClaw session keys (for example `sessions_list[].key`), not provider conversation ids like `01K...`.",
|
|
3527
|
+
],
|
|
3528
|
+
},
|
|
3529
|
+
streaming: {
|
|
3530
|
+
blockStreamingCoalesceDefaults: { minChars: 1200, idleMs: 900 },
|
|
3531
|
+
},
|
|
3532
|
+
configSchema: {
|
|
3533
|
+
schema: {
|
|
3534
|
+
type: "object",
|
|
3535
|
+
additionalProperties: false,
|
|
3536
|
+
properties: {
|
|
3537
|
+
enabled: { type: "boolean" },
|
|
3538
|
+
name: { type: "string" },
|
|
3539
|
+
setupToken: { type: "string" },
|
|
3540
|
+
sessionId: { type: "string" },
|
|
3541
|
+
subscribeSessionIds: {
|
|
3542
|
+
type: "array",
|
|
3543
|
+
items: { type: "string" },
|
|
3544
|
+
},
|
|
3545
|
+
relayId: { type: "string" },
|
|
3546
|
+
token: { type: "string" },
|
|
3547
|
+
defaultTo: { type: "string" },
|
|
3548
|
+
connectTimeoutMs: { type: "number", minimum: 1 },
|
|
3549
|
+
sendTimeoutMs: { type: "number", minimum: 1 },
|
|
3550
|
+
reconnectDelayMs: { type: "number", minimum: 1 },
|
|
3551
|
+
accounts: {
|
|
3552
|
+
type: "object",
|
|
3553
|
+
additionalProperties: {
|
|
3554
|
+
type: "object",
|
|
3555
|
+
additionalProperties: false,
|
|
3556
|
+
properties: {
|
|
3557
|
+
enabled: { type: "boolean" },
|
|
3558
|
+
name: { type: "string" },
|
|
3559
|
+
setupToken: { type: "string" },
|
|
3560
|
+
sessionId: { type: "string" },
|
|
3561
|
+
subscribeSessionIds: {
|
|
3562
|
+
type: "array",
|
|
3563
|
+
items: { type: "string" },
|
|
3564
|
+
},
|
|
3565
|
+
relayId: { type: "string" },
|
|
3566
|
+
token: { type: "string" },
|
|
3567
|
+
defaultTo: { type: "string" },
|
|
3568
|
+
connectTimeoutMs: { type: "number", minimum: 1 },
|
|
3569
|
+
sendTimeoutMs: { type: "number", minimum: 1 },
|
|
3570
|
+
reconnectDelayMs: { type: "number", minimum: 1 },
|
|
3571
|
+
},
|
|
3572
|
+
},
|
|
3573
|
+
},
|
|
3574
|
+
},
|
|
3575
|
+
},
|
|
3576
|
+
},
|
|
3577
|
+
reload: { configPrefixes: [`channels.${SIDER_CHANNEL_ID}`] },
|
|
3578
|
+
config: {
|
|
3579
|
+
listAccountIds: (cfg) => listSiderAccountIds(cfg),
|
|
3580
|
+
resolveAccount: (cfg, accountId) => resolveSiderAccount(cfg, accountId),
|
|
3581
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
3582
|
+
isConfigured: (account) => account.configured || isSiderAccountBootstrappable(account),
|
|
3583
|
+
describeAccount: (account) => ({
|
|
3584
|
+
accountId: account.accountId,
|
|
3585
|
+
enabled: account.enabled,
|
|
3586
|
+
configured: account.configured,
|
|
3587
|
+
name: account.name,
|
|
3588
|
+
baseUrl: account.gatewayUrl,
|
|
3589
|
+
relayId: account.relayId,
|
|
3590
|
+
sessionId: account.sessionId,
|
|
3591
|
+
subscribeSessionIds: account.subscribeSessionIds,
|
|
3592
|
+
}),
|
|
3593
|
+
resolveDefaultTo: ({ cfg, accountId }) => resolveSiderAccount(cfg, accountId).defaultTo,
|
|
3594
|
+
},
|
|
3595
|
+
actions: {
|
|
3596
|
+
describeMessageTool: ({ cfg }) => {
|
|
3597
|
+
const hasConfigured = listSiderAccountIds(cfg).some(
|
|
3598
|
+
(accountId) => {
|
|
3599
|
+
const account = resolveSiderAccount(cfg, accountId);
|
|
3600
|
+
return account.configured || isSiderAccountBootstrappable(account);
|
|
3601
|
+
},
|
|
3602
|
+
);
|
|
3603
|
+
return hasConfigured ? { actions: ["sendAttachment", "send"] as const } : null;
|
|
3604
|
+
},
|
|
3605
|
+
supportsAction: ({ action }) => action === "sendAttachment" || action === "send",
|
|
3606
|
+
extractToolSend: ({ args }) => extractSiderToolSend(args),
|
|
3607
|
+
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => {
|
|
3608
|
+
if (action === "send") {
|
|
3609
|
+
const account = await resolveManagedSiderAccount({ cfg, accountId });
|
|
3610
|
+
if (!account.configured) {
|
|
3611
|
+
throw new Error(describeSiderAccountConfigurationError(account));
|
|
3612
|
+
}
|
|
3613
|
+
const to = readStringParam(params, "to", { required: true });
|
|
3614
|
+
const mediaUrl =
|
|
3615
|
+
readStringParam(params, "media", { trim: false }) ??
|
|
3616
|
+
readStringParam(params, "mediaUrl", { trim: false }) ??
|
|
3617
|
+
readStringParam(params, "path", { trim: false }) ??
|
|
3618
|
+
readStringParam(params, "filePath", { trim: false });
|
|
3619
|
+
const text =
|
|
3620
|
+
readStringParam(params, "message", { allowEmpty: true }) ??
|
|
3621
|
+
readStringParam(params, "caption", { allowEmpty: true }) ??
|
|
3622
|
+
undefined;
|
|
3623
|
+
const sessionId = resolveOutboundSessionId({ account, to });
|
|
3624
|
+
const parts = await buildSiderPartsFromReplyPayload({
|
|
3625
|
+
account,
|
|
3626
|
+
sessionId,
|
|
3627
|
+
payload: {
|
|
3628
|
+
text,
|
|
3629
|
+
mediaUrl: mediaUrl ?? undefined,
|
|
3630
|
+
},
|
|
3631
|
+
mediaLocalRoots,
|
|
3632
|
+
logger: {
|
|
3633
|
+
debug: logDebug,
|
|
3634
|
+
warn: logWarn,
|
|
3635
|
+
},
|
|
3636
|
+
});
|
|
3637
|
+
if (parts.length === 0) {
|
|
3638
|
+
throw new Error("sider send requires text or media");
|
|
3639
|
+
}
|
|
3640
|
+
const result = await sendMessageToSider({
|
|
3641
|
+
account,
|
|
3642
|
+
sessionId,
|
|
3643
|
+
parts,
|
|
3644
|
+
});
|
|
3645
|
+
return jsonResult({
|
|
3646
|
+
ok: true,
|
|
3647
|
+
channel: SIDER_CHANNEL_ID,
|
|
3648
|
+
messageId: result.messageId,
|
|
3649
|
+
conversationId: result.conversationId,
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
if (action !== "sendAttachment") {
|
|
3653
|
+
throw new Error(`sider action ${action} is not supported`);
|
|
3654
|
+
}
|
|
3655
|
+
const account = await resolveManagedSiderAccount({ cfg, accountId });
|
|
3656
|
+
if (!account.configured) {
|
|
3657
|
+
throw new Error(describeSiderAccountConfigurationError(account));
|
|
3658
|
+
}
|
|
3659
|
+
const to = readStringParam(params, "to", { required: true });
|
|
3660
|
+
const rawBuffer = readStringParam(params, "buffer", { trim: false, required: true });
|
|
3661
|
+
const contentType =
|
|
3662
|
+
readStringParam(params, "contentType", { trim: false }) ??
|
|
3663
|
+
readStringParam(params, "mimeType", { trim: false });
|
|
3664
|
+
const fileName = readStringParam(params, "filename", { trim: false }) ?? undefined;
|
|
3665
|
+
const text =
|
|
3666
|
+
readStringParam(params, "caption", { allowEmpty: true }) ??
|
|
3667
|
+
readStringParam(params, "message", { allowEmpty: true }) ??
|
|
3668
|
+
undefined;
|
|
3669
|
+
const sessionId = resolveOutboundSessionId({ account, to });
|
|
3670
|
+
const buffer = decodeBase64AttachmentBuffer(rawBuffer);
|
|
3671
|
+
const savedAttachment = await persistInlineAttachmentBuffer({
|
|
3672
|
+
cfg,
|
|
3673
|
+
accountId,
|
|
3674
|
+
buffer,
|
|
3675
|
+
contentType: contentType ?? undefined,
|
|
3676
|
+
fileName,
|
|
3677
|
+
});
|
|
3678
|
+
const resolvedContentType = contentType ?? savedAttachment.contentType ?? undefined;
|
|
3679
|
+
const resolvedFileName = fileName ?? savedAttachment.path;
|
|
3680
|
+
const parts: SiderPart[] = [];
|
|
3681
|
+
const trimmedText = text?.trim();
|
|
3682
|
+
if (trimmedText) {
|
|
3683
|
+
parts.push({
|
|
3684
|
+
type: "core.text",
|
|
3685
|
+
spec_version: 1,
|
|
3686
|
+
payload: { text: trimmedText },
|
|
3687
|
+
});
|
|
3688
|
+
}
|
|
3689
|
+
parts.push(
|
|
3690
|
+
await buildSiderPartFromInlineAttachment({
|
|
3691
|
+
account,
|
|
3692
|
+
sessionId,
|
|
3693
|
+
buffer,
|
|
3694
|
+
contentType: resolvedContentType,
|
|
3695
|
+
fileName: resolvedFileName,
|
|
3696
|
+
sourceLabel: resolvedFileName,
|
|
3697
|
+
logger: {
|
|
3698
|
+
debug: logDebug,
|
|
3699
|
+
warn: logWarn,
|
|
3700
|
+
},
|
|
3701
|
+
}),
|
|
3702
|
+
);
|
|
3703
|
+
const result = await sendMessageToSider({
|
|
3704
|
+
account,
|
|
3705
|
+
sessionId,
|
|
3706
|
+
parts,
|
|
3707
|
+
});
|
|
3708
|
+
return jsonResult({
|
|
3709
|
+
ok: true,
|
|
3710
|
+
channel: SIDER_CHANNEL_ID,
|
|
3711
|
+
messageId: result.messageId,
|
|
3712
|
+
conversationId: result.conversationId,
|
|
3713
|
+
path: savedAttachment.path,
|
|
3714
|
+
mediaUrl: savedAttachment.path,
|
|
3715
|
+
mediaUrls: [savedAttachment.path],
|
|
3716
|
+
contentType: resolvedContentType,
|
|
3717
|
+
filename: resolvedFileName,
|
|
3718
|
+
});
|
|
3719
|
+
},
|
|
3720
|
+
},
|
|
3721
|
+
outbound: {
|
|
3722
|
+
deliveryMode: "direct",
|
|
3723
|
+
sendText: async ({ cfg, accountId, to, text, mediaLocalRoots }) => {
|
|
3724
|
+
const account = await resolveManagedSiderAccount({ cfg, accountId });
|
|
3725
|
+
if (!account.configured) {
|
|
3726
|
+
throw new Error(describeSiderAccountConfigurationError(account));
|
|
3727
|
+
}
|
|
3728
|
+
const sessionId = resolveOutboundSessionId({ account, to });
|
|
3729
|
+
const parts = await buildSiderPartsFromReplyPayload({
|
|
3730
|
+
account,
|
|
3731
|
+
sessionId,
|
|
3732
|
+
payload: {
|
|
3733
|
+
text,
|
|
3734
|
+
},
|
|
3735
|
+
mediaLocalRoots,
|
|
3736
|
+
logger: {
|
|
3737
|
+
debug: logDebug,
|
|
3738
|
+
warn: logWarn,
|
|
3739
|
+
},
|
|
3740
|
+
});
|
|
3741
|
+
if (parts.length === 0) {
|
|
3742
|
+
throw new Error("sider sendText requires non-empty text or uploadable media reference");
|
|
3743
|
+
}
|
|
3744
|
+
const result = await sendMessageToSider({
|
|
3745
|
+
account,
|
|
3746
|
+
sessionId,
|
|
3747
|
+
parts,
|
|
3748
|
+
});
|
|
3749
|
+
return {
|
|
3750
|
+
channel: SIDER_CHANNEL_ID,
|
|
3751
|
+
messageId: result.messageId,
|
|
3752
|
+
conversationId: result.conversationId,
|
|
3753
|
+
};
|
|
3754
|
+
},
|
|
3755
|
+
sendMedia: async ({ cfg, accountId, to, text, mediaUrl, mediaLocalRoots }) => {
|
|
3756
|
+
const account = await resolveManagedSiderAccount({ cfg, accountId });
|
|
3757
|
+
if (!account.configured) {
|
|
3758
|
+
throw new Error(describeSiderAccountConfigurationError(account));
|
|
3759
|
+
}
|
|
3760
|
+
const sessionId = resolveOutboundSessionId({ account, to });
|
|
3761
|
+
const parts = await buildSiderPartsFromReplyPayload({
|
|
3762
|
+
account,
|
|
3763
|
+
sessionId,
|
|
3764
|
+
payload: {
|
|
3765
|
+
text,
|
|
3766
|
+
mediaUrl,
|
|
3767
|
+
},
|
|
3768
|
+
mediaLocalRoots,
|
|
3769
|
+
logger: {
|
|
3770
|
+
debug: logDebug,
|
|
3771
|
+
warn: logWarn,
|
|
3772
|
+
},
|
|
3773
|
+
});
|
|
3774
|
+
if (parts.length === 0) {
|
|
3775
|
+
throw new Error("sider sendMedia requires text and/or mediaUrl");
|
|
3776
|
+
}
|
|
3777
|
+
const result = await sendMessageToSider({
|
|
3778
|
+
account,
|
|
3779
|
+
sessionId,
|
|
3780
|
+
parts,
|
|
3781
|
+
});
|
|
3782
|
+
return {
|
|
3783
|
+
channel: SIDER_CHANNEL_ID,
|
|
3784
|
+
messageId: result.messageId,
|
|
3785
|
+
conversationId: result.conversationId,
|
|
3786
|
+
};
|
|
3787
|
+
},
|
|
3788
|
+
},
|
|
3789
|
+
gateway: {
|
|
3790
|
+
startAccount: async (ctx) => {
|
|
3791
|
+
const account = await resolveManagedSiderAccount({
|
|
3792
|
+
cfg: ctx.cfg,
|
|
3793
|
+
accountId: ctx.accountId,
|
|
3794
|
+
});
|
|
3795
|
+
if (!account.configured) {
|
|
3796
|
+
throw new Error(describeSiderAccountConfigurationError(account));
|
|
3797
|
+
}
|
|
3798
|
+
ctx.log?.info(
|
|
3799
|
+
`[${account.accountId}] starting sider relay monitor (${account.gatewayUrl}, relayId=${account.relayId})`,
|
|
3800
|
+
);
|
|
3801
|
+
await runManagedSiderRelayMonitor({
|
|
3802
|
+
cfg: ctx.cfg,
|
|
3803
|
+
account,
|
|
3804
|
+
abortSignal: ctx.abortSignal,
|
|
3805
|
+
log: ctx.log,
|
|
3806
|
+
});
|
|
3807
|
+
ctx.log?.info(`[${account.accountId}] sider relay monitor watcher stopped`);
|
|
3808
|
+
},
|
|
3809
|
+
},
|
|
3810
|
+
auth: {
|
|
3811
|
+
login: async ({ accountId, runtime }) => {
|
|
3812
|
+
const resolvedAccountId = normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID;
|
|
3813
|
+
for (;;) {
|
|
3814
|
+
const pairing = await requestSiderPairing();
|
|
3815
|
+
runtime.log(
|
|
3816
|
+
formatSiderPairingInstructions({
|
|
3817
|
+
pairingCode: pairing.pairingCode,
|
|
3818
|
+
leadLine: "One more step to connect:",
|
|
3819
|
+
}),
|
|
3820
|
+
);
|
|
3821
|
+
const reportPendingUpdate = createSiderPairingPendingUpdateReporter({
|
|
3822
|
+
pairingCode: pairing.pairingCode,
|
|
3823
|
+
report: (message) => {
|
|
3824
|
+
runtime.log(message);
|
|
3825
|
+
},
|
|
3826
|
+
});
|
|
3827
|
+
try {
|
|
3828
|
+
const paired = await waitForSiderPairing({
|
|
3829
|
+
pairing,
|
|
3830
|
+
onPending: reportPendingUpdate,
|
|
3831
|
+
onRetryableError: (message) => {
|
|
3832
|
+
runtime.log(message);
|
|
3833
|
+
},
|
|
3834
|
+
});
|
|
3835
|
+
const latestCfg = loadConfig();
|
|
3836
|
+
const nextCfg = applySiderSetupAccountConfig({
|
|
3837
|
+
cfg: latestCfg,
|
|
3838
|
+
accountId: resolvedAccountId,
|
|
3839
|
+
input: {
|
|
3840
|
+
token: paired.token,
|
|
3841
|
+
},
|
|
3842
|
+
});
|
|
3843
|
+
await writeConfigFile(nextCfg);
|
|
3844
|
+
runtime.log("Connected! You can now chat with me in the browser Side Panel.");
|
|
3845
|
+
return;
|
|
3846
|
+
} catch (error) {
|
|
3847
|
+
if (error instanceof SiderPairingExpiredError) {
|
|
3848
|
+
runtime.log("Pairing code expired.");
|
|
3849
|
+
runtime.log("Generating a new code...");
|
|
3850
|
+
continue;
|
|
3851
|
+
}
|
|
3852
|
+
throw error;
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
},
|
|
3856
|
+
},
|
|
3857
|
+
messaging: {
|
|
3858
|
+
normalizeTarget: normalizeSiderMessagingTarget,
|
|
3859
|
+
targetResolver: {
|
|
3860
|
+
looksLikeId: looksLikeSiderTargetId,
|
|
3861
|
+
hint: "session:<sessionId> (or raw <sessionId>)",
|
|
3862
|
+
},
|
|
3863
|
+
},
|
|
3864
|
+
};
|