ddchat 0.4.1 → 0.4.3
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 +22 -0
- package/index.js +11 -11
- package/openclaw.plugin.json +148 -148
- package/package.json +36 -36
- package/setup-entry.js +8 -8
- package/src/channel.js +99 -99
- package/src/constants.js +4 -4
- package/src/dedupe.js +44 -44
- package/src/gateway.js +211 -211
- package/src/inbound.js +363 -363
- package/src/outbound.js +150 -150
- package/src/pairing.js +8 -8
- package/src/runtime.js +20 -20
- package/src/session.js +13 -13
- package/src/types.js +73 -73
package/src/inbound.js
CHANGED
|
@@ -1,363 +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
|
-
}
|
|
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
|
+
}
|