ddchat 0.1.0
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 +14 -0
- package/index.ts +13 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +36 -0
- package/setup-entry.ts +4 -0
- package/src/channel.ts +101 -0
- package/src/constants.ts +5 -0
- package/src/dedupe.ts +31 -0
- package/src/gateway.ts +237 -0
- package/src/inbound.ts +379 -0
- package/src/outbound.ts +95 -0
- package/src/pairing.ts +9 -0
- package/src/runtime.ts +24 -0
- package/src/session.ts +19 -0
- package/src/types.ts +126 -0
- package/task/BLOCKERS.md +3 -0
- package/task/DOING.md +3 -0
- package/task/DONE.md +8 -0
- package/task/README.md +17 -0
- package/task/TODO.md +10 -0
- package/test/README.md +48 -0
- package/test/chat.html +304 -0
- package/test/server.mjs +143 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
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 { getDdchatState } from "./runtime.js";
|
|
5
|
+
import { resolveDdchatAccount } from "./types.js";
|
|
6
|
+
|
|
7
|
+
type InboundReq = IncomingMessage & { body?: unknown; rawBody?: unknown };
|
|
8
|
+
|
|
9
|
+
type DdchatInboundFile = {
|
|
10
|
+
name?: string;
|
|
11
|
+
type?: string;
|
|
12
|
+
mimeType?: string;
|
|
13
|
+
base64?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type DdchatInboundPayload = {
|
|
18
|
+
accountId?: string;
|
|
19
|
+
messageId?: string;
|
|
20
|
+
chatType?: "direct" | "group";
|
|
21
|
+
userId?: string;
|
|
22
|
+
groupId?: string;
|
|
23
|
+
text?: string;
|
|
24
|
+
files?: DdchatInboundFile[];
|
|
25
|
+
// legacy compatibility fields
|
|
26
|
+
mediaUrl?: string;
|
|
27
|
+
mediaType?: string;
|
|
28
|
+
mediaName?: string;
|
|
29
|
+
type?: "text" | "image" | "file" | "audio";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function parseBodyLike(raw: unknown): DdchatInboundPayload {
|
|
33
|
+
if (!raw) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
|
|
37
|
+
return raw as DdchatInboundPayload;
|
|
38
|
+
}
|
|
39
|
+
const text = Buffer.isBuffer(raw) ? raw.toString("utf-8") : String(raw);
|
|
40
|
+
if (!text.trim()) {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(text);
|
|
45
|
+
return typeof parsed === "object" && parsed ? (parsed as DdchatInboundPayload) : {};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readRawBodyFromStream(req: IncomingMessage): Promise<string> {
|
|
52
|
+
const chunks: Buffer[] = [];
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
55
|
+
}
|
|
56
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readBody(req: InboundReq): Promise<DdchatInboundPayload> {
|
|
60
|
+
const direct = req.rawBody ?? req.body;
|
|
61
|
+
if (direct !== undefined) {
|
|
62
|
+
return parseBodyLike(direct);
|
|
63
|
+
}
|
|
64
|
+
const rawText = await readRawBodyFromStream(req);
|
|
65
|
+
return parseBodyLike(rawText);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveMediaFetchMaxBytes(
|
|
69
|
+
cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>,
|
|
70
|
+
): number | undefined {
|
|
71
|
+
const mb = cfg.agents?.defaults?.mediaMaxMb;
|
|
72
|
+
if (!mb || mb <= 0) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return mb * 1024 * 1024;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveInboundIdentity(body: DdchatInboundPayload): {
|
|
79
|
+
messageId: string;
|
|
80
|
+
chatType: "direct" | "group";
|
|
81
|
+
userId: string;
|
|
82
|
+
groupId?: string;
|
|
83
|
+
} {
|
|
84
|
+
const messageId = String(body.messageId ?? "").trim();
|
|
85
|
+
const chatType = body.chatType === "group" ? "group" : "direct";
|
|
86
|
+
const userId = String(body.userId ?? "").trim();
|
|
87
|
+
const groupId = String(body.groupId ?? "").trim();
|
|
88
|
+
if (!messageId) {
|
|
89
|
+
throw new Error("missing messageId");
|
|
90
|
+
}
|
|
91
|
+
if (!userId) {
|
|
92
|
+
throw new Error("missing userId");
|
|
93
|
+
}
|
|
94
|
+
if (chatType === "group" && !groupId) {
|
|
95
|
+
throw new Error("missing groupId for group chatType");
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
messageId,
|
|
99
|
+
chatType,
|
|
100
|
+
userId,
|
|
101
|
+
groupId: groupId || undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toInboundFiles(body: DdchatInboundPayload): DdchatInboundFile[] {
|
|
106
|
+
if (Array.isArray(body.files) && body.files.length > 0) {
|
|
107
|
+
return body.files;
|
|
108
|
+
}
|
|
109
|
+
if (body.mediaUrl) {
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
name: body.mediaName,
|
|
113
|
+
type: body.mediaType,
|
|
114
|
+
url: body.mediaUrl,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function resolveInboundMedia(params: {
|
|
122
|
+
channelRuntime: OpenClawPluginApi["runtime"]["channel"];
|
|
123
|
+
cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>;
|
|
124
|
+
files: DdchatInboundFile[];
|
|
125
|
+
logInfo?: (message: string) => void;
|
|
126
|
+
}): Promise<Array<{ path: string; contentType?: string }>> {
|
|
127
|
+
const out: Array<{ path: string; contentType?: string }> = [];
|
|
128
|
+
const maxBytes = resolveMediaFetchMaxBytes(params.cfg);
|
|
129
|
+
|
|
130
|
+
for (const file of params.files) {
|
|
131
|
+
const name = typeof file.name === "string" ? file.name : undefined;
|
|
132
|
+
const mediaType =
|
|
133
|
+
(typeof file.type === "string" && file.type.trim()) ||
|
|
134
|
+
(typeof file.mimeType === "string" && file.mimeType.trim()) ||
|
|
135
|
+
undefined;
|
|
136
|
+
const base64 = typeof file.base64 === "string" ? file.base64.trim() : "";
|
|
137
|
+
const url = typeof file.url === "string" ? file.url.trim() : "";
|
|
138
|
+
try {
|
|
139
|
+
let buffer: Buffer;
|
|
140
|
+
let contentType = mediaType;
|
|
141
|
+
if (base64) {
|
|
142
|
+
buffer = Buffer.from(base64, "base64");
|
|
143
|
+
} else if (url) {
|
|
144
|
+
const fetched = await params.channelRuntime.media.fetchRemoteMedia({
|
|
145
|
+
url,
|
|
146
|
+
maxBytes,
|
|
147
|
+
});
|
|
148
|
+
buffer = fetched.buffer;
|
|
149
|
+
contentType = fetched.contentType ?? contentType;
|
|
150
|
+
} else {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const saved = await params.channelRuntime.media.saveMediaBuffer(
|
|
154
|
+
buffer,
|
|
155
|
+
contentType,
|
|
156
|
+
"inbound/ddchat",
|
|
157
|
+
maxBytes,
|
|
158
|
+
name,
|
|
159
|
+
);
|
|
160
|
+
out.push({
|
|
161
|
+
path: saved.path,
|
|
162
|
+
contentType: saved.contentType ?? contentType,
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
params.logInfo?.(`ddchat media parse failed: ${String(error)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function inferBodyFromMedia(mediaList: Array<{ contentType?: string }>): string {
|
|
172
|
+
const firstType = mediaList[0]?.contentType;
|
|
173
|
+
if (!firstType) {
|
|
174
|
+
return "<media:file>";
|
|
175
|
+
}
|
|
176
|
+
if (firstType.startsWith("image/")) {
|
|
177
|
+
return "<media:image>";
|
|
178
|
+
}
|
|
179
|
+
if (firstType.startsWith("audio/")) {
|
|
180
|
+
return "<media:audio>";
|
|
181
|
+
}
|
|
182
|
+
if (firstType.startsWith("video/")) {
|
|
183
|
+
return "<media:video>";
|
|
184
|
+
}
|
|
185
|
+
return "<media:file>";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildLocalAgentMediaPayload(
|
|
189
|
+
mediaList: Array<{ path: string; contentType?: string }>,
|
|
190
|
+
): {
|
|
191
|
+
MediaPath?: string;
|
|
192
|
+
MediaType?: string;
|
|
193
|
+
MediaUrl?: string;
|
|
194
|
+
MediaPaths?: string[];
|
|
195
|
+
MediaUrls?: string[];
|
|
196
|
+
MediaTypes?: string[];
|
|
197
|
+
} {
|
|
198
|
+
const first = mediaList[0];
|
|
199
|
+
const mediaPaths = mediaList.map((media) => media.path);
|
|
200
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
201
|
+
return {
|
|
202
|
+
MediaPath: first?.path,
|
|
203
|
+
MediaType: first?.contentType ?? undefined,
|
|
204
|
+
MediaUrl: first?.path,
|
|
205
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
206
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
207
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function registerDdchatWebhook(api: OpenClawPluginApi): void {
|
|
212
|
+
api.registerHttpRoute({
|
|
213
|
+
path: "/ddchat/webhook",
|
|
214
|
+
auth: "plugin",
|
|
215
|
+
handler: async (req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
const body = await readBody(req as InboundReq);
|
|
218
|
+
const result = await processDdchatInboundWithChannelRuntime({
|
|
219
|
+
channelRuntime: api.runtime.channel,
|
|
220
|
+
cfg: api.runtime.config.loadConfig(),
|
|
221
|
+
body,
|
|
222
|
+
source: "webhook",
|
|
223
|
+
logInfo: (message) =>
|
|
224
|
+
api.runtime.logging.getChildLogger({ module: "ddchat-inbound" }).info(message),
|
|
225
|
+
});
|
|
226
|
+
res.statusCode = 200;
|
|
227
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
228
|
+
res.end(JSON.stringify(result));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
res.statusCode = 400;
|
|
231
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
232
|
+
res.end(error instanceof Error ? error.message : "invalid request");
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function processDdchatInboundWithChannelRuntime(params: {
|
|
240
|
+
channelRuntime: OpenClawPluginApi["runtime"]["channel"];
|
|
241
|
+
cfg: ReturnType<OpenClawPluginApi["runtime"]["config"]["loadConfig"]>;
|
|
242
|
+
body: unknown;
|
|
243
|
+
fallbackAccountId?: string;
|
|
244
|
+
source?: "webhook" | "ws";
|
|
245
|
+
logInfo?: (message: string) => void;
|
|
246
|
+
}): Promise<
|
|
247
|
+
| { ok: true; duplicate: true; messageId: string; accountId: string }
|
|
248
|
+
| { ok: true; sessionKey: string; agentId: string; deliveredCount: number; delivered: Array<{ messageId?: string; text?: string }> }
|
|
249
|
+
> {
|
|
250
|
+
const body = parseBodyLike(params.body);
|
|
251
|
+
const accountId = String(body.accountId ?? params.fallbackAccountId ?? "default").trim() || "default";
|
|
252
|
+
const account = resolveDdchatAccount(params.cfg, accountId);
|
|
253
|
+
const identity = resolveInboundIdentity(body);
|
|
254
|
+
|
|
255
|
+
if (getDdchatState().dedupe.isDuplicate(account.accountId, identity.messageId)) {
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
duplicate: true,
|
|
259
|
+
messageId: identity.messageId,
|
|
260
|
+
accountId: account.accountId,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const peer = identity.chatType === "group"
|
|
265
|
+
? { kind: "group" as const, id: identity.groupId! }
|
|
266
|
+
: { kind: "direct" as const, id: identity.userId };
|
|
267
|
+
const route = params.channelRuntime.routing.resolveAgentRoute({
|
|
268
|
+
cfg: params.cfg,
|
|
269
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
270
|
+
accountId: account.accountId,
|
|
271
|
+
peer,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const mediaList = await resolveInboundMedia({
|
|
275
|
+
channelRuntime: params.channelRuntime,
|
|
276
|
+
cfg: params.cfg,
|
|
277
|
+
files: toInboundFiles(body),
|
|
278
|
+
logInfo: params.logInfo,
|
|
279
|
+
});
|
|
280
|
+
const mediaPayload = buildLocalAgentMediaPayload(mediaList);
|
|
281
|
+
const inboundText = typeof body.text === "string" && body.text.trim()
|
|
282
|
+
? body.text.trim()
|
|
283
|
+
: mediaList.length > 0
|
|
284
|
+
? inferBodyFromMedia(mediaList)
|
|
285
|
+
: "";
|
|
286
|
+
if (!inboundText) {
|
|
287
|
+
throw new Error("missing text/files");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const timestamp = Date.now();
|
|
291
|
+
const from = `ddchat:${identity.userId}`;
|
|
292
|
+
const to = identity.chatType === "group" ? `group:${identity.groupId}` : `user:${identity.userId}`;
|
|
293
|
+
const ctxPayload = params.channelRuntime.reply.finalizeInboundContext({
|
|
294
|
+
Body: inboundText,
|
|
295
|
+
BodyForAgent: inboundText,
|
|
296
|
+
RawBody: inboundText,
|
|
297
|
+
CommandBody: inboundText,
|
|
298
|
+
From: from,
|
|
299
|
+
To: to,
|
|
300
|
+
SessionKey: route.sessionKey,
|
|
301
|
+
AccountId: route.accountId,
|
|
302
|
+
ChatType: identity.chatType,
|
|
303
|
+
GroupSubject: identity.chatType === "group" ? identity.groupId : undefined,
|
|
304
|
+
SenderId: identity.userId,
|
|
305
|
+
MessageSid: identity.messageId,
|
|
306
|
+
Timestamp: timestamp,
|
|
307
|
+
Provider: DDCHAT_CHANNEL_ID,
|
|
308
|
+
Surface: DDCHAT_CHANNEL_ID,
|
|
309
|
+
OriginatingChannel: DDCHAT_CHANNEL_ID,
|
|
310
|
+
OriginatingTo: to,
|
|
311
|
+
CommandAuthorized: true,
|
|
312
|
+
...mediaPayload,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const delivered: Array<{ messageId?: string; text?: string }> = [];
|
|
316
|
+
const streamId = `ddchat-stream-${identity.messageId}`;
|
|
317
|
+
let streamText = "";
|
|
318
|
+
const streamMode = account.streamingMode;
|
|
319
|
+
const emitStream = (patch: { delta?: string; done: boolean }) => {
|
|
320
|
+
getDdchatState().wsSend?.({
|
|
321
|
+
type: "stream_chunk",
|
|
322
|
+
streamId,
|
|
323
|
+
accountId: account.accountId,
|
|
324
|
+
source: params.source ?? "ws",
|
|
325
|
+
sessionKey: route.sessionKey,
|
|
326
|
+
agentId: route.agentId,
|
|
327
|
+
mode: streamMode,
|
|
328
|
+
delta: patch.delta ?? "",
|
|
329
|
+
fullText: streamText,
|
|
330
|
+
done: patch.done,
|
|
331
|
+
from: "plugin",
|
|
332
|
+
ts: Date.now(),
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
await params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
337
|
+
ctx: ctxPayload,
|
|
338
|
+
cfg: params.cfg,
|
|
339
|
+
dispatcherOptions: {
|
|
340
|
+
deliver: async (payload: { text?: string; body?: string; mediaUrl?: string }) => {
|
|
341
|
+
const text = payload.text ?? payload.body ?? "";
|
|
342
|
+
if (!text && !payload.mediaUrl) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const outbound = {
|
|
346
|
+
type: "outbound_message",
|
|
347
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
348
|
+
accountId: account.accountId,
|
|
349
|
+
targetType: identity.chatType,
|
|
350
|
+
targetId: identity.chatType === "group" ? identity.groupId : identity.userId,
|
|
351
|
+
text,
|
|
352
|
+
mediaUrl: payload.mediaUrl,
|
|
353
|
+
};
|
|
354
|
+
const sent = getDdchatState().wsSend?.(outbound) ?? false;
|
|
355
|
+
delivered.push({
|
|
356
|
+
messageId: sent ? `ddchat-out-${Date.now()}` : undefined,
|
|
357
|
+
text,
|
|
358
|
+
});
|
|
359
|
+
if (account.streaming && text) {
|
|
360
|
+
streamText += text;
|
|
361
|
+
emitStream({ delta: text, done: false });
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
onReplyStart: () => {
|
|
365
|
+
params.logInfo?.("ddchat agent reply started");
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
if (account.streaming) {
|
|
370
|
+
emitStream({ done: true });
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
sessionKey: route.sessionKey,
|
|
375
|
+
agentId: route.agentId,
|
|
376
|
+
deliveredCount: delivered.length,
|
|
377
|
+
delivered,
|
|
378
|
+
};
|
|
379
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { loadWebMedia } from "openclaw/plugin-sdk/media-runtime";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
4
|
+
import { getDdchatState } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
function resolveMediaMaxBytes(cfg: OpenClawConfig): number | undefined {
|
|
7
|
+
const mb = cfg.agents?.defaults?.mediaMaxMb;
|
|
8
|
+
if (!mb || mb <= 0) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
return mb * 1024 * 1024;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveTarget(to: string): { targetType: "group" | "direct"; targetId: string } {
|
|
15
|
+
const normalized = to.trim();
|
|
16
|
+
if (normalized.startsWith("group:")) {
|
|
17
|
+
return { targetType: "group", targetId: normalized.slice("group:".length) };
|
|
18
|
+
}
|
|
19
|
+
if (normalized.startsWith("chat:")) {
|
|
20
|
+
return { targetType: "group", targetId: normalized.slice("chat:".length) };
|
|
21
|
+
}
|
|
22
|
+
if (normalized.startsWith("user:")) {
|
|
23
|
+
return { targetType: "direct", targetId: normalized.slice("user:".length) };
|
|
24
|
+
}
|
|
25
|
+
return { targetType: "direct", targetId: normalized };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ddchatOutbound = {
|
|
29
|
+
deliveryMode: "direct",
|
|
30
|
+
textChunkLimit: 4000,
|
|
31
|
+
chunkerMode: "markdown",
|
|
32
|
+
sendText: async ({ to, text, accountId }) => {
|
|
33
|
+
const messageId = `ddchat-text-${Date.now()}`;
|
|
34
|
+
const target = resolveTarget(to);
|
|
35
|
+
const payload = {
|
|
36
|
+
type: "outbound_message",
|
|
37
|
+
from: "plugin",
|
|
38
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
39
|
+
accountId,
|
|
40
|
+
messageId,
|
|
41
|
+
targetType: target.targetType,
|
|
42
|
+
targetId: target.targetId,
|
|
43
|
+
text,
|
|
44
|
+
};
|
|
45
|
+
const sent = getDdchatState().wsSend?.(payload) ?? false;
|
|
46
|
+
return {
|
|
47
|
+
messageId,
|
|
48
|
+
to,
|
|
49
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
50
|
+
text,
|
|
51
|
+
transport: sent ? "ws" : "mock",
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
55
|
+
const messageId = `ddchat-media-${Date.now()}`;
|
|
56
|
+
const target = resolveTarget(to);
|
|
57
|
+
let mediaBase64: string | undefined;
|
|
58
|
+
let mediaType: string | undefined;
|
|
59
|
+
let mediaName: string | undefined;
|
|
60
|
+
try {
|
|
61
|
+
const media = await loadWebMedia(mediaUrl, {
|
|
62
|
+
maxBytes: resolveMediaMaxBytes(cfg),
|
|
63
|
+
});
|
|
64
|
+
mediaBase64 = media.buffer.toString("base64");
|
|
65
|
+
mediaType = media.contentType;
|
|
66
|
+
mediaName = media.fileName;
|
|
67
|
+
} catch {
|
|
68
|
+
// Keep URL-only fallback for remote/local unsupported cases.
|
|
69
|
+
}
|
|
70
|
+
const payload = {
|
|
71
|
+
type: "outbound_message",
|
|
72
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
73
|
+
accountId,
|
|
74
|
+
messageId,
|
|
75
|
+
targetType: target.targetType,
|
|
76
|
+
targetId: target.targetId,
|
|
77
|
+
text,
|
|
78
|
+
mediaUrl,
|
|
79
|
+
mediaBase64,
|
|
80
|
+
mediaType,
|
|
81
|
+
mediaName,
|
|
82
|
+
};
|
|
83
|
+
const sent = getDdchatState().wsSend?.(payload) ?? false;
|
|
84
|
+
return {
|
|
85
|
+
messageId,
|
|
86
|
+
to,
|
|
87
|
+
channel: DDCHAT_CHANNEL_ID,
|
|
88
|
+
text,
|
|
89
|
+
mediaUrl,
|
|
90
|
+
mediaType,
|
|
91
|
+
mediaName,
|
|
92
|
+
transport: sent ? "ws" : "mock",
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DdchatDedupeStore } from "./dedupe.js";
|
|
2
|
+
|
|
3
|
+
export type DdchatPluginRuntimeState = {
|
|
4
|
+
dedupe: DdchatDedupeStore;
|
|
5
|
+
wsSend?: (payload: Record<string, unknown>) => boolean;
|
|
6
|
+
wsConnected: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const state: DdchatPluginRuntimeState = {
|
|
10
|
+
dedupe: new DdchatDedupeStore(),
|
|
11
|
+
wsConnected: false,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function getDdchatState(): DdchatPluginRuntimeState {
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setDdchatWsRuntime(params: {
|
|
19
|
+
send?: (payload: Record<string, unknown>) => boolean;
|
|
20
|
+
connected: boolean;
|
|
21
|
+
}): void {
|
|
22
|
+
state.wsSend = params.send;
|
|
23
|
+
state.wsConnected = params.connected;
|
|
24
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { DDCHAT_CHANNEL_ID, DDCHAT_DEFAULT_ACCOUNT_ID } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export type DdchatStreamingMode = "chunk" | "token";
|
|
5
|
+
export type DdchatConnectionMode = "websocket" | "webhook";
|
|
6
|
+
export type DdchatPolicy = "open" | "pairing" | "allowlist";
|
|
7
|
+
|
|
8
|
+
export type DdchatResolvedAccount = {
|
|
9
|
+
accountId: string;
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
configured: boolean;
|
|
12
|
+
token?: string;
|
|
13
|
+
wsUrl?: string;
|
|
14
|
+
webhookPath?: string;
|
|
15
|
+
webhookPort?: number;
|
|
16
|
+
connectionMode: DdchatConnectionMode;
|
|
17
|
+
dmPolicy: DdchatPolicy;
|
|
18
|
+
groupPolicy: "open" | "allowlist" | "disabled";
|
|
19
|
+
requireMention: boolean;
|
|
20
|
+
streaming: boolean;
|
|
21
|
+
streamingMode: DdchatStreamingMode;
|
|
22
|
+
allowFrom: string[];
|
|
23
|
+
groupAllowFrom: string[];
|
|
24
|
+
heartbeatSec: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type DdchatAccountConfig = {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
token?: string;
|
|
30
|
+
wsUrl?: string;
|
|
31
|
+
webhookPath?: string;
|
|
32
|
+
webhookPort?: number;
|
|
33
|
+
connectionMode?: DdchatConnectionMode;
|
|
34
|
+
dmPolicy?: DdchatPolicy;
|
|
35
|
+
groupPolicy?: "open" | "allowlist" | "disabled";
|
|
36
|
+
requireMention?: boolean;
|
|
37
|
+
streaming?: boolean;
|
|
38
|
+
streamingMode?: DdchatStreamingMode;
|
|
39
|
+
allowFrom?: Array<string | number>;
|
|
40
|
+
groupAllowFrom?: Array<string | number>;
|
|
41
|
+
heartbeatSec?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type DdchatChannelConfig = DdchatAccountConfig & {
|
|
45
|
+
defaultAccount?: string;
|
|
46
|
+
accounts?: Record<string, DdchatAccountConfig | undefined>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function toStringList(input: Array<string | number> | undefined): string[] {
|
|
50
|
+
if (!input) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
return input.map((entry) => String(entry).trim()).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeConnectionMode(mode: string | undefined): DdchatConnectionMode {
|
|
57
|
+
return mode === "webhook" ? "webhook" : "websocket";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeStreamingMode(mode: string | undefined): DdchatStreamingMode {
|
|
61
|
+
return mode === "token" ? "token" : "chunk";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readChannelConfig(cfg: OpenClawConfig): DdchatChannelConfig {
|
|
65
|
+
return ((cfg.channels as Record<string, unknown> | undefined)?.[DDCHAT_CHANNEL_ID] ?? {}) as DdchatChannelConfig;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mergeAccountConfig(params: {
|
|
69
|
+
root: DdchatChannelConfig;
|
|
70
|
+
account?: DdchatAccountConfig;
|
|
71
|
+
}): DdchatAccountConfig {
|
|
72
|
+
const { root, account } = params;
|
|
73
|
+
return {
|
|
74
|
+
...root,
|
|
75
|
+
...account,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function listDdchatAccountIds(cfg: OpenClawConfig): string[] {
|
|
80
|
+
const channelCfg = readChannelConfig(cfg);
|
|
81
|
+
const keys = Object.keys(channelCfg.accounts ?? {}).filter((key) => key.trim().length > 0);
|
|
82
|
+
return keys.length > 0 ? keys : [DDCHAT_DEFAULT_ACCOUNT_ID];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveDefaultDdchatAccountId(cfg: OpenClawConfig): string {
|
|
86
|
+
const channelCfg = readChannelConfig(cfg);
|
|
87
|
+
const ids = listDdchatAccountIds(cfg);
|
|
88
|
+
const configured = channelCfg.defaultAccount?.trim();
|
|
89
|
+
return configured && ids.includes(configured) ? configured : ids[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveDdchatAccount(
|
|
93
|
+
cfg: OpenClawConfig,
|
|
94
|
+
accountId?: string | null,
|
|
95
|
+
): DdchatResolvedAccount {
|
|
96
|
+
const channelCfg = readChannelConfig(cfg);
|
|
97
|
+
const resolvedAccountId = accountId?.trim() || resolveDefaultDdchatAccountId(cfg);
|
|
98
|
+
const named = channelCfg.accounts?.[resolvedAccountId];
|
|
99
|
+
const merged = mergeAccountConfig({ root: channelCfg, account: named });
|
|
100
|
+
const token = typeof merged.token === "string" ? merged.token.trim() : "";
|
|
101
|
+
const wsUrl = typeof merged.wsUrl === "string" ? merged.wsUrl.trim() : "";
|
|
102
|
+
return {
|
|
103
|
+
accountId: resolvedAccountId,
|
|
104
|
+
enabled: merged.enabled !== false,
|
|
105
|
+
configured: Boolean(token),
|
|
106
|
+
token: token || undefined,
|
|
107
|
+
wsUrl: wsUrl || undefined,
|
|
108
|
+
webhookPath:
|
|
109
|
+
typeof merged.webhookPath === "string" && merged.webhookPath.trim()
|
|
110
|
+
? merged.webhookPath.trim()
|
|
111
|
+
: "/ddchat/webhook",
|
|
112
|
+
webhookPort:
|
|
113
|
+
typeof merged.webhookPort === "number" && Number.isFinite(merged.webhookPort)
|
|
114
|
+
? merged.webhookPort
|
|
115
|
+
: 3010,
|
|
116
|
+
connectionMode: normalizeConnectionMode(merged.connectionMode),
|
|
117
|
+
dmPolicy: merged.dmPolicy ?? "pairing",
|
|
118
|
+
groupPolicy: merged.groupPolicy ?? "allowlist",
|
|
119
|
+
requireMention: merged.requireMention === true,
|
|
120
|
+
streaming: merged.streaming !== false,
|
|
121
|
+
streamingMode: normalizeStreamingMode(merged.streamingMode),
|
|
122
|
+
allowFrom: toStringList(merged.allowFrom),
|
|
123
|
+
groupAllowFrom: toStringList(merged.groupAllowFrom),
|
|
124
|
+
heartbeatSec: Math.max(15, Number(merged.heartbeatSec ?? 60) || 60),
|
|
125
|
+
};
|
|
126
|
+
}
|
package/task/BLOCKERS.md
ADDED
package/task/DOING.md
ADDED
package/task/DONE.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# DONE
|
|
2
|
+
|
|
3
|
+
- [x] Created `ddchat/task` workflow files (`README/TODO/DOING/DONE/BLOCKERS`)
|
|
4
|
+
- Date: 2026-04-01
|
|
5
|
+
- Verification: files exist under `ddchat/task/`
|
|
6
|
+
- [x] Initialized DDChat plugin skeleton and websocket-first runtime scaffold
|
|
7
|
+
- Date: 2026-04-01
|
|
8
|
+
- Verification: `ddchat/package.json`, `ddchat/openclaw.plugin.json`, and `ddchat/src/*` core files created
|
package/task/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# DDChat Task Records
|
|
2
|
+
|
|
3
|
+
This directory tracks development progress for the `ddchat` plugin.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
- `TODO.md`: all pending tasks
|
|
8
|
+
- `DOING.md`: the single task currently in progress
|
|
9
|
+
- `DONE.md`: completed tasks with verification notes
|
|
10
|
+
- `BLOCKERS.md`: blockers, decisions, and risks
|
|
11
|
+
|
|
12
|
+
## Workflow
|
|
13
|
+
|
|
14
|
+
1. Move one item from `TODO.md` to `DOING.md` before coding.
|
|
15
|
+
2. When finished, move it to `DONE.md` with date and verification notes.
|
|
16
|
+
3. If blocked, add details to `BLOCKERS.md` and stop starting new work.
|
|
17
|
+
4. Keep `DOING.md` to one active task only.
|