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.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
3
|
+
import { resolveDdchatOutboundMediaFields } from "./outbound.js";
|
|
4
|
+
import { getDdchatState, getDdchatWsRuntime } from "./runtime.js";
|
|
5
|
+
import { listDdchatAccountIds, resolveDdchatAccount, resolveDdchatMediaMaxBytes } from "./types.js";
|
|
6
|
+
const DEFAULT_WEBHOOK_BODY_MAX_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
function parseBodyLike(raw) {
|
|
8
|
+
if (!raw) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
|
|
12
|
+
return raw;
|
|
13
|
+
}
|
|
14
|
+
const text = Buffer.isBuffer(raw) ? raw.toString("utf-8") : String(raw);
|
|
15
|
+
if (!text.trim()) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(text);
|
|
20
|
+
return typeof parsed === "object" && parsed ? parsed : {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function readRawBodyFromStream(req, maxBytes) {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let totalBytes = 0;
|
|
29
|
+
for await (const chunk of req) {
|
|
30
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
31
|
+
totalBytes += buffer.length;
|
|
32
|
+
if (totalBytes > maxBytes) {
|
|
33
|
+
throw new Error("webhook body too large");
|
|
34
|
+
}
|
|
35
|
+
chunks.push(buffer);
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
38
|
+
}
|
|
39
|
+
async function readBody(req, maxBytes = DEFAULT_WEBHOOK_BODY_MAX_BYTES) {
|
|
40
|
+
const direct = req.rawBody ?? req.body;
|
|
41
|
+
if (direct !== undefined) {
|
|
42
|
+
if (Buffer.isBuffer(direct) && direct.length > maxBytes) {
|
|
43
|
+
throw new Error("webhook body too large");
|
|
44
|
+
}
|
|
45
|
+
if (typeof direct === "string" && Buffer.byteLength(direct, "utf-8") > maxBytes) {
|
|
46
|
+
throw new Error("webhook body too large");
|
|
47
|
+
}
|
|
48
|
+
return parseBodyLike(direct);
|
|
49
|
+
}
|
|
50
|
+
const rawText = await readRawBodyFromStream(req, maxBytes);
|
|
51
|
+
return parseBodyLike(rawText);
|
|
52
|
+
}
|
|
53
|
+
function resolveInboundIdentity(body) {
|
|
54
|
+
const messageId = String(body.messageId ?? "").trim();
|
|
55
|
+
const chatType = body.chatType === "group" ? "group" : "direct";
|
|
56
|
+
const userId = String(body.userId ?? "").trim();
|
|
57
|
+
const groupId = String(body.groupId ?? "").trim();
|
|
58
|
+
if (!messageId) {
|
|
59
|
+
throw new Error("missing messageId");
|
|
60
|
+
}
|
|
61
|
+
if (!userId) {
|
|
62
|
+
throw new Error("missing userId");
|
|
63
|
+
}
|
|
64
|
+
if (chatType === "group" && !groupId) {
|
|
65
|
+
throw new Error("missing groupId for group chatType");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
messageId,
|
|
69
|
+
chatType,
|
|
70
|
+
userId,
|
|
71
|
+
groupId: groupId || undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function createDdchatMessageId(prefix) {
|
|
75
|
+
return `${prefix}-${randomUUID()}`;
|
|
76
|
+
}
|
|
77
|
+
function estimateBase64DecodedBytes(input) {
|
|
78
|
+
const normalized = input.replace(/\s/g, "");
|
|
79
|
+
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
|
80
|
+
return Math.floor((normalized.length * 3) / 4) - padding;
|
|
81
|
+
}
|
|
82
|
+
function toInboundFiles(body) {
|
|
83
|
+
if (Array.isArray(body.files) && body.files.length > 0) {
|
|
84
|
+
return body.files;
|
|
85
|
+
}
|
|
86
|
+
if (body.mediaUrl) {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
name: body.mediaName,
|
|
90
|
+
type: body.mediaType,
|
|
91
|
+
url: body.mediaUrl,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
async function resolveInboundMedia(params) {
|
|
98
|
+
const out = [];
|
|
99
|
+
const maxBytes = resolveDdchatMediaMaxBytes(params.cfg);
|
|
100
|
+
for (const file of params.files) {
|
|
101
|
+
const name = typeof file.name === "string" ? file.name : undefined;
|
|
102
|
+
const mediaType = (typeof file.type === "string" && file.type.trim()) ||
|
|
103
|
+
(typeof file.mimeType === "string" && file.mimeType.trim()) ||
|
|
104
|
+
undefined;
|
|
105
|
+
const base64 = typeof file.base64 === "string" ? file.base64.trim() : "";
|
|
106
|
+
const url = typeof file.url === "string" ? file.url.trim() : "";
|
|
107
|
+
try {
|
|
108
|
+
let buffer;
|
|
109
|
+
let contentType = mediaType;
|
|
110
|
+
if (base64) {
|
|
111
|
+
if (maxBytes && estimateBase64DecodedBytes(base64) > maxBytes) {
|
|
112
|
+
throw new Error("media exceeds maximum size");
|
|
113
|
+
}
|
|
114
|
+
buffer = Buffer.from(base64, "base64");
|
|
115
|
+
}
|
|
116
|
+
else if (url) {
|
|
117
|
+
const fetched = await params.channelRuntime.media.fetchRemoteMedia({
|
|
118
|
+
url,
|
|
119
|
+
maxBytes,
|
|
120
|
+
});
|
|
121
|
+
buffer = fetched.buffer;
|
|
122
|
+
contentType = fetched.contentType ?? contentType;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const saved = await params.channelRuntime.media.saveMediaBuffer(buffer, contentType, "inbound/ddchat", maxBytes, name);
|
|
128
|
+
out.push({
|
|
129
|
+
path: saved.path,
|
|
130
|
+
contentType: saved.contentType ?? contentType,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
params.logInfo?.(`ddchat media parse failed: ${String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function inferBodyFromMedia(mediaList) {
|
|
140
|
+
const firstType = mediaList[0]?.contentType;
|
|
141
|
+
if (!firstType) {
|
|
142
|
+
return "<media:file>";
|
|
143
|
+
}
|
|
144
|
+
if (firstType.startsWith("image/")) {
|
|
145
|
+
return "<media:image>";
|
|
146
|
+
}
|
|
147
|
+
if (firstType.startsWith("audio/")) {
|
|
148
|
+
return "<media:audio>";
|
|
149
|
+
}
|
|
150
|
+
if (firstType.startsWith("video/")) {
|
|
151
|
+
return "<media:video>";
|
|
152
|
+
}
|
|
153
|
+
return "<media:file>";
|
|
154
|
+
}
|
|
155
|
+
function buildLocalAgentMediaPayload(mediaList) {
|
|
156
|
+
const first = mediaList[0];
|
|
157
|
+
const mediaPaths = mediaList.map((media) => media.path);
|
|
158
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean);
|
|
159
|
+
return {
|
|
160
|
+
MediaPath: first?.path,
|
|
161
|
+
MediaType: first?.contentType ?? undefined,
|
|
162
|
+
MediaUrl: first?.path,
|
|
163
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
164
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
165
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function collectWebhookRoutes(cfg) {
|
|
169
|
+
const byPath = new Map();
|
|
170
|
+
const add = (path, accountId) => {
|
|
171
|
+
const accounts = byPath.get(path) ?? new Set();
|
|
172
|
+
accounts.add(accountId);
|
|
173
|
+
byPath.set(path, accounts);
|
|
174
|
+
};
|
|
175
|
+
byPath.set("/ddchat/webhook", new Set());
|
|
176
|
+
for (const accountId of listDdchatAccountIds(cfg)) {
|
|
177
|
+
const account = resolveDdchatAccount(cfg, accountId);
|
|
178
|
+
add(account.webhookPath ?? "/ddchat/webhook", account.accountId);
|
|
179
|
+
}
|
|
180
|
+
return Array.from(byPath.entries()).map(([path, accounts]) => {
|
|
181
|
+
const accountIds = Array.from(accounts);
|
|
182
|
+
return {
|
|
183
|
+
path,
|
|
184
|
+
fallbackAccountId: accountIds.length === 1 ? accountIds[0] : undefined,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
export function registerDdchatWebhook(api) {
|
|
189
|
+
const cfg = api.runtime.config.loadConfig();
|
|
190
|
+
for (const route of collectWebhookRoutes(cfg)) {
|
|
191
|
+
api.registerHttpRoute({
|
|
192
|
+
path: route.path,
|
|
193
|
+
auth: "plugin",
|
|
194
|
+
handler: async (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
const currentCfg = api.runtime.config.loadConfig();
|
|
197
|
+
const body = await readBody(req);
|
|
198
|
+
const result = await processDdchatInboundWithChannelRuntime({
|
|
199
|
+
channelRuntime: api.runtime.channel,
|
|
200
|
+
cfg: currentCfg,
|
|
201
|
+
body,
|
|
202
|
+
fallbackAccountId: route.fallbackAccountId,
|
|
203
|
+
source: "webhook",
|
|
204
|
+
logInfo: (message) => api.runtime.logging.getChildLogger({ module: "ddchat-inbound" }).info(message),
|
|
205
|
+
});
|
|
206
|
+
res.statusCode = 200;
|
|
207
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
208
|
+
res.end(JSON.stringify(result));
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
res.statusCode = 400;
|
|
212
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
213
|
+
res.end(error instanceof Error ? error.message : "invalid request");
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export async function processDdchatInboundWithChannelRuntime(params) {
|
|
221
|
+
const body = parseBodyLike(params.body);
|
|
222
|
+
const accountId = String(body.accountId ?? params.fallbackAccountId ?? "default").trim() || "default";
|
|
223
|
+
const account = resolveDdchatAccount(params.cfg, accountId);
|
|
224
|
+
if (!account.enabled) {
|
|
225
|
+
throw new Error(`ddchat account '${account.accountId}' is disabled`);
|
|
226
|
+
}
|
|
227
|
+
const identity = resolveInboundIdentity(body);
|
|
228
|
+
if (getDdchatState().dedupe.isDuplicate(account.accountId, identity.messageId)) {
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
duplicate: true,
|
|
232
|
+
messageId: identity.messageId,
|
|
233
|
+
accountId: account.accountId,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const peer = identity.chatType === "group"
|
|
237
|
+
? { kind: "group", id: identity.groupId }
|
|
238
|
+
: { kind: "direct", id: identity.userId };
|
|
239
|
+
const route = params.channelRuntime.routing.resolveAgentRoute({
|
|
240
|
+
cfg: params.cfg,
|
|
241
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
242
|
+
accountId: account.accountId,
|
|
243
|
+
peer,
|
|
244
|
+
});
|
|
245
|
+
const mediaList = await resolveInboundMedia({
|
|
246
|
+
channelRuntime: params.channelRuntime,
|
|
247
|
+
cfg: params.cfg,
|
|
248
|
+
files: toInboundFiles(body),
|
|
249
|
+
logInfo: params.logInfo,
|
|
250
|
+
});
|
|
251
|
+
const mediaPayload = buildLocalAgentMediaPayload(mediaList);
|
|
252
|
+
const inboundText = typeof body.text === "string" && body.text.trim()
|
|
253
|
+
? body.text.trim()
|
|
254
|
+
: mediaList.length > 0
|
|
255
|
+
? inferBodyFromMedia(mediaList)
|
|
256
|
+
: "";
|
|
257
|
+
if (!inboundText) {
|
|
258
|
+
throw new Error("missing text/files");
|
|
259
|
+
}
|
|
260
|
+
const timestamp = Date.now();
|
|
261
|
+
const from = `ddchat:${identity.userId}`;
|
|
262
|
+
const to = identity.chatType === "group" ? `group:${identity.groupId}` : `user:${identity.userId}`;
|
|
263
|
+
const ctxPayload = params.channelRuntime.reply.finalizeInboundContext({
|
|
264
|
+
Body: inboundText,
|
|
265
|
+
BodyForAgent: inboundText,
|
|
266
|
+
RawBody: inboundText,
|
|
267
|
+
CommandBody: inboundText,
|
|
268
|
+
From: from,
|
|
269
|
+
To: to,
|
|
270
|
+
SessionKey: route.sessionKey,
|
|
271
|
+
AccountId: route.accountId,
|
|
272
|
+
ChatType: identity.chatType,
|
|
273
|
+
GroupSubject: identity.chatType === "group" ? identity.groupId : undefined,
|
|
274
|
+
SenderId: identity.userId,
|
|
275
|
+
MessageSid: identity.messageId,
|
|
276
|
+
Timestamp: timestamp,
|
|
277
|
+
Provider: DDCHAT_CHANNEL_ID,
|
|
278
|
+
Surface: DDCHAT_CHANNEL_ID,
|
|
279
|
+
OriginatingChannel: DDCHAT_CHANNEL_ID,
|
|
280
|
+
OriginatingTo: to,
|
|
281
|
+
CommandAuthorized: true,
|
|
282
|
+
...mediaPayload,
|
|
283
|
+
});
|
|
284
|
+
const delivered = [];
|
|
285
|
+
const streamId = `ddchat-stream-${identity.messageId}`;
|
|
286
|
+
let streamText = "";
|
|
287
|
+
const streamMode = account.streamingMode;
|
|
288
|
+
const emitStream = (patch) => {
|
|
289
|
+
const runtime = getDdchatWsRuntime(account.accountId);
|
|
290
|
+
const sent = runtime.send?.({
|
|
291
|
+
type: "stream_chunk",
|
|
292
|
+
streamId,
|
|
293
|
+
accountId: account.accountId,
|
|
294
|
+
source: params.source ?? "ws",
|
|
295
|
+
sessionKey: route.sessionKey,
|
|
296
|
+
agentId: route.agentId,
|
|
297
|
+
mode: streamMode,
|
|
298
|
+
delta: patch.delta ?? "",
|
|
299
|
+
fullText: streamText,
|
|
300
|
+
done: patch.done,
|
|
301
|
+
from: "claw",
|
|
302
|
+
ts: Date.now(),
|
|
303
|
+
}) ?? false;
|
|
304
|
+
if (!sent) {
|
|
305
|
+
params.logInfo?.(`ddchat stream send failed for account ${account.accountId}`);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
await params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
309
|
+
ctx: ctxPayload,
|
|
310
|
+
cfg: params.cfg,
|
|
311
|
+
dispatcherOptions: {
|
|
312
|
+
deliver: async (payload) => {
|
|
313
|
+
const text = payload.text ?? payload.body ?? "";
|
|
314
|
+
const mediaUrl = payload.mediaUrl?.trim() || payload.mediaUrls?.find((u) => u?.trim())?.trim();
|
|
315
|
+
if (!text && !mediaUrl) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const mediaFields = mediaUrl
|
|
319
|
+
? await resolveDdchatOutboundMediaFields(params.cfg, mediaUrl)
|
|
320
|
+
: {};
|
|
321
|
+
const outbound = {
|
|
322
|
+
type: "outbound_message",
|
|
323
|
+
from: "claw",
|
|
324
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
325
|
+
accountId: account.accountId,
|
|
326
|
+
targetType: identity.chatType,
|
|
327
|
+
targetId: identity.chatType === "group" ? identity.groupId : identity.userId,
|
|
328
|
+
text,
|
|
329
|
+
mediaUrl,
|
|
330
|
+
...mediaFields,
|
|
331
|
+
};
|
|
332
|
+
if (!account.streaming || !text) {
|
|
333
|
+
const runtime = getDdchatWsRuntime(account.accountId);
|
|
334
|
+
const sent = runtime.send?.(outbound) ?? false;
|
|
335
|
+
if (!sent) {
|
|
336
|
+
params.logInfo?.(`ddchat outbound send failed for account ${account.accountId}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
delivered.push({
|
|
340
|
+
messageId: createDdchatMessageId("ddchat-out"),
|
|
341
|
+
text,
|
|
342
|
+
});
|
|
343
|
+
if (account.streaming && text) {
|
|
344
|
+
streamText += text;
|
|
345
|
+
emitStream({ delta: text, done: false });
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
onReplyStart: () => {
|
|
349
|
+
params.logInfo?.("ddchat agent reply started");
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
if (account.streaming) {
|
|
354
|
+
emitStream({ done: true });
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
ok: true,
|
|
358
|
+
sessionKey: route.sessionKey,
|
|
359
|
+
agentId: route.agentId,
|
|
360
|
+
deliveredCount: delivered.length,
|
|
361
|
+
delivered,
|
|
362
|
+
};
|
|
363
|
+
}
|
package/src/outbound.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
|
4
|
+
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
5
|
+
import { getDdchatWsRuntime } from "./runtime.js";
|
|
6
|
+
import { resolveDdchatMediaMaxBytes } from "./types.js";
|
|
7
|
+
function createDdchatMessageId(prefix) {
|
|
8
|
+
return `${prefix}-${randomUUID()}`;
|
|
9
|
+
}
|
|
10
|
+
function isLocalFilePath(url) {
|
|
11
|
+
if (url.startsWith("file://"))
|
|
12
|
+
return true;
|
|
13
|
+
if (/^[a-zA-Z]:[\\/]/.test(url))
|
|
14
|
+
return true;
|
|
15
|
+
if (url.startsWith("/") && !url.startsWith("//"))
|
|
16
|
+
return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
function toFileUrl(path) {
|
|
20
|
+
if (path.startsWith("file://"))
|
|
21
|
+
return path;
|
|
22
|
+
return pathToFileURL(path).href;
|
|
23
|
+
}
|
|
24
|
+
export async function resolveDdchatOutboundMediaFields(cfg, mediaUrl) {
|
|
25
|
+
try {
|
|
26
|
+
let resolvedUrl = mediaUrl;
|
|
27
|
+
if (isLocalFilePath(mediaUrl)) {
|
|
28
|
+
resolvedUrl = toFileUrl(mediaUrl);
|
|
29
|
+
}
|
|
30
|
+
console.log(`[ddchat] Resolved media URL: ${resolvedUrl}`);
|
|
31
|
+
const media = await loadWebMedia(resolvedUrl, {
|
|
32
|
+
maxBytes: resolveDdchatMediaMaxBytes(cfg),
|
|
33
|
+
});
|
|
34
|
+
console.log(`[ddchat] Media loaded successfully: ${media.fileName}, type: ${media.contentType}, size: ${media.buffer.length}`);
|
|
35
|
+
return {
|
|
36
|
+
mediaBase64: media.buffer.toString("base64"),
|
|
37
|
+
mediaType: media.contentType,
|
|
38
|
+
mediaName: media.fileName,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
43
|
+
console.error(`[ddchat] Failed to load media from ${mediaUrl}: ${errorMsg}`);
|
|
44
|
+
return { error: errorMsg };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function resolveTarget(to) {
|
|
48
|
+
const normalized = to.trim();
|
|
49
|
+
if (normalized.startsWith("group:")) {
|
|
50
|
+
return { targetType: "group", targetId: normalized.slice("group:".length) };
|
|
51
|
+
}
|
|
52
|
+
if (normalized.startsWith("chat:")) {
|
|
53
|
+
return { targetType: "group", targetId: normalized.slice("chat:".length) };
|
|
54
|
+
}
|
|
55
|
+
if (normalized.startsWith("user:")) {
|
|
56
|
+
return { targetType: "direct", targetId: normalized.slice("user:".length) };
|
|
57
|
+
}
|
|
58
|
+
return { targetType: "direct", targetId: normalized };
|
|
59
|
+
}
|
|
60
|
+
export const ddchatOutbound = {
|
|
61
|
+
deliveryMode: "direct",
|
|
62
|
+
textChunkLimit: 4000,
|
|
63
|
+
chunkerMode: "markdown",
|
|
64
|
+
sendText: async ({ to, text, accountId = "default" }) => {
|
|
65
|
+
const messageId = createDdchatMessageId("ddchat-text");
|
|
66
|
+
const target = resolveTarget(to);
|
|
67
|
+
const runtime = getDdchatWsRuntime(accountId);
|
|
68
|
+
if (!runtime.send || !runtime.connected) {
|
|
69
|
+
throw new Error("DDChat WebSocket not connected");
|
|
70
|
+
}
|
|
71
|
+
const payload = {
|
|
72
|
+
type: "outbound_message",
|
|
73
|
+
from: "claw",
|
|
74
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
75
|
+
accountId,
|
|
76
|
+
messageId,
|
|
77
|
+
targetType: target.targetType,
|
|
78
|
+
targetId: target.targetId,
|
|
79
|
+
text,
|
|
80
|
+
};
|
|
81
|
+
console.log(`[ddchat] Sending text payload:`, JSON.stringify(payload, null, 2));
|
|
82
|
+
const sent = runtime.send(payload);
|
|
83
|
+
if (!sent) {
|
|
84
|
+
throw new Error("Failed to send text message via WebSocket");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
messageId,
|
|
88
|
+
to,
|
|
89
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
90
|
+
text,
|
|
91
|
+
transport: "ws",
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId = "default" }) => {
|
|
95
|
+
const messageId = createDdchatMessageId("ddchat-media");
|
|
96
|
+
const target = resolveTarget(to);
|
|
97
|
+
const runtime = getDdchatWsRuntime(accountId);
|
|
98
|
+
console.log(`[ddchat] sendMedia called - wsConnected: ${runtime.connected}, wsSend available: ${!!runtime.send}`);
|
|
99
|
+
if (!runtime.send || !runtime.connected) {
|
|
100
|
+
throw new Error("DDChat WebSocket not connected");
|
|
101
|
+
}
|
|
102
|
+
console.log(`[ddchat] Loading media from: ${mediaUrl}`);
|
|
103
|
+
const { mediaBase64, mediaType, mediaName, error } = await resolveDdchatOutboundMediaFields(cfg, mediaUrl);
|
|
104
|
+
if (error) {
|
|
105
|
+
console.error(`[ddchat] Media loading failed: ${error}`);
|
|
106
|
+
throw new Error(`Failed to load media: ${error}`);
|
|
107
|
+
}
|
|
108
|
+
if (!mediaBase64) {
|
|
109
|
+
console.error(`[ddchat] No media data available for ${mediaUrl}`);
|
|
110
|
+
throw new Error("No media data available");
|
|
111
|
+
}
|
|
112
|
+
const payload = {
|
|
113
|
+
type: "outbound_message",
|
|
114
|
+
from: "claw",
|
|
115
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
116
|
+
accountId,
|
|
117
|
+
messageId,
|
|
118
|
+
targetType: target.targetType,
|
|
119
|
+
targetId: target.targetId,
|
|
120
|
+
text,
|
|
121
|
+
mediaUrl,
|
|
122
|
+
mediaBase64,
|
|
123
|
+
mediaType,
|
|
124
|
+
mediaName,
|
|
125
|
+
};
|
|
126
|
+
console.log(`[ddchat] Sending media message:`, {
|
|
127
|
+
messageId,
|
|
128
|
+
targetType: target.targetType,
|
|
129
|
+
targetId: target.targetId,
|
|
130
|
+
mediaType,
|
|
131
|
+
mediaName,
|
|
132
|
+
mediaSize: mediaBase64?.length,
|
|
133
|
+
});
|
|
134
|
+
const sent = runtime.send(payload);
|
|
135
|
+
if (!sent) {
|
|
136
|
+
console.error(`[ddchat] Failed to send media message via WebSocket`);
|
|
137
|
+
throw new Error("Failed to send media message via WebSocket");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
messageId,
|
|
141
|
+
to,
|
|
142
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
143
|
+
text,
|
|
144
|
+
mediaUrl,
|
|
145
|
+
mediaType,
|
|
146
|
+
mediaName,
|
|
147
|
+
transport: "ws",
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
package/src/pairing.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
|
2
|
+
export const ddchatPairing = {
|
|
3
|
+
idLabel: "ddchatUserId",
|
|
4
|
+
normalizeAllowEntry: createPairingPrefixStripper(/^(ddchat|user):/i),
|
|
5
|
+
notifyApproval: async ({ runtime, id }) => {
|
|
6
|
+
runtime?.log?.(`[ddchat] pairing approved for ${id}`);
|
|
7
|
+
},
|
|
8
|
+
};
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DdchatDedupeStore } from "./dedupe.js";
|
|
2
|
+
const state = {
|
|
3
|
+
dedupe: new DdchatDedupeStore(),
|
|
4
|
+
wsByAccount: new Map(),
|
|
5
|
+
};
|
|
6
|
+
export function getDdchatState() {
|
|
7
|
+
return state;
|
|
8
|
+
}
|
|
9
|
+
export function getDdchatWsRuntime(accountId) {
|
|
10
|
+
return state.wsByAccount.get(accountId) ?? { connected: false };
|
|
11
|
+
}
|
|
12
|
+
export function setDdchatWsRuntime(params) {
|
|
13
|
+
state.wsByAccount.set(params.accountId, {
|
|
14
|
+
send: params.send,
|
|
15
|
+
connected: params.connected,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function clearDdchatWsRuntime(accountId) {
|
|
19
|
+
state.wsByAccount.delete(accountId);
|
|
20
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function buildDdchatSessionKey(params) {
|
|
2
|
+
const base = params.peerKind === "group"
|
|
3
|
+
? `ddchat:group:${params.peerId}`
|
|
4
|
+
: `ddchat:direct:${params.peerId}`;
|
|
5
|
+
const thread = params.threadId?.trim();
|
|
6
|
+
if (!thread) {
|
|
7
|
+
return base;
|
|
8
|
+
}
|
|
9
|
+
if (params.peerKind === "group" && params.senderId) {
|
|
10
|
+
return `${base}:thread:${thread}:sender:${params.senderId}`;
|
|
11
|
+
}
|
|
12
|
+
return `${base}:thread:${thread}`;
|
|
13
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DDCHAT_CHANNEL_ID, DDCHAT_DEFAULT_ACCOUNT_ID } from "./constants.js";
|
|
2
|
+
export function resolveDdchatMediaMaxBytes(cfg) {
|
|
3
|
+
const mb = cfg.agents?.defaults?.mediaMaxMb;
|
|
4
|
+
if (!mb || mb <= 0) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
return mb * 1024 * 1024;
|
|
8
|
+
}
|
|
9
|
+
function toStringList(input) {
|
|
10
|
+
if (!input) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
return input.map((entry) => String(entry).trim()).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
function normalizeWebhookPath(path) {
|
|
16
|
+
const trimmed = path?.trim();
|
|
17
|
+
return trimmed?.startsWith("/") ? trimmed : "/ddchat/webhook";
|
|
18
|
+
}
|
|
19
|
+
function normalizeConnectionMode(mode) {
|
|
20
|
+
return mode === "webhook" ? "webhook" : "websocket";
|
|
21
|
+
}
|
|
22
|
+
function normalizeStreamingMode(mode) {
|
|
23
|
+
return mode === "token" ? "token" : "chunk";
|
|
24
|
+
}
|
|
25
|
+
function readChannelConfig(cfg) {
|
|
26
|
+
return (cfg.channels?.[DDCHAT_CHANNEL_ID] ?? {});
|
|
27
|
+
}
|
|
28
|
+
function mergeAccountConfig(params) {
|
|
29
|
+
const { root, account } = params;
|
|
30
|
+
return {
|
|
31
|
+
...root,
|
|
32
|
+
...account,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function listDdchatAccountIds(cfg) {
|
|
36
|
+
const channelCfg = readChannelConfig(cfg);
|
|
37
|
+
const keys = Object.keys(channelCfg.accounts ?? {}).filter((key) => key.trim().length > 0);
|
|
38
|
+
return keys.length > 0 ? keys : [DDCHAT_DEFAULT_ACCOUNT_ID];
|
|
39
|
+
}
|
|
40
|
+
export function resolveDefaultDdchatAccountId(cfg) {
|
|
41
|
+
const channelCfg = readChannelConfig(cfg);
|
|
42
|
+
const ids = listDdchatAccountIds(cfg);
|
|
43
|
+
const configured = channelCfg.defaultAccount?.trim();
|
|
44
|
+
return configured && ids.includes(configured) ? configured : ids[0];
|
|
45
|
+
}
|
|
46
|
+
export function resolveDdchatAccount(cfg, accountId) {
|
|
47
|
+
const channelCfg = readChannelConfig(cfg);
|
|
48
|
+
const resolvedAccountId = accountId?.trim() || resolveDefaultDdchatAccountId(cfg);
|
|
49
|
+
const named = channelCfg.accounts?.[resolvedAccountId];
|
|
50
|
+
const merged = mergeAccountConfig({ root: channelCfg, account: named });
|
|
51
|
+
const token = typeof merged.token === "string" ? merged.token.trim() : "";
|
|
52
|
+
const wsUrl = typeof merged.wsUrl === "string" ? merged.wsUrl.trim() : "";
|
|
53
|
+
return {
|
|
54
|
+
accountId: resolvedAccountId,
|
|
55
|
+
enabled: merged.enabled !== false,
|
|
56
|
+
configured: Boolean(token),
|
|
57
|
+
token: token || undefined,
|
|
58
|
+
wsUrl: wsUrl || undefined,
|
|
59
|
+
webhookPath: normalizeWebhookPath(merged.webhookPath),
|
|
60
|
+
webhookPort: typeof merged.webhookPort === "number" && Number.isFinite(merged.webhookPort)
|
|
61
|
+
? merged.webhookPort
|
|
62
|
+
: 3010,
|
|
63
|
+
connectionMode: normalizeConnectionMode(merged.connectionMode),
|
|
64
|
+
dmPolicy: merged.dmPolicy ?? "pairing",
|
|
65
|
+
groupPolicy: merged.groupPolicy ?? "allowlist",
|
|
66
|
+
requireMention: merged.requireMention === true,
|
|
67
|
+
streaming: merged.streaming !== false,
|
|
68
|
+
streamingMode: normalizeStreamingMode(merged.streamingMode),
|
|
69
|
+
allowFrom: toStringList(merged.allowFrom),
|
|
70
|
+
groupAllowFrom: toStringList(merged.groupAllowFrom),
|
|
71
|
+
heartbeatSec: Math.max(15, Number(merged.heartbeatSec ?? 60) || 60),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/CLAUDE.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
|
|
4
|
-
|
|
5
|
-
## 项目概述
|
|
6
|
-
|
|
7
|
-
DDChat 频道插件 (`@ddchat/openclaw-ddchat`) — OpenClaw AI 智能体平台的 DDChat(内部即时通讯)频道集成插件。通过 WebSocket 或 Webhook 连接,将 DDChat 的私聊和群聊消息桥接到 OpenClaw 智能体。
|
|
8
|
-
|
|
9
|
-
## 技术栈
|
|
10
|
-
|
|
11
|
-
- TypeScript,ES 模块 (`"type": "module"`)
|
|
12
|
-
- OpenClaw monorepo 的一部分(使用 `workspace:*` 依赖)
|
|
13
|
-
- OpenClaw 插件 SDK:`openclaw/plugin-sdk/core`、`openclaw/plugin-sdk/setup`、`openclaw/plugin-sdk/channel-pairing`、`openclaw/plugin-sdk/media-runtime`
|
|
14
|
-
|
|
15
|
-
## 开发与测试
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
# 启动模拟 DDChat 服务器(WebSocket 端口 :9001,HTTP 界面端口 :9020)
|
|
19
|
-
node ddchat/test/server.mjs
|
|
20
|
-
|
|
21
|
-
# 将插件安装到 OpenClaw
|
|
22
|
-
openclaw channels add --channel ddchat --token "appId:appSecret"
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
无独立的构建或测试脚本 — 构建由 OpenClaw 工作区统一处理。
|
|
26
|
-
|
|
27
|
-
## 架构
|
|
28
|
-
|
|
29
|
-
插件遵循 OpenClaw 的 `ChatChannelPlugin` 模式,包含以下核心模块:
|
|
30
|
-
|
|
31
|
-
- **`index.ts`** — 插件入口,导出 extensions 和 setup entry
|
|
32
|
-
- **`src/channel.ts`** — 插件定义:能力声明、配置 schema、安装流程、网关、安全策略
|
|
33
|
-
- **`src/gateway.ts`** — WebSocket 生命周期:连接、认证(token 作为查询参数)、心跳 ping/pong、指数退避重连(1s→2s→5s→10s→30s)
|
|
34
|
-
- **`src/inbound.ts`** — 接收消息(WebSocket 或 webhook),去重,解析媒体/文件,路由到 AI 智能体,处理流式响应
|
|
35
|
-
- **`src/outbound.ts`** — 将智能体回复发送回 DDChat;按 4000 字符使用 markdown 感知方式分块;处理媒体附件(base64 或 URL)
|
|
36
|
-
- **`src/types.ts`** — `DdchatResolvedAccount` 配置类型、账户解析/合并逻辑、策略类型
|
|
37
|
-
- **`src/runtime.ts`** — 全局插件状态:去重存储、WebSocket 发送函数
|
|
38
|
-
- **`src/dedupe.ts`** — 基于 TTL 的消息去重(默认 48 小时),带 GC 回收
|
|
39
|
-
- **`src/pairing.ts`** — 用户配对审批流程,ID 标准化(去除前缀)
|
|
40
|
-
- **`src/session.ts`** — 会话 key 构造,用于私聊/群聊路由
|
|
41
|
-
|
|
42
|
-
**数据流:** DDChat → WebSocket/Webhook → inbound.ts(去重、解析、媒体处理)→ OpenClaw 智能体 → outbound.ts(分块、发送)→ DDChat
|
|
43
|
-
|
|
44
|
-
## 关键约定
|
|
45
|
-
|
|
46
|
-
- 出站消息包含 `from: "plugin"` 字段
|
|
47
|
-
- 标识符:From 使用 `ddchat:${userId}`,To 使用 `user:${userId}` / `group:${groupId}`
|
|
48
|
-
- 流式 ID:`ddchat-stream-${messageId}`
|
|
49
|
-
- 连接模式:`websocket`(默认)| `webhook`
|
|
50
|
-
- 访问策略:私聊使用 `open | pairing | allowlist`(默认:pairing);群聊使用 `open | allowlist | disabled`(默认:allowlist)
|
|
51
|
-
- 流式模式:`chunk` | `token`
|