ddchat 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{index.ts → index.js} +5 -7
- package/openclaw.plugin.json +137 -3
- package/package.json +6 -6
- package/setup-entry.js +8 -0
- package/src/channel.js +99 -0
- package/src/{constants.ts → constants.js} +0 -1
- package/src/dedupe.js +44 -0
- package/src/gateway.js +211 -0
- package/src/inbound.js +363 -0
- package/src/outbound.js +150 -0
- package/src/pairing.js +8 -0
- package/src/runtime.js +20 -0
- package/src/session.js +13 -0
- package/src/types.js +73 -0
- package/CLAUDE.md +0 -51
- package/OPTIMIZATION.md +0 -129
- package/README.md +0 -22
- package/setup-entry.ts +0 -4
- package/src/channel.ts +0 -101
- package/src/dedupe.ts +0 -51
- package/src/gateway.ts +0 -255
- package/src/inbound.ts +0 -451
- package/src/outbound.ts +0 -167
- package/src/pairing.ts +0 -9
- package/src/runtime.ts +0 -41
- package/src/session.ts +0 -19
- package/src/types.ts +0 -136
- package/task/BLOCKERS.md +0 -3
- package/task/DOING.md +0 -3
- package/task/DONE.md +0 -8
- package/task/README.md +0 -17
- package/task/TODO.md +0 -10
- package/test/README.md +0 -48
- package/test/chat.html +0 -304
- package/test/server.mjs +0 -143
package/src/inbound.ts
DELETED
|
@@ -1,451 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { IncomingMessage } from "node:http";
|
|
3
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
4
|
-
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
5
|
-
import { resolveDdchatOutboundMediaFields } from "./outbound.js";
|
|
6
|
-
import { getDdchatState, getDdchatWsRuntime } from "./runtime.js";
|
|
7
|
-
import { listDdchatAccountIds, resolveDdchatAccount, resolveDdchatMediaMaxBytes } from "./types.js";
|
|
8
|
-
|
|
9
|
-
type InboundReq = IncomingMessage & { body?: unknown; rawBody?: unknown };
|
|
10
|
-
|
|
11
|
-
const DEFAULT_WEBHOOK_BODY_MAX_BYTES = 10 * 1024 * 1024;
|
|
12
|
-
|
|
13
|
-
type DdchatInboundFile = {
|
|
14
|
-
name?: string;
|
|
15
|
-
type?: string;
|
|
16
|
-
mimeType?: string;
|
|
17
|
-
base64?: string;
|
|
18
|
-
url?: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
type DdchatInboundPayload = {
|
|
22
|
-
accountId?: string;
|
|
23
|
-
messageId?: string;
|
|
24
|
-
chatType?: "direct" | "group";
|
|
25
|
-
userId?: string;
|
|
26
|
-
groupId?: string;
|
|
27
|
-
text?: string;
|
|
28
|
-
files?: DdchatInboundFile[];
|
|
29
|
-
// legacy compatibility fields
|
|
30
|
-
mediaUrl?: string;
|
|
31
|
-
mediaType?: string;
|
|
32
|
-
mediaName?: string;
|
|
33
|
-
type?: "text" | "image" | "file" | "audio";
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
function parseBodyLike(raw: unknown): DdchatInboundPayload {
|
|
37
|
-
if (!raw) {
|
|
38
|
-
return {};
|
|
39
|
-
}
|
|
40
|
-
if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
|
|
41
|
-
return raw as DdchatInboundPayload;
|
|
42
|
-
}
|
|
43
|
-
const text = Buffer.isBuffer(raw) ? raw.toString("utf-8") : String(raw);
|
|
44
|
-
if (!text.trim()) {
|
|
45
|
-
return {};
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
const parsed = JSON.parse(text);
|
|
49
|
-
return typeof parsed === "object" && parsed ? (parsed as DdchatInboundPayload) : {};
|
|
50
|
-
} catch {
|
|
51
|
-
return {};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function readRawBodyFromStream(req: IncomingMessage, maxBytes: number): Promise<string> {
|
|
56
|
-
const chunks: Buffer[] = [];
|
|
57
|
-
let totalBytes = 0;
|
|
58
|
-
for await (const chunk of req) {
|
|
59
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
60
|
-
totalBytes += buffer.length;
|
|
61
|
-
if (totalBytes > maxBytes) {
|
|
62
|
-
throw new Error("webhook body too large");
|
|
63
|
-
}
|
|
64
|
-
chunks.push(buffer);
|
|
65
|
-
}
|
|
66
|
-
return Buffer.concat(chunks).toString("utf-8");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function readBody(req: InboundReq, maxBytes = DEFAULT_WEBHOOK_BODY_MAX_BYTES): Promise<DdchatInboundPayload> {
|
|
70
|
-
const direct = req.rawBody ?? req.body;
|
|
71
|
-
if (direct !== undefined) {
|
|
72
|
-
if (Buffer.isBuffer(direct) && direct.length > maxBytes) {
|
|
73
|
-
throw new Error("webhook body too large");
|
|
74
|
-
}
|
|
75
|
-
if (typeof direct === "string" && Buffer.byteLength(direct, "utf-8") > maxBytes) {
|
|
76
|
-
throw new Error("webhook body too large");
|
|
77
|
-
}
|
|
78
|
-
return parseBodyLike(direct);
|
|
79
|
-
}
|
|
80
|
-
const rawText = await readRawBodyFromStream(req, maxBytes);
|
|
81
|
-
return parseBodyLike(rawText);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function resolveInboundIdentity(body: DdchatInboundPayload): {
|
|
85
|
-
messageId: string;
|
|
86
|
-
chatType: "direct" | "group";
|
|
87
|
-
userId: string;
|
|
88
|
-
groupId?: string;
|
|
89
|
-
} {
|
|
90
|
-
const messageId = String(body.messageId ?? "").trim();
|
|
91
|
-
const chatType = body.chatType === "group" ? "group" : "direct";
|
|
92
|
-
const userId = String(body.userId ?? "").trim();
|
|
93
|
-
const groupId = String(body.groupId ?? "").trim();
|
|
94
|
-
if (!messageId) {
|
|
95
|
-
throw new Error("missing messageId");
|
|
96
|
-
}
|
|
97
|
-
if (!userId) {
|
|
98
|
-
throw new Error("missing userId");
|
|
99
|
-
}
|
|
100
|
-
if (chatType === "group" && !groupId) {
|
|
101
|
-
throw new Error("missing groupId for group chatType");
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
messageId,
|
|
105
|
-
chatType,
|
|
106
|
-
userId,
|
|
107
|
-
groupId: groupId || undefined,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function createDdchatMessageId(prefix: string): string {
|
|
112
|
-
return `${prefix}-${randomUUID()}`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function estimateBase64DecodedBytes(input: string): number {
|
|
116
|
-
const normalized = input.replace(/\s/g, "");
|
|
117
|
-
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
|
118
|
-
return Math.floor((normalized.length * 3) / 4) - padding;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function toInboundFiles(body: DdchatInboundPayload): DdchatInboundFile[] {
|
|
122
|
-
if (Array.isArray(body.files) && body.files.length > 0) {
|
|
123
|
-
return body.files;
|
|
124
|
-
}
|
|
125
|
-
if (body.mediaUrl) {
|
|
126
|
-
return [
|
|
127
|
-
{
|
|
128
|
-
name: body.mediaName,
|
|
129
|
-
type: body.mediaType,
|
|
130
|
-
url: body.mediaUrl,
|
|
131
|
-
},
|
|
132
|
-
];
|
|
133
|
-
}
|
|
134
|
-
return [];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function resolveInboundMedia(params: {
|
|
138
|
-
channelRuntime: OpenClawPluginApi["runtime"]["channel"];
|
|
139
|
-
cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>;
|
|
140
|
-
files: DdchatInboundFile[];
|
|
141
|
-
logInfo?: (message: string) => void;
|
|
142
|
-
}): Promise<Array<{ path: string; contentType?: string }>> {
|
|
143
|
-
const out: Array<{ path: string; contentType?: string }> = [];
|
|
144
|
-
const maxBytes = resolveDdchatMediaMaxBytes(params.cfg);
|
|
145
|
-
|
|
146
|
-
for (const file of params.files) {
|
|
147
|
-
const name = typeof file.name === "string" ? file.name : undefined;
|
|
148
|
-
const mediaType =
|
|
149
|
-
(typeof file.type === "string" && file.type.trim()) ||
|
|
150
|
-
(typeof file.mimeType === "string" && file.mimeType.trim()) ||
|
|
151
|
-
undefined;
|
|
152
|
-
const base64 = typeof file.base64 === "string" ? file.base64.trim() : "";
|
|
153
|
-
const url = typeof file.url === "string" ? file.url.trim() : "";
|
|
154
|
-
try {
|
|
155
|
-
let buffer: Buffer;
|
|
156
|
-
let contentType = mediaType;
|
|
157
|
-
if (base64) {
|
|
158
|
-
if (maxBytes && estimateBase64DecodedBytes(base64) > maxBytes) {
|
|
159
|
-
throw new Error("media exceeds maximum size");
|
|
160
|
-
}
|
|
161
|
-
buffer = Buffer.from(base64, "base64");
|
|
162
|
-
} else if (url) {
|
|
163
|
-
const fetched = await params.channelRuntime.media.fetchRemoteMedia({
|
|
164
|
-
url,
|
|
165
|
-
maxBytes,
|
|
166
|
-
});
|
|
167
|
-
buffer = fetched.buffer;
|
|
168
|
-
contentType = fetched.contentType ?? contentType;
|
|
169
|
-
} else {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
const saved = await params.channelRuntime.media.saveMediaBuffer(
|
|
173
|
-
buffer,
|
|
174
|
-
contentType,
|
|
175
|
-
"inbound/ddchat",
|
|
176
|
-
maxBytes,
|
|
177
|
-
name,
|
|
178
|
-
);
|
|
179
|
-
out.push({
|
|
180
|
-
path: saved.path,
|
|
181
|
-
contentType: saved.contentType ?? contentType,
|
|
182
|
-
});
|
|
183
|
-
} catch (error) {
|
|
184
|
-
params.logInfo?.(`ddchat media parse failed: ${String(error)}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return out;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function inferBodyFromMedia(mediaList: Array<{ contentType?: string }>): string {
|
|
191
|
-
const firstType = mediaList[0]?.contentType;
|
|
192
|
-
if (!firstType) {
|
|
193
|
-
return "<media:file>";
|
|
194
|
-
}
|
|
195
|
-
if (firstType.startsWith("image/")) {
|
|
196
|
-
return "<media:image>";
|
|
197
|
-
}
|
|
198
|
-
if (firstType.startsWith("audio/")) {
|
|
199
|
-
return "<media:audio>";
|
|
200
|
-
}
|
|
201
|
-
if (firstType.startsWith("video/")) {
|
|
202
|
-
return "<media:video>";
|
|
203
|
-
}
|
|
204
|
-
return "<media:file>";
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function buildLocalAgentMediaPayload(
|
|
208
|
-
mediaList: Array<{ path: string; contentType?: string }>,
|
|
209
|
-
): {
|
|
210
|
-
MediaPath?: string;
|
|
211
|
-
MediaType?: string;
|
|
212
|
-
MediaUrl?: string;
|
|
213
|
-
MediaPaths?: string[];
|
|
214
|
-
MediaUrls?: string[];
|
|
215
|
-
MediaTypes?: string[];
|
|
216
|
-
} {
|
|
217
|
-
const first = mediaList[0];
|
|
218
|
-
const mediaPaths = mediaList.map((media) => media.path);
|
|
219
|
-
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
220
|
-
return {
|
|
221
|
-
MediaPath: first?.path,
|
|
222
|
-
MediaType: first?.contentType ?? undefined,
|
|
223
|
-
MediaUrl: first?.path,
|
|
224
|
-
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
225
|
-
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
226
|
-
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function collectWebhookRoutes(cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>): Array<{
|
|
231
|
-
path: string;
|
|
232
|
-
fallbackAccountId?: string;
|
|
233
|
-
}> {
|
|
234
|
-
const byPath = new Map<string, Set<string>>();
|
|
235
|
-
const add = (path: string, accountId: string) => {
|
|
236
|
-
const accounts = byPath.get(path) ?? new Set<string>();
|
|
237
|
-
accounts.add(accountId);
|
|
238
|
-
byPath.set(path, accounts);
|
|
239
|
-
};
|
|
240
|
-
byPath.set("/ddchat/webhook", new Set());
|
|
241
|
-
for (const accountId of listDdchatAccountIds(cfg)) {
|
|
242
|
-
const account = resolveDdchatAccount(cfg, accountId);
|
|
243
|
-
add(account.webhookPath ?? "/ddchat/webhook", account.accountId);
|
|
244
|
-
}
|
|
245
|
-
return Array.from(byPath.entries()).map(([path, accounts]) => {
|
|
246
|
-
const accountIds = Array.from(accounts);
|
|
247
|
-
return {
|
|
248
|
-
path,
|
|
249
|
-
fallbackAccountId: accountIds.length === 1 ? accountIds[0] : undefined,
|
|
250
|
-
};
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function registerDdchatWebhook(api: OpenClawPluginApi): void {
|
|
255
|
-
const cfg = api.runtime.config.loadConfig();
|
|
256
|
-
for (const route of collectWebhookRoutes(cfg)) {
|
|
257
|
-
api.registerHttpRoute({
|
|
258
|
-
path: route.path,
|
|
259
|
-
auth: "plugin",
|
|
260
|
-
handler: async (req, res) => {
|
|
261
|
-
try {
|
|
262
|
-
const currentCfg = api.runtime.config.loadConfig();
|
|
263
|
-
const body = await readBody(req as InboundReq);
|
|
264
|
-
const result = await processDdchatInboundWithChannelRuntime({
|
|
265
|
-
channelRuntime: api.runtime.channel,
|
|
266
|
-
cfg: currentCfg,
|
|
267
|
-
body,
|
|
268
|
-
fallbackAccountId: route.fallbackAccountId,
|
|
269
|
-
source: "webhook",
|
|
270
|
-
logInfo: (message) =>
|
|
271
|
-
api.runtime.logging.getChildLogger({ module: "ddchat-inbound" }).info(message),
|
|
272
|
-
});
|
|
273
|
-
res.statusCode = 200;
|
|
274
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
275
|
-
res.end(JSON.stringify(result));
|
|
276
|
-
} catch (error) {
|
|
277
|
-
res.statusCode = 400;
|
|
278
|
-
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
279
|
-
res.end(error instanceof Error ? error.message : "invalid request");
|
|
280
|
-
}
|
|
281
|
-
return true;
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export async function processDdchatInboundWithChannelRuntime(params: {
|
|
288
|
-
channelRuntime: OpenClawPluginApi["runtime"]["channel"];
|
|
289
|
-
cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>;
|
|
290
|
-
body: unknown;
|
|
291
|
-
fallbackAccountId?: string;
|
|
292
|
-
source?: "webhook" | "ws";
|
|
293
|
-
logInfo?: (message: string) => void;
|
|
294
|
-
}): Promise<
|
|
295
|
-
| { ok: true; duplicate: true; messageId: string; accountId: string }
|
|
296
|
-
| { ok: true; sessionKey: string; agentId: string; deliveredCount: number; delivered: Array<{ messageId?: string; text?: string }> }
|
|
297
|
-
> {
|
|
298
|
-
const body = parseBodyLike(params.body);
|
|
299
|
-
const accountId = String(body.accountId ?? params.fallbackAccountId ?? "default").trim() || "default";
|
|
300
|
-
const account = resolveDdchatAccount(params.cfg, accountId);
|
|
301
|
-
if (!account.enabled) {
|
|
302
|
-
throw new Error(`ddchat account '${account.accountId}' is disabled`);
|
|
303
|
-
}
|
|
304
|
-
const identity = resolveInboundIdentity(body);
|
|
305
|
-
|
|
306
|
-
if (getDdchatState().dedupe.isDuplicate(account.accountId, identity.messageId)) {
|
|
307
|
-
return {
|
|
308
|
-
ok: true,
|
|
309
|
-
duplicate: true,
|
|
310
|
-
messageId: identity.messageId,
|
|
311
|
-
accountId: account.accountId,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const peer = identity.chatType === "group"
|
|
316
|
-
? { kind: "group" as const, id: identity.groupId! }
|
|
317
|
-
: { kind: "direct" as const, id: identity.userId };
|
|
318
|
-
const route = params.channelRuntime.routing.resolveAgentRoute({
|
|
319
|
-
cfg: params.cfg,
|
|
320
|
-
channel: DDCHAT_CHANNEL_ID,
|
|
321
|
-
accountId: account.accountId,
|
|
322
|
-
peer,
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const mediaList = await resolveInboundMedia({
|
|
326
|
-
channelRuntime: params.channelRuntime,
|
|
327
|
-
cfg: params.cfg,
|
|
328
|
-
files: toInboundFiles(body),
|
|
329
|
-
logInfo: params.logInfo,
|
|
330
|
-
});
|
|
331
|
-
const mediaPayload = buildLocalAgentMediaPayload(mediaList);
|
|
332
|
-
const inboundText = typeof body.text === "string" && body.text.trim()
|
|
333
|
-
? body.text.trim()
|
|
334
|
-
: mediaList.length > 0
|
|
335
|
-
? inferBodyFromMedia(mediaList)
|
|
336
|
-
: "";
|
|
337
|
-
if (!inboundText) {
|
|
338
|
-
throw new Error("missing text/files");
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const timestamp = Date.now();
|
|
342
|
-
const from = `ddchat:${identity.userId}`;
|
|
343
|
-
const to = identity.chatType === "group" ? `group:${identity.groupId}` : `user:${identity.userId}`;
|
|
344
|
-
const ctxPayload = params.channelRuntime.reply.finalizeInboundContext({
|
|
345
|
-
Body: inboundText,
|
|
346
|
-
BodyForAgent: inboundText,
|
|
347
|
-
RawBody: inboundText,
|
|
348
|
-
CommandBody: inboundText,
|
|
349
|
-
From: from,
|
|
350
|
-
To: to,
|
|
351
|
-
SessionKey: route.sessionKey,
|
|
352
|
-
AccountId: route.accountId,
|
|
353
|
-
ChatType: identity.chatType,
|
|
354
|
-
GroupSubject: identity.chatType === "group" ? identity.groupId : undefined,
|
|
355
|
-
SenderId: identity.userId,
|
|
356
|
-
MessageSid: identity.messageId,
|
|
357
|
-
Timestamp: timestamp,
|
|
358
|
-
Provider: DDCHAT_CHANNEL_ID,
|
|
359
|
-
Surface: DDCHAT_CHANNEL_ID,
|
|
360
|
-
OriginatingChannel: DDCHAT_CHANNEL_ID,
|
|
361
|
-
OriginatingTo: to,
|
|
362
|
-
CommandAuthorized: true,
|
|
363
|
-
...mediaPayload,
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
const delivered: Array<{ messageId?: string; text?: string }> = [];
|
|
367
|
-
const streamId = `ddchat-stream-${identity.messageId}`;
|
|
368
|
-
let streamText = "";
|
|
369
|
-
const streamMode = account.streamingMode;
|
|
370
|
-
const emitStream = (patch: { delta?: string; done: boolean }) => {
|
|
371
|
-
const runtime = getDdchatWsRuntime(account.accountId);
|
|
372
|
-
const sent = runtime.send?.({
|
|
373
|
-
type: "stream_chunk",
|
|
374
|
-
streamId,
|
|
375
|
-
accountId: account.accountId,
|
|
376
|
-
source: params.source ?? "ws",
|
|
377
|
-
sessionKey: route.sessionKey,
|
|
378
|
-
agentId: route.agentId,
|
|
379
|
-
mode: streamMode,
|
|
380
|
-
delta: patch.delta ?? "",
|
|
381
|
-
fullText: streamText,
|
|
382
|
-
done: patch.done,
|
|
383
|
-
from: "claw",
|
|
384
|
-
ts: Date.now(),
|
|
385
|
-
}) ?? false;
|
|
386
|
-
if (!sent) {
|
|
387
|
-
params.logInfo?.(`ddchat stream send failed for account ${account.accountId}`);
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
await params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
392
|
-
ctx: ctxPayload,
|
|
393
|
-
cfg: params.cfg,
|
|
394
|
-
dispatcherOptions: {
|
|
395
|
-
deliver: async (payload: {
|
|
396
|
-
text?: string;
|
|
397
|
-
body?: string;
|
|
398
|
-
mediaUrl?: string;
|
|
399
|
-
mediaUrls?: string[];
|
|
400
|
-
}) => {
|
|
401
|
-
const text = payload.text ?? payload.body ?? "";
|
|
402
|
-
const mediaUrl = payload.mediaUrl?.trim() || payload.mediaUrls?.find((u) => u?.trim())?.trim();
|
|
403
|
-
if (!text && !mediaUrl) {
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const mediaFields = mediaUrl
|
|
407
|
-
? await resolveDdchatOutboundMediaFields(params.cfg, mediaUrl)
|
|
408
|
-
: {};
|
|
409
|
-
const outbound = {
|
|
410
|
-
type: "outbound_message",
|
|
411
|
-
from: "claw",
|
|
412
|
-
channel: DDCHAT_CHANNEL_ID,
|
|
413
|
-
accountId: account.accountId,
|
|
414
|
-
targetType: identity.chatType,
|
|
415
|
-
targetId: identity.chatType === "group" ? identity.groupId : identity.userId,
|
|
416
|
-
text,
|
|
417
|
-
mediaUrl,
|
|
418
|
-
...mediaFields,
|
|
419
|
-
};
|
|
420
|
-
if (!account.streaming || !text) {
|
|
421
|
-
const runtime = getDdchatWsRuntime(account.accountId);
|
|
422
|
-
const sent = runtime.send?.(outbound) ?? false;
|
|
423
|
-
if (!sent) {
|
|
424
|
-
params.logInfo?.(`ddchat outbound send failed for account ${account.accountId}`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
delivered.push({
|
|
428
|
-
messageId: createDdchatMessageId("ddchat-out"),
|
|
429
|
-
text,
|
|
430
|
-
});
|
|
431
|
-
if (account.streaming && text) {
|
|
432
|
-
streamText += text;
|
|
433
|
-
emitStream({ delta: text, done: false });
|
|
434
|
-
}
|
|
435
|
-
},
|
|
436
|
-
onReplyStart: () => {
|
|
437
|
-
params.logInfo?.("ddchat agent reply started");
|
|
438
|
-
},
|
|
439
|
-
},
|
|
440
|
-
});
|
|
441
|
-
if (account.streaming) {
|
|
442
|
-
emitStream({ done: true });
|
|
443
|
-
}
|
|
444
|
-
return {
|
|
445
|
-
ok: true,
|
|
446
|
-
sessionKey: route.sessionKey,
|
|
447
|
-
agentId: route.agentId,
|
|
448
|
-
deliveredCount: delivered.length,
|
|
449
|
-
delivered,
|
|
450
|
-
};
|
|
451
|
-
}
|
package/src/outbound.ts
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
|
4
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
5
|
-
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
6
|
-
import { getDdchatWsRuntime } from "./runtime.js";
|
|
7
|
-
import { resolveDdchatMediaMaxBytes } from "./types.js";
|
|
8
|
-
|
|
9
|
-
function createDdchatMessageId(prefix: string): string {
|
|
10
|
-
return `${prefix}-${randomUUID()}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function isLocalFilePath(url: string): boolean {
|
|
14
|
-
if (url.startsWith("file://")) return true;
|
|
15
|
-
if (/^[a-zA-Z]:[\\/]/.test(url)) return true;
|
|
16
|
-
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function toFileUrl(path: string): string {
|
|
21
|
-
if (path.startsWith("file://")) return path;
|
|
22
|
-
return pathToFileURL(path).href;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function resolveDdchatOutboundMediaFields(
|
|
26
|
-
cfg: OpenClawConfig,
|
|
27
|
-
mediaUrl: string,
|
|
28
|
-
): Promise<{ mediaBase64?: string; mediaType?: string; mediaName?: string; error?: string }> {
|
|
29
|
-
try {
|
|
30
|
-
let resolvedUrl = mediaUrl;
|
|
31
|
-
if (isLocalFilePath(mediaUrl)) {
|
|
32
|
-
resolvedUrl = toFileUrl(mediaUrl);
|
|
33
|
-
}
|
|
34
|
-
console.log(`[ddchat] Resolved media URL: ${resolvedUrl}`);
|
|
35
|
-
|
|
36
|
-
const media = await loadWebMedia(resolvedUrl, {
|
|
37
|
-
maxBytes: resolveDdchatMediaMaxBytes(cfg),
|
|
38
|
-
});
|
|
39
|
-
console.log(`[ddchat] Media loaded successfully: ${media.fileName}, type: ${media.contentType}, size: ${media.buffer.length}`);
|
|
40
|
-
return {
|
|
41
|
-
mediaBase64: media.buffer.toString("base64"),
|
|
42
|
-
mediaType: media.contentType,
|
|
43
|
-
mediaName: media.fileName,
|
|
44
|
-
};
|
|
45
|
-
} catch (error) {
|
|
46
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
47
|
-
console.error(`[ddchat] Failed to load media from ${mediaUrl}: ${errorMsg}`);
|
|
48
|
-
return { error: errorMsg };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function resolveTarget(to: string): { targetType: "group" | "direct"; targetId: string } {
|
|
53
|
-
const normalized = to.trim();
|
|
54
|
-
if (normalized.startsWith("group:")) {
|
|
55
|
-
return { targetType: "group", targetId: normalized.slice("group:".length) };
|
|
56
|
-
}
|
|
57
|
-
if (normalized.startsWith("chat:")) {
|
|
58
|
-
return { targetType: "group", targetId: normalized.slice("chat:".length) };
|
|
59
|
-
}
|
|
60
|
-
if (normalized.startsWith("user:")) {
|
|
61
|
-
return { targetType: "direct", targetId: normalized.slice("user:".length) };
|
|
62
|
-
}
|
|
63
|
-
return { targetType: "direct", targetId: normalized };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export const ddchatOutbound = {
|
|
67
|
-
deliveryMode: "direct",
|
|
68
|
-
textChunkLimit: 4000,
|
|
69
|
-
chunkerMode: "markdown",
|
|
70
|
-
sendText: async ({ to, text, accountId }) => {
|
|
71
|
-
const messageId = createDdchatMessageId("ddchat-text");
|
|
72
|
-
const target = resolveTarget(to);
|
|
73
|
-
|
|
74
|
-
const runtime = getDdchatWsRuntime(accountId);
|
|
75
|
-
if (!runtime.send || !runtime.connected) {
|
|
76
|
-
throw new Error("DDChat WebSocket not connected");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const payload = {
|
|
80
|
-
type: "outbound_message",
|
|
81
|
-
from: "claw",
|
|
82
|
-
channel: DDCHAT_CHANNEL_ID,
|
|
83
|
-
accountId,
|
|
84
|
-
messageId,
|
|
85
|
-
targetType: target.targetType,
|
|
86
|
-
targetId: target.targetId,
|
|
87
|
-
text,
|
|
88
|
-
};
|
|
89
|
-
console.log(`[ddchat] Sending text payload:`, JSON.stringify(payload, null, 2));
|
|
90
|
-
const sent = runtime.send(payload);
|
|
91
|
-
if (!sent) {
|
|
92
|
-
throw new Error("Failed to send text message via WebSocket");
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
messageId,
|
|
96
|
-
to,
|
|
97
|
-
channel: DDCHAT_CHANNEL_ID,
|
|
98
|
-
text,
|
|
99
|
-
transport: "ws",
|
|
100
|
-
};
|
|
101
|
-
},
|
|
102
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
103
|
-
const messageId = createDdchatMessageId("ddchat-media");
|
|
104
|
-
const target = resolveTarget(to);
|
|
105
|
-
|
|
106
|
-
const runtime = getDdchatWsRuntime(accountId);
|
|
107
|
-
console.log(`[ddchat] sendMedia called - wsConnected: ${runtime.connected}, wsSend available: ${!!runtime.send}`);
|
|
108
|
-
if (!runtime.send || !runtime.connected) {
|
|
109
|
-
throw new Error("DDChat WebSocket not connected");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
console.log(`[ddchat] Loading media from: ${mediaUrl}`);
|
|
113
|
-
const { mediaBase64, mediaType, mediaName, error } = await resolveDdchatOutboundMediaFields(
|
|
114
|
-
cfg,
|
|
115
|
-
mediaUrl,
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
if (error) {
|
|
119
|
-
console.error(`[ddchat] Media loading failed: ${error}`);
|
|
120
|
-
throw new Error(`Failed to load media: ${error}`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!mediaBase64) {
|
|
124
|
-
console.error(`[ddchat] No media data available for ${mediaUrl}`);
|
|
125
|
-
throw new Error("No media data available");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const payload = {
|
|
129
|
-
type: "outbound_message",
|
|
130
|
-
from: "claw",
|
|
131
|
-
channel: DDCHAT_CHANNEL_ID,
|
|
132
|
-
accountId,
|
|
133
|
-
messageId,
|
|
134
|
-
targetType: target.targetType,
|
|
135
|
-
targetId: target.targetId,
|
|
136
|
-
text,
|
|
137
|
-
mediaUrl,
|
|
138
|
-
mediaBase64,
|
|
139
|
-
mediaType,
|
|
140
|
-
mediaName,
|
|
141
|
-
};
|
|
142
|
-
console.log(`[ddchat] Sending media message:`, {
|
|
143
|
-
messageId,
|
|
144
|
-
targetType: target.targetType,
|
|
145
|
-
targetId: target.targetId,
|
|
146
|
-
mediaType,
|
|
147
|
-
mediaName,
|
|
148
|
-
mediaSize: mediaBase64?.length,
|
|
149
|
-
});
|
|
150
|
-
const sent = runtime.send(payload);
|
|
151
|
-
if (!sent) {
|
|
152
|
-
console.error(`[ddchat] Failed to send media message via WebSocket`);
|
|
153
|
-
throw new Error("Failed to send media message via WebSocket");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
messageId,
|
|
158
|
-
to,
|
|
159
|
-
channel: DDCHAT_CHANNEL_ID,
|
|
160
|
-
text,
|
|
161
|
-
mediaUrl,
|
|
162
|
-
mediaType,
|
|
163
|
-
mediaName,
|
|
164
|
-
transport: "ws",
|
|
165
|
-
};
|
|
166
|
-
},
|
|
167
|
-
};
|
package/src/pairing.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
|
2
|
-
|
|
3
|
-
export const ddchatPairing = {
|
|
4
|
-
idLabel: "ddchatUserId",
|
|
5
|
-
normalizeAllowEntry: createPairingPrefixStripper(/^(ddchat|user):/i),
|
|
6
|
-
notifyApproval: async ({ runtime, id }) => {
|
|
7
|
-
runtime?.log?.(`[ddchat] pairing approved for ${id}`);
|
|
8
|
-
},
|
|
9
|
-
};
|
package/src/runtime.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { DdchatDedupeStore } from "./dedupe.js";
|
|
2
|
-
|
|
3
|
-
export type DdchatWsSend = (payload: Record<string, unknown>) => boolean;
|
|
4
|
-
|
|
5
|
-
export type DdchatWsRuntime = {
|
|
6
|
-
send?: DdchatWsSend;
|
|
7
|
-
connected: boolean;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export type DdchatPluginRuntimeState = {
|
|
11
|
-
dedupe: DdchatDedupeStore;
|
|
12
|
-
wsByAccount: Map<string, DdchatWsRuntime>;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const state: DdchatPluginRuntimeState = {
|
|
16
|
-
dedupe: new DdchatDedupeStore(),
|
|
17
|
-
wsByAccount: new Map(),
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export function getDdchatState(): DdchatPluginRuntimeState {
|
|
21
|
-
return state;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function getDdchatWsRuntime(accountId: string): DdchatWsRuntime {
|
|
25
|
-
return state.wsByAccount.get(accountId) ?? { connected: false };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function setDdchatWsRuntime(params: {
|
|
29
|
-
accountId: string;
|
|
30
|
-
send?: DdchatWsSend;
|
|
31
|
-
connected: boolean;
|
|
32
|
-
}): void {
|
|
33
|
-
state.wsByAccount.set(params.accountId, {
|
|
34
|
-
send: params.send,
|
|
35
|
-
connected: params.connected,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function clearDdchatWsRuntime(accountId: string): void {
|
|
40
|
-
state.wsByAccount.delete(accountId);
|
|
41
|
-
}
|
package/src/session.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export function buildDdchatSessionKey(params: {
|
|
2
|
-
peerKind: "direct" | "group";
|
|
3
|
-
peerId: string;
|
|
4
|
-
senderId?: string;
|
|
5
|
-
threadId?: string | null;
|
|
6
|
-
}): string {
|
|
7
|
-
const base =
|
|
8
|
-
params.peerKind === "group"
|
|
9
|
-
? `ddchat:group:${params.peerId}`
|
|
10
|
-
: `ddchat:direct:${params.peerId}`;
|
|
11
|
-
const thread = params.threadId?.trim();
|
|
12
|
-
if (!thread) {
|
|
13
|
-
return base;
|
|
14
|
-
}
|
|
15
|
-
if (params.peerKind === "group" && params.senderId) {
|
|
16
|
-
return `${base}:thread:${thread}:sender:${params.senderId}`;
|
|
17
|
-
}
|
|
18
|
-
return `${base}:thread:${thread}`;
|
|
19
|
-
}
|