ddchat 0.4.1 → 0.4.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/src/gateway.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { DDCHAT_PLUGIN_WS_BASE_URL } from "./constants.js";
2
+ import { processDdchatInboundWithChannelRuntime } from "./inbound.js";
3
+ import { setDdchatWsRuntime } from "./runtime.js";
4
+ import type { DdchatResolvedAccount } from "./types.js";
5
+
6
+ function createReconnectDelayMs(attempt: number): number {
7
+ const base = [1000, 2000, 5000, 10000, 30000][Math.min(attempt, 4)] ?? 30000;
8
+ const jitter = Math.floor(Math.random() * 300);
9
+ return base + jitter;
10
+ }
11
+
12
+ export const ddchatGateway = {
13
+ startAccount: async (ctx) => {
14
+ ctx.log?.info?.(
15
+ `[ddchat:${ctx.accountId}] start mode=${ctx.account.connectionMode} heartbeat=${ctx.account.heartbeatSec}s stream=${ctx.account.streamingMode}`,
16
+ );
17
+
18
+ if (ctx.account.connectionMode === "webhook") {
19
+ setDdchatWsRuntime({ connected: false, send: undefined });
20
+ ctx.setStatus({
21
+ accountId: ctx.accountId,
22
+ running: true,
23
+ connected: true,
24
+ reconnectAttempts: 0,
25
+ heartbeatSec: ctx.account.heartbeatSec,
26
+ inboundMode: "webhook",
27
+ });
28
+ await new Promise<void>((resolve) => {
29
+ const timer = setInterval(() => {
30
+ if (ctx.abortSignal.aborted) {
31
+ clearInterval(timer);
32
+ resolve();
33
+ }
34
+ }, 1000);
35
+ });
36
+ return;
37
+ }
38
+
39
+ const wsUrl = ctx.account.wsUrl?.trim() || DDCHAT_PLUGIN_WS_BASE_URL;
40
+
41
+ const token = ctx.account.token?.trim();
42
+ if (!token) {
43
+ throw new Error(`ddchat[${ctx.accountId}] missing token (set via channels add --token or config)`);
44
+ }
45
+
46
+ const resolvedWsUrl = appendDdchatAuthQuery(wsUrl, token);
47
+
48
+ let attempts = 0;
49
+ while (!ctx.abortSignal.aborted) {
50
+ attempts += 1;
51
+ const reconnectDelay = createReconnectDelayMs(attempts);
52
+ try {
53
+ await runWsSession({ ctx, wsUrl: resolvedWsUrl, wsUrlForLog: redactDdchatWsUrl(resolvedWsUrl), attempts });
54
+ if (ctx.abortSignal.aborted) {
55
+ break;
56
+ }
57
+ } catch (error) {
58
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws session failed: ${String(error)}`);
59
+ }
60
+ if (ctx.abortSignal.aborted) {
61
+ break;
62
+ }
63
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] reconnecting in ${reconnectDelay}ms`);
64
+ await new Promise<void>((resolve) => setTimeout(resolve, reconnectDelay));
65
+ }
66
+ },
67
+ stopAccount: async (ctx) => {
68
+ setDdchatWsRuntime({ connected: false, send: undefined });
69
+ ctx.setStatus({
70
+ accountId: ctx.accountId,
71
+ running: false,
72
+ connected: false,
73
+ });
74
+ ctx.log?.info?.(`[ddchat:${ctx.accountId}] stopped`);
75
+ },
76
+ };
77
+
78
+ function appendDdchatAuthQuery(wsUrl: string, token: string): string {
79
+ try {
80
+ const url = new URL(wsUrl);
81
+ url.searchParams.set("token", token);
82
+ return url.toString();
83
+ } catch {
84
+ const hasQuery = wsUrl.includes("?");
85
+ const sep = hasQuery ? "&" : "?";
86
+ return `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
87
+ }
88
+ }
89
+
90
+ function redactDdchatWsUrl(wsUrl: string): string {
91
+ try {
92
+ const url = new URL(wsUrl);
93
+ if (url.searchParams.has("token")) {
94
+ url.searchParams.set("token", "(redacted)");
95
+ }
96
+ return url.toString();
97
+ } catch {
98
+ return wsUrl.replace(/([?&]token=)[^&]*/i, "$1(redacted)");
99
+ }
100
+ }
101
+
102
+ async function runWsSession(params: {
103
+ ctx: {
104
+ accountId: string;
105
+ account: DdchatResolvedAccount;
106
+ cfg: Record<string, unknown>;
107
+ channelRuntime?: {
108
+ reply: Record<string, unknown>;
109
+ routing: Record<string, unknown>;
110
+ media: Record<string, unknown>;
111
+ };
112
+ abortSignal: AbortSignal;
113
+ setStatus: (next: Record<string, unknown>) => void;
114
+ log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
115
+ };
116
+ wsUrl: string;
117
+ wsUrlForLog: string;
118
+ attempts: number;
119
+ }): Promise<void> {
120
+ const { ctx, wsUrl, wsUrlForLog, attempts } = params;
121
+ await new Promise<void>((resolve, reject) => {
122
+ const WebSocketCtor = (
123
+ globalThis as unknown as {
124
+ WebSocket?: new (url: string) => {
125
+ readyState: number;
126
+ send: (data: string) => void;
127
+ close: () => void;
128
+ addEventListener: (name: string, fn: (...args: never[]) => void) => void;
129
+ };
130
+ }
131
+ ).WebSocket;
132
+ if (!WebSocketCtor) {
133
+ reject(new Error("WebSocket is not available in this runtime"));
134
+ return;
135
+ }
136
+ const ws = new WebSocketCtor(wsUrl);
137
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
138
+ let settled = false;
139
+ const finish = (fn: () => void) => {
140
+ if (settled) {
141
+ return;
142
+ }
143
+ settled = true;
144
+ if (heartbeatTimer) {
145
+ clearInterval(heartbeatTimer);
146
+ }
147
+ setDdchatWsRuntime({ connected: false, send: undefined });
148
+ fn();
149
+ };
150
+
151
+ ws.addEventListener("open", () => {
152
+ ctx.log?.info?.(`[ddchat:${ctx.accountId}] ws connected -> ${wsUrlForLog}`);
153
+ setDdchatWsRuntime({
154
+ connected: true,
155
+ send: (payload) => {
156
+ if (ws.readyState !== WebSocket.OPEN) {
157
+ return false;
158
+ }
159
+ ws.send(JSON.stringify(payload));
160
+ return true;
161
+ },
162
+ });
163
+ ctx.setStatus({
164
+ accountId: ctx.accountId,
165
+ running: true,
166
+ connected: true,
167
+ reconnectAttempts: attempts - 1,
168
+ heartbeatSec: ctx.account.heartbeatSec,
169
+ inboundMode: "ws",
170
+ wsUrl: wsUrlForLog,
171
+ });
172
+ heartbeatTimer = setInterval(() => {
173
+ if (ws.readyState === WebSocket.OPEN) {
174
+ ws.send(JSON.stringify({ type: "ping", ts: Date.now(), from: "plugin" }));
175
+ }
176
+ }, Math.max(1000, ctx.account.heartbeatSec * 1000));
177
+ });
178
+
179
+ ws.addEventListener("message", async (event) => {
180
+ try {
181
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
182
+ const message = JSON.parse(raw) as Record<string, unknown>;
183
+ if (message.type === "ping") {
184
+ ws.send(JSON.stringify({ type: "pong", ts: Date.now(), from: "plugin" }));
185
+ return;
186
+ }
187
+ if (message.type !== "inbound_message") {
188
+ return;
189
+ }
190
+ if (!ctx.channelRuntime) {
191
+ throw new Error("channelRuntime unavailable in gateway context");
192
+ }
193
+ const result = await processDdchatInboundWithChannelRuntime({
194
+ channelRuntime: ctx.channelRuntime as never,
195
+ cfg: ctx.cfg as never,
196
+ body: message,
197
+ fallbackAccountId: ctx.accountId,
198
+ source: "ws",
199
+ logInfo: (msg) => ctx.log?.info?.(msg),
200
+ });
201
+ if (ws.readyState === WebSocket.OPEN) {
202
+ ws.send(JSON.stringify({ type: "ack", ok: true, from: "plugin", result }));
203
+ }
204
+ } catch (error) {
205
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws message handling failed: ${String(error)}`);
206
+ }
207
+ });
208
+
209
+ ws.addEventListener("error", (event) => {
210
+ ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws error: ${String((event as Event).type)}`);
211
+ });
212
+
213
+ ws.addEventListener("close", () => {
214
+ finish(resolve);
215
+ });
216
+
217
+ if (ctx.abortSignal.aborted) {
218
+ try {
219
+ ws.close();
220
+ } catch {}
221
+ finish(resolve);
222
+ return;
223
+ }
224
+ ctx.abortSignal.addEventListener(
225
+ "abort",
226
+ () => {
227
+ try {
228
+ ws.close();
229
+ } catch (error) {
230
+ reject(error);
231
+ }
232
+ finish(resolve);
233
+ },
234
+ { once: true },
235
+ );
236
+ });
237
+ }
package/src/inbound.ts ADDED
@@ -0,0 +1,394 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
+ import { DDCHAT_CHANNEL_ID } from "./constants.js";
4
+ import { resolveDdchatOutboundMediaFields } from "./outbound.js";
5
+ import { getDdchatState } from "./runtime.js";
6
+ import { resolveDdchatAccount } from "./types.js";
7
+
8
+ type InboundReq = IncomingMessage & { body?: unknown; rawBody?: unknown };
9
+
10
+ type DdchatInboundFile = {
11
+ name?: string;
12
+ type?: string;
13
+ mimeType?: string;
14
+ base64?: string;
15
+ url?: string;
16
+ };
17
+
18
+ type DdchatInboundPayload = {
19
+ accountId?: string;
20
+ messageId?: string;
21
+ chatType?: "direct" | "group";
22
+ userId?: string;
23
+ groupId?: string;
24
+ text?: string;
25
+ files?: DdchatInboundFile[];
26
+ // legacy compatibility fields
27
+ mediaUrl?: string;
28
+ mediaType?: string;
29
+ mediaName?: string;
30
+ type?: "text" | "image" | "file" | "audio";
31
+ };
32
+
33
+ function parseBodyLike(raw: unknown): DdchatInboundPayload {
34
+ if (!raw) {
35
+ return {};
36
+ }
37
+ if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
38
+ return raw as DdchatInboundPayload;
39
+ }
40
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf-8") : String(raw);
41
+ if (!text.trim()) {
42
+ return {};
43
+ }
44
+ try {
45
+ const parsed = JSON.parse(text);
46
+ return typeof parsed === "object" && parsed ? (parsed as DdchatInboundPayload) : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ async function readRawBodyFromStream(req: IncomingMessage): Promise<string> {
53
+ const chunks: Buffer[] = [];
54
+ for await (const chunk of req) {
55
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
56
+ }
57
+ return Buffer.concat(chunks).toString("utf-8");
58
+ }
59
+
60
+ async function readBody(req: InboundReq): Promise<DdchatInboundPayload> {
61
+ const direct = req.rawBody ?? req.body;
62
+ if (direct !== undefined) {
63
+ return parseBodyLike(direct);
64
+ }
65
+ const rawText = await readRawBodyFromStream(req);
66
+ return parseBodyLike(rawText);
67
+ }
68
+
69
+ function resolveMediaFetchMaxBytes(
70
+ cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>,
71
+ ): number | undefined {
72
+ const mb = cfg.agents?.defaults?.mediaMaxMb;
73
+ if (!mb || mb <= 0) {
74
+ return undefined;
75
+ }
76
+ return mb * 1024 * 1024;
77
+ }
78
+
79
+ function resolveInboundIdentity(body: DdchatInboundPayload): {
80
+ messageId: string;
81
+ chatType: "direct" | "group";
82
+ userId: string;
83
+ groupId?: string;
84
+ } {
85
+ const messageId = String(body.messageId ?? "").trim();
86
+ const chatType = body.chatType === "group" ? "group" : "direct";
87
+ const userId = String(body.userId ?? "").trim();
88
+ const groupId = String(body.groupId ?? "").trim();
89
+ if (!messageId) {
90
+ throw new Error("missing messageId");
91
+ }
92
+ if (!userId) {
93
+ throw new Error("missing userId");
94
+ }
95
+ if (chatType === "group" && !groupId) {
96
+ throw new Error("missing groupId for group chatType");
97
+ }
98
+ return {
99
+ messageId,
100
+ chatType,
101
+ userId,
102
+ groupId: groupId || undefined,
103
+ };
104
+ }
105
+
106
+ function toInboundFiles(body: DdchatInboundPayload): DdchatInboundFile[] {
107
+ if (Array.isArray(body.files) && body.files.length > 0) {
108
+ return body.files;
109
+ }
110
+ if (body.mediaUrl) {
111
+ return [
112
+ {
113
+ name: body.mediaName,
114
+ type: body.mediaType,
115
+ url: body.mediaUrl,
116
+ },
117
+ ];
118
+ }
119
+ return [];
120
+ }
121
+
122
+ async function resolveInboundMedia(params: {
123
+ channelRuntime: OpenClawPluginApi["runtime"]["channel"];
124
+ cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>;
125
+ files: DdchatInboundFile[];
126
+ logInfo?: (message: string) => void;
127
+ }): Promise<Array<{ path: string; contentType?: string }>> {
128
+ const out: Array<{ path: string; contentType?: string }> = [];
129
+ const maxBytes = resolveMediaFetchMaxBytes(params.cfg);
130
+
131
+ for (const file of params.files) {
132
+ const name = typeof file.name === "string" ? file.name : undefined;
133
+ const mediaType =
134
+ (typeof file.type === "string" && file.type.trim()) ||
135
+ (typeof file.mimeType === "string" && file.mimeType.trim()) ||
136
+ undefined;
137
+ const base64 = typeof file.base64 === "string" ? file.base64.trim() : "";
138
+ const url = typeof file.url === "string" ? file.url.trim() : "";
139
+ try {
140
+ let buffer: Buffer;
141
+ let contentType = mediaType;
142
+ if (base64) {
143
+ buffer = Buffer.from(base64, "base64");
144
+ } else if (url) {
145
+ const fetched = await params.channelRuntime.media.fetchRemoteMedia({
146
+ url,
147
+ maxBytes,
148
+ });
149
+ buffer = fetched.buffer;
150
+ contentType = fetched.contentType ?? contentType;
151
+ } else {
152
+ continue;
153
+ }
154
+ const saved = await params.channelRuntime.media.saveMediaBuffer(
155
+ buffer,
156
+ contentType,
157
+ "inbound/ddchat",
158
+ maxBytes,
159
+ name,
160
+ );
161
+ out.push({
162
+ path: saved.path,
163
+ contentType: saved.contentType ?? contentType,
164
+ });
165
+ } catch (error) {
166
+ params.logInfo?.(`ddchat media parse failed: ${String(error)}`);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ function inferBodyFromMedia(mediaList: Array<{ contentType?: string }>): string {
173
+ const firstType = mediaList[0]?.contentType;
174
+ if (!firstType) {
175
+ return "<media:file>";
176
+ }
177
+ if (firstType.startsWith("image/")) {
178
+ return "<media:image>";
179
+ }
180
+ if (firstType.startsWith("audio/")) {
181
+ return "<media:audio>";
182
+ }
183
+ if (firstType.startsWith("video/")) {
184
+ return "<media:video>";
185
+ }
186
+ return "<media:file>";
187
+ }
188
+
189
+ function buildLocalAgentMediaPayload(
190
+ mediaList: Array<{ path: string; contentType?: string }>,
191
+ ): {
192
+ MediaPath?: string;
193
+ MediaType?: string;
194
+ MediaUrl?: string;
195
+ MediaPaths?: string[];
196
+ MediaUrls?: string[];
197
+ MediaTypes?: string[];
198
+ } {
199
+ const first = mediaList[0];
200
+ const mediaPaths = mediaList.map((media) => media.path);
201
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
202
+ return {
203
+ MediaPath: first?.path,
204
+ MediaType: first?.contentType ?? undefined,
205
+ MediaUrl: first?.path,
206
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
207
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
208
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
209
+ };
210
+ }
211
+
212
+ export function registerDdchatWebhook(api: OpenClawPluginApi): void {
213
+ api.registerHttpRoute({
214
+ path: "/ddchat/webhook",
215
+ auth: "plugin",
216
+ handler: async (req, res) => {
217
+ try {
218
+ const body = await readBody(req as InboundReq);
219
+ const result = await processDdchatInboundWithChannelRuntime({
220
+ channelRuntime: api.runtime.channel,
221
+ cfg: api.runtime.config.loadConfig(),
222
+ body,
223
+ source: "webhook",
224
+ logInfo: (message) =>
225
+ api.runtime.logging.getChildLogger({ module: "ddchat-inbound" }).info(message),
226
+ });
227
+ res.statusCode = 200;
228
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
229
+ res.end(JSON.stringify(result));
230
+ } catch (error) {
231
+ res.statusCode = 400;
232
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
233
+ res.end(error instanceof Error ? error.message : "invalid request");
234
+ }
235
+ return true;
236
+ },
237
+ });
238
+ }
239
+
240
+ export async function processDdchatInboundWithChannelRuntime(params: {
241
+ channelRuntime: OpenClawPluginApi["runtime"]["channel"];
242
+ cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>;
243
+ body: unknown;
244
+ fallbackAccountId?: string;
245
+ source?: "webhook" | "ws";
246
+ logInfo?: (message: string) => void;
247
+ }): Promise<
248
+ | { ok: true; duplicate: true; messageId: string; accountId: string }
249
+ | { ok: true; sessionKey: string; agentId: string; deliveredCount: number; delivered: Array<{ messageId?: string; text?: string }> }
250
+ > {
251
+ const body = parseBodyLike(params.body);
252
+ const accountId = String(body.accountId ?? params.fallbackAccountId ?? "default").trim() || "default";
253
+ const account = resolveDdchatAccount(params.cfg, accountId);
254
+ const identity = resolveInboundIdentity(body);
255
+
256
+ if (getDdchatState().dedupe.isDuplicate(account.accountId, identity.messageId)) {
257
+ return {
258
+ ok: true,
259
+ duplicate: true,
260
+ messageId: identity.messageId,
261
+ accountId: account.accountId,
262
+ };
263
+ }
264
+
265
+ const peer = identity.chatType === "group"
266
+ ? { kind: "group" as const, id: identity.groupId! }
267
+ : { kind: "direct" as const, id: identity.userId };
268
+ const route = params.channelRuntime.routing.resolveAgentRoute({
269
+ cfg: params.cfg,
270
+ channel: DDCHAT_CHANNEL_ID,
271
+ accountId: account.accountId,
272
+ peer,
273
+ });
274
+
275
+ const mediaList = await resolveInboundMedia({
276
+ channelRuntime: params.channelRuntime,
277
+ cfg: params.cfg,
278
+ files: toInboundFiles(body),
279
+ logInfo: params.logInfo,
280
+ });
281
+ const mediaPayload = buildLocalAgentMediaPayload(mediaList);
282
+ const inboundText = typeof body.text === "string" && body.text.trim()
283
+ ? body.text.trim()
284
+ : mediaList.length > 0
285
+ ? inferBodyFromMedia(mediaList)
286
+ : "";
287
+ if (!inboundText) {
288
+ throw new Error("missing text/files");
289
+ }
290
+
291
+ const timestamp = Date.now();
292
+ const from = `ddchat:${identity.userId}`;
293
+ const to = identity.chatType === "group" ? `group:${identity.groupId}` : `user:${identity.userId}`;
294
+ const ctxPayload = params.channelRuntime.reply.finalizeInboundContext({
295
+ Body: inboundText,
296
+ BodyForAgent: inboundText,
297
+ RawBody: inboundText,
298
+ CommandBody: inboundText,
299
+ From: from,
300
+ To: to,
301
+ SessionKey: route.sessionKey,
302
+ AccountId: route.accountId,
303
+ ChatType: identity.chatType,
304
+ GroupSubject: identity.chatType === "group" ? identity.groupId : undefined,
305
+ SenderId: identity.userId,
306
+ MessageSid: identity.messageId,
307
+ Timestamp: timestamp,
308
+ Provider: DDCHAT_CHANNEL_ID,
309
+ Surface: DDCHAT_CHANNEL_ID,
310
+ OriginatingChannel: DDCHAT_CHANNEL_ID,
311
+ OriginatingTo: to,
312
+ CommandAuthorized: true,
313
+ ...mediaPayload,
314
+ });
315
+
316
+ const delivered: Array<{ messageId?: string; text?: string }> = [];
317
+ const streamId = `ddchat-stream-${identity.messageId}`;
318
+ let streamText = "";
319
+ const streamMode = account.streamingMode;
320
+ const emitStream = (patch: { delta?: string; done: boolean }) => {
321
+ getDdchatState().wsSend?.({
322
+ type: "stream_chunk",
323
+ streamId,
324
+ accountId: account.accountId,
325
+ source: params.source ?? "ws",
326
+ sessionKey: route.sessionKey,
327
+ agentId: route.agentId,
328
+ mode: streamMode,
329
+ delta: patch.delta ?? "",
330
+ fullText: streamText,
331
+ done: patch.done,
332
+ from: "plugin",
333
+ ts: Date.now(),
334
+ });
335
+ };
336
+
337
+ await params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
338
+ ctx: ctxPayload,
339
+ cfg: params.cfg,
340
+ dispatcherOptions: {
341
+ deliver: async (payload: {
342
+ text?: string;
343
+ body?: string;
344
+ mediaUrl?: string;
345
+ mediaUrls?: string[];
346
+ }) => {
347
+ const text = payload.text ?? payload.body ?? "";
348
+ const mediaUrl = payload.mediaUrl?.trim() || payload.mediaUrls?.find((u) => u?.trim())?.trim();
349
+ if (!text && !mediaUrl) {
350
+ return;
351
+ }
352
+ const mediaFields = mediaUrl
353
+ ? await resolveDdchatOutboundMediaFields(params.cfg, mediaUrl)
354
+ : {};
355
+ const outbound = {
356
+ type: "outbound_message",
357
+ from: "plugin",
358
+ channel: DDCHAT_CHANNEL_ID,
359
+ accountId: account.accountId,
360
+ targetType: identity.chatType,
361
+ targetId: identity.chatType === "group" ? identity.groupId : identity.userId,
362
+ text,
363
+ mediaUrl,
364
+ ...mediaFields,
365
+ };
366
+ let sent = false;
367
+ if (!account.streaming || !text) {
368
+ sent = getDdchatState().wsSend?.(outbound) ?? false;
369
+ }
370
+ delivered.push({
371
+ messageId: `ddchat-out-${Date.now()}`,
372
+ text,
373
+ });
374
+ if (account.streaming && text) {
375
+ streamText += text;
376
+ emitStream({ delta: text, done: false });
377
+ }
378
+ },
379
+ onReplyStart: () => {
380
+ params.logInfo?.("ddchat agent reply started");
381
+ },
382
+ },
383
+ });
384
+ if (account.streaming) {
385
+ emitStream({ done: true });
386
+ }
387
+ return {
388
+ ok: true,
389
+ sessionKey: route.sessionKey,
390
+ agentId: route.agentId,
391
+ deliveredCount: delivered.length,
392
+ delivered,
393
+ };
394
+ }