ddchat 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test/chat.html ADDED
@@ -0,0 +1,304 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>DDChat Mock IM Chat</title>
7
+ <style>
8
+ body {
9
+ font-family: Consolas, Menlo, monospace;
10
+ background: #0b1020;
11
+ color: #d4ddff;
12
+ margin: 0;
13
+ }
14
+ .wrap {
15
+ max-width: 1080px;
16
+ margin: 24px auto;
17
+ padding: 0 16px;
18
+ }
19
+ h1 {
20
+ font-size: 18px;
21
+ margin: 0 0 12px;
22
+ }
23
+ .panel {
24
+ background: #121a33;
25
+ border: 1px solid #24315f;
26
+ border-radius: 10px;
27
+ padding: 12px;
28
+ margin-bottom: 12px;
29
+ }
30
+ label {
31
+ font-size: 12px;
32
+ color: #9fb1f1;
33
+ display: block;
34
+ margin-bottom: 4px;
35
+ }
36
+ input,
37
+ textarea,
38
+ button,
39
+ select {
40
+ width: 100%;
41
+ box-sizing: border-box;
42
+ border-radius: 8px;
43
+ border: 1px solid #2f3e74;
44
+ background: #0f1630;
45
+ color: #d4ddff;
46
+ padding: 8px;
47
+ }
48
+ textarea {
49
+ min-height: 80px;
50
+ }
51
+ .row {
52
+ display: grid;
53
+ gap: 8px;
54
+ grid-template-columns: repeat(4, 1fr);
55
+ }
56
+ .row-3 {
57
+ display: grid;
58
+ gap: 8px;
59
+ grid-template-columns: repeat(3, 1fr);
60
+ }
61
+ .log {
62
+ height: 220px;
63
+ overflow: auto;
64
+ background: #090e1f;
65
+ border: 1px solid #24315f;
66
+ border-radius: 8px;
67
+ padding: 8px;
68
+ white-space: pre-wrap;
69
+ line-height: 1.35;
70
+ }
71
+ .btn {
72
+ background: #2442ad;
73
+ cursor: pointer;
74
+ }
75
+ .chat {
76
+ height: 320px;
77
+ overflow: auto;
78
+ background: #090e1f;
79
+ border: 1px solid #24315f;
80
+ border-radius: 8px;
81
+ padding: 10px;
82
+ margin-bottom: 12px;
83
+ }
84
+ .msg {
85
+ margin: 8px 0;
86
+ padding: 8px 10px;
87
+ border-radius: 10px;
88
+ max-width: 86%;
89
+ white-space: pre-wrap;
90
+ }
91
+ .msg.user {
92
+ margin-left: auto;
93
+ background: #1e3b87;
94
+ }
95
+ .msg.assistant {
96
+ margin-right: auto;
97
+ background: #1e2750;
98
+ }
99
+ .msg.done {
100
+ border: 1px solid #3954b3;
101
+ }
102
+ .meta {
103
+ margin-top: 6px;
104
+ font-size: 12px;
105
+ color: #9fb1f1;
106
+ }
107
+ .preview {
108
+ margin-top: 8px;
109
+ border: 1px solid #2f3e74;
110
+ border-radius: 8px;
111
+ padding: 8px;
112
+ background: #0f1630;
113
+ }
114
+ .preview img {
115
+ max-width: 280px;
116
+ max-height: 220px;
117
+ border-radius: 6px;
118
+ display: block;
119
+ }
120
+ </style>
121
+ </head>
122
+ <body>
123
+ <div class="wrap">
124
+ <h1>DDChat Mock IM (WebSocket)</h1>
125
+ <div class="panel row">
126
+ <div>
127
+ <label>Account ID</label>
128
+ <input id="accountId" value="xkx" />
129
+ </div>
130
+ <div>
131
+ <label>Chat Type</label>
132
+ <select id="chatType">
133
+ <option value="direct">direct</option>
134
+ <option value="group">group</option>
135
+ </select>
136
+ </div>
137
+ <div>
138
+ <label>User ID</label>
139
+ <input id="userId" value="u-1001" />
140
+ </div>
141
+ <div>
142
+ <label>Group ID (group 模式必填)</label>
143
+ <input id="groupId" value="g-1001" />
144
+ </div>
145
+ </div>
146
+ <div class="panel row-3">
147
+ <div>
148
+ <label>Message ID</label>
149
+ <input id="messageId" value="" placeholder="默认自动生成" />
150
+ </div>
151
+ <div>
152
+ <label>文件上传(支持多选)</label>
153
+ <input id="fileInput" type="file" multiple />
154
+ </div>
155
+ <div>
156
+ <label>或填远程文件 URL(可选)</label>
157
+ <input id="mediaUrl" placeholder="https://example.com/photo.png" />
158
+ </div>
159
+ </div>
160
+ <div class="panel">
161
+ <label>Text</label>
162
+ <textarea id="text">请介绍一下你自己,并分三段回答。</textarea>
163
+ <button class="btn" id="sendBtn">Send inbound_message</button>
164
+ </div>
165
+ <div class="panel">
166
+ <div id="status">Connecting to ws://127.0.0.1:9001 ...</div>
167
+ <div class="chat" id="chat"></div>
168
+ <div class="log" id="log"></div>
169
+ </div>
170
+ </div>
171
+ <script>
172
+ const ws = new WebSocket("ws://127.0.0.1:9001/?role=ui");
173
+ const log = document.getElementById("log");
174
+ const chat = document.getElementById("chat");
175
+ const status = document.getElementById("status");
176
+ const streamMap = new Map();
177
+ const append = (line) => {
178
+ const now = new Date().toISOString();
179
+ log.textContent += `[${now}] ${line}\n`;
180
+ log.scrollTop = log.scrollHeight;
181
+ };
182
+ const addUserMessage = (payload) => {
183
+ const div = document.createElement("div");
184
+ div.className = "msg user";
185
+ div.textContent = payload.text || "(empty text)";
186
+ const meta = document.createElement("div");
187
+ meta.className = "meta";
188
+ meta.textContent = `account=${payload.accountId} | ${payload.chatType}:${payload.chatType === "group" ? payload.groupId : payload.userId} | files=${payload.files?.length || 0}`;
189
+ div.appendChild(meta);
190
+ chat.appendChild(div);
191
+ chat.scrollTop = chat.scrollHeight;
192
+ };
193
+ const addAssistantMessage = (payload) => {
194
+ const div = document.createElement("div");
195
+ div.className = "msg assistant";
196
+ const text = payload.text || "(no text)";
197
+ div.textContent = text;
198
+ if (payload.mediaUrl) {
199
+ const preview = document.createElement("div");
200
+ preview.className = "preview";
201
+ const link = document.createElement("a");
202
+ link.href = payload.mediaUrl;
203
+ link.target = "_blank";
204
+ link.rel = "noreferrer";
205
+ link.textContent = `media: ${payload.mediaUrl}`;
206
+ link.style.color = "#9bc4ff";
207
+ preview.appendChild(link);
208
+ div.appendChild(preview);
209
+ }
210
+ chat.appendChild(div);
211
+ chat.scrollTop = chat.scrollHeight;
212
+ };
213
+ const upsertAssistantStream = (payload) => {
214
+ const key = payload.streamId || `stream-${Date.now()}`;
215
+ let node = streamMap.get(key);
216
+ if (!node) {
217
+ node = document.createElement("div");
218
+ node.className = "msg assistant";
219
+ node.textContent = "";
220
+ chat.appendChild(node);
221
+ streamMap.set(key, node);
222
+ }
223
+ node.textContent = payload.fullText || node.textContent + (payload.delta || "");
224
+ if (payload.done) {
225
+ node.classList.add("done");
226
+ const meta = document.createElement("div");
227
+ meta.className = "meta";
228
+ meta.textContent = `stream done | mode=${payload.mode || "chunk"}`;
229
+ node.appendChild(meta);
230
+ }
231
+ chat.scrollTop = chat.scrollHeight;
232
+ };
233
+ ws.addEventListener("open", () => {
234
+ status.textContent = "Connected";
235
+ append("connected");
236
+ });
237
+ ws.addEventListener("close", () => {
238
+ status.textContent = "Disconnected";
239
+ append("disconnected");
240
+ });
241
+ ws.addEventListener("message", (ev) => {
242
+ append(`recv: ${ev.data}`);
243
+ try {
244
+ const data = JSON.parse(ev.data);
245
+ const payload = data?.payload || data;
246
+ if (payload?.type === "stream_chunk") {
247
+ upsertAssistantStream(payload);
248
+ }
249
+ if (payload?.type === "outbound_message") {
250
+ addAssistantMessage(payload);
251
+ }
252
+ } catch {}
253
+ });
254
+
255
+ async function fileToBase64(file) {
256
+ const buf = await file.arrayBuffer();
257
+ let binary = "";
258
+ const bytes = new Uint8Array(buf);
259
+ const chunkSize = 0x8000;
260
+ for (let i = 0; i < bytes.length; i += chunkSize) {
261
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
262
+ }
263
+ return btoa(binary);
264
+ }
265
+
266
+ document.getElementById("sendBtn").addEventListener("click", async () => {
267
+ const fileInput = document.getElementById("fileInput");
268
+ const files = [];
269
+ for (const file of Array.from(fileInput.files || [])) {
270
+ files.push({
271
+ name: file.name,
272
+ type: file.type || "application/octet-stream",
273
+ base64: await fileToBase64(file),
274
+ });
275
+ }
276
+ const mediaUrl = document.getElementById("mediaUrl").value || "";
277
+ if (mediaUrl.trim()) {
278
+ files.push({
279
+ name: undefined,
280
+ type: undefined,
281
+ url: mediaUrl.trim(),
282
+ });
283
+ }
284
+
285
+ const payload = {
286
+ type: "inbound_message",
287
+ accountId: document.getElementById("accountId").value || "xkx",
288
+ chatType: document.getElementById("chatType").value === "group" ? "group" : "direct",
289
+ userId: document.getElementById("userId").value || "u-1001",
290
+ groupId: document.getElementById("groupId").value || undefined,
291
+ messageId: document.getElementById("messageId").value || `m-${Date.now()}`,
292
+ text: document.getElementById("text").value || "",
293
+ files: files.length > 0 ? files : undefined,
294
+ };
295
+ if (payload.chatType === "direct") {
296
+ delete payload.groupId;
297
+ }
298
+ addUserMessage(payload);
299
+ ws.send(JSON.stringify(payload));
300
+ append(`send: ${JSON.stringify({ ...payload, files: payload.files ? `[${payload.files.length} file(s)]` : undefined })}`);
301
+ });
302
+ </script>
303
+ </body>
304
+ </html>
@@ -0,0 +1,143 @@
1
+ import http from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { WebSocketServer } from "ws";
4
+
5
+ const HTTP_PORT = Number(process.env.DDCHAT_MOCK_HTTP_PORT ?? 9020);
6
+ const WS_PORT = Number(process.env.DDCHAT_MOCK_WS_PORT ?? 9001);
7
+
8
+ const clients = new Set();
9
+ const uiClients = new Set();
10
+ let pluginClient = null;
11
+ const history = [];
12
+
13
+ const server = http.createServer(async (req, res) => {
14
+ if (!req.url || req.url === "/") {
15
+ try {
16
+ const html = await readFile(new URL("./chat.html", import.meta.url), "utf-8");
17
+ res.statusCode = 200;
18
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
19
+ res.end(html);
20
+ return;
21
+ } catch (error) {
22
+ res.statusCode = 500;
23
+ res.end(String(error));
24
+ return;
25
+ }
26
+ }
27
+ if (req.url === "/history") {
28
+ res.statusCode = 200;
29
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
30
+ res.end(JSON.stringify(history.slice(-300)));
31
+ return;
32
+ }
33
+ res.statusCode = 404;
34
+ res.end("not found");
35
+ });
36
+
37
+ server.listen(HTTP_PORT, () => {
38
+ console.log(`[ddchat-mock] ui: http://127.0.0.1:${HTTP_PORT}`);
39
+ });
40
+
41
+ const wss = new WebSocketServer({ port: WS_PORT });
42
+ wss.on("connection", (ws, req) => {
43
+ clients.add(ws);
44
+ const role = new URL(req.url ?? "/", "http://localhost").searchParams.get("role");
45
+ const isUi = role === "ui";
46
+ if (isUi) {
47
+ uiClients.add(ws);
48
+ console.log("[ddchat-mock] ui connected");
49
+ } else {
50
+ pluginClient = ws;
51
+ console.log("[ddchat-mock] ddchat plugin connected");
52
+ }
53
+ ws.send(
54
+ JSON.stringify({
55
+ type: "hello",
56
+ role: "ddchat-mock",
57
+ ts: Date.now(),
58
+ peerRole: isUi ? "ui" : "plugin",
59
+ }),
60
+ );
61
+ ws.on("close", () => {
62
+ clients.delete(ws);
63
+ uiClients.delete(ws);
64
+ if (pluginClient === ws) {
65
+ pluginClient = null;
66
+ console.log("[ddchat-mock] ddchat plugin disconnected");
67
+ }
68
+ });
69
+ ws.on("message", (buf) => {
70
+ const text = buf.toString("utf-8");
71
+ let payload;
72
+ try {
73
+ payload = JSON.parse(text);
74
+ } catch {
75
+ payload = { type: "raw", text };
76
+ }
77
+ if (uiClients.has(ws)) {
78
+ history.push({ direction: "to_plugin", payload, ts: Date.now() });
79
+ if (pluginClient && pluginClient.readyState === 1) {
80
+ pluginClient.send(JSON.stringify(payload));
81
+ }
82
+ broadcastToUi({
83
+ type: "event",
84
+ event: "to_plugin",
85
+ payload,
86
+ ts: Date.now(),
87
+ });
88
+ return;
89
+ }
90
+ history.push({ direction: "from_plugin", payload, ts: Date.now() });
91
+ broadcastToUi({
92
+ type: "event",
93
+ event: "from_plugin",
94
+ payload,
95
+ ts: Date.now(),
96
+ });
97
+ });
98
+ });
99
+
100
+ console.log(`[ddchat-mock] websocket: ws://127.0.0.1:${WS_PORT}`);
101
+
102
+ function broadcastToUi(data) {
103
+ const text = JSON.stringify(data);
104
+ for (const ws of uiClients) {
105
+ try {
106
+ ws.send(text);
107
+ } catch {}
108
+ }
109
+ }
110
+
111
+ process.stdin.setEncoding("utf-8");
112
+ process.stdin.on("data", (chunk) => {
113
+ const text = chunk.trim();
114
+ if (!text) return;
115
+ if (text === "help") {
116
+ console.log(
117
+ 'stdin example: {"accountId":"xkx","chatType":"direct","userId":"u-1001","messageId":"m-1","text":"hello"}',
118
+ );
119
+ return;
120
+ }
121
+ try {
122
+ const payload = JSON.parse(text);
123
+ const message = {
124
+ type: "inbound_message",
125
+ accountId: payload.accountId ?? "xkx",
126
+ messageId: payload.messageId ?? `m-${Date.now()}`,
127
+ chatType: payload.chatType === "group" ? "group" : "direct",
128
+ userId: payload.userId ?? "u-1001",
129
+ groupId: payload.groupId,
130
+ text: payload.text ?? "",
131
+ files: Array.isArray(payload.files) ? payload.files : undefined,
132
+ ts: Date.now(),
133
+ };
134
+ history.push({ direction: "to_plugin", payload: message, ts: Date.now() });
135
+ broadcastToUi({ type: "event", event: "to_plugin", payload: message, ts: Date.now() });
136
+ if (pluginClient && pluginClient.readyState === 1) {
137
+ pluginClient.send(JSON.stringify(message));
138
+ }
139
+ console.log("[ddchat-mock] sent inbound_message");
140
+ } catch (error) {
141
+ console.error("[ddchat-mock] invalid json:", error);
142
+ }
143
+ });
package/setup-entry.js DELETED
@@ -1,8 +0,0 @@
1
- import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
- import { ddchatPlugin } from "./src/channel.js";
3
- export default defineChannelPluginEntry({
4
- id: "ddchat",
5
- name: "DDChat",
6
- description: "DDChat channel setup plugin",
7
- plugin: ddchatPlugin,
8
- });
package/src/channel.js DELETED
@@ -1,99 +0,0 @@
1
- import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
2
- import { patchScopedAccountConfig, prepareScopedSetupConfig } from "openclaw/plugin-sdk/setup";
3
- import { DDCHAT_CHANNEL_ID } from "./constants.js";
4
- import { ddchatGateway } from "./gateway.js";
5
- import { ddchatOutbound } from "./outbound.js";
6
- import { ddchatPairing } from "./pairing.js";
7
- import { listDdchatAccountIds, resolveDdchatAccount } from "./types.js";
8
- function inspectDdchatAccount(cfg, accountId) {
9
- const account = resolveDdchatAccount(cfg, accountId);
10
- return {
11
- enabled: account.enabled,
12
- configured: account.configured,
13
- tokenStatus: account.token ? "available" : "missing",
14
- connectionMode: account.connectionMode,
15
- dmPolicy: account.dmPolicy,
16
- groupPolicy: account.groupPolicy,
17
- streaming: account.streaming,
18
- streamingMode: account.streamingMode,
19
- };
20
- }
21
- export const ddchatPlugin = createChatChannelPlugin({
22
- base: {
23
- id: DDCHAT_CHANNEL_ID,
24
- meta: {
25
- id: DDCHAT_CHANNEL_ID,
26
- label: "DDChat",
27
- selectionLabel: "DDChat (IM)",
28
- docsPath: "/channels/ddchat",
29
- blurb: "DDChat internal IM integration.",
30
- order: 90,
31
- },
32
- capabilities: {
33
- chatTypes: ["direct", "channel"],
34
- media: true,
35
- threads: false,
36
- polls: false,
37
- },
38
- config: {
39
- listAccountIds: (cfg) => listDdchatAccountIds(cfg),
40
- resolveAccount: (cfg, accountId) => resolveDdchatAccount(cfg, accountId),
41
- inspectAccount: inspectDdchatAccount,
42
- isEnabled: (account) => account.enabled,
43
- isConfigured: (account) => account.configured,
44
- },
45
- setup: {
46
- resolveAccountId: ({ accountId }) => accountId ?? "default",
47
- applyAccountName: ({ cfg, accountId, name }) => prepareScopedSetupConfig({
48
- cfg,
49
- channelKey: DDCHAT_CHANNEL_ID,
50
- accountId,
51
- name,
52
- alwaysUseAccounts: true,
53
- }),
54
- validateInput: ({ input }) => {
55
- const token = typeof input.token === "string" ? input.token.trim() : "";
56
- return token ? null : "ddchat requires --token";
57
- },
58
- applyAccountConfig: ({ cfg, accountId, input }) => {
59
- const token = typeof input.token === "string" ? input.token.trim() : "";
60
- const next = prepareScopedSetupConfig({
61
- cfg,
62
- channelKey: DDCHAT_CHANNEL_ID,
63
- accountId,
64
- name: input.name,
65
- alwaysUseAccounts: true,
66
- });
67
- const patch = {};
68
- if (token) {
69
- patch.token = token;
70
- }
71
- return patchScopedAccountConfig({
72
- cfg: next,
73
- channelKey: DDCHAT_CHANNEL_ID,
74
- accountId,
75
- patch,
76
- accountPatch: patch,
77
- ensureChannelEnabled: true,
78
- ensureAccountEnabled: true,
79
- scopeDefaultToAccounts: true,
80
- });
81
- },
82
- },
83
- gateway: ddchatGateway,
84
- },
85
- pairing: ddchatPairing,
86
- security: {
87
- dm: {
88
- channelKey: DDCHAT_CHANNEL_ID,
89
- resolvePolicy: (account) => account.dmPolicy,
90
- resolveAllowFrom: (account) => account.allowFrom,
91
- defaultPolicy: "pairing",
92
- normalizeEntry: (raw) => raw.replace(/^ddchat:/i, "").trim(),
93
- },
94
- },
95
- outbound: ddchatOutbound,
96
- threading: {
97
- topLevelReplyToMode: "off",
98
- },
99
- });
package/src/dedupe.js DELETED
@@ -1,44 +0,0 @@
1
- const DEFAULT_TTL_MS = 48 * 60 * 60 * 1000;
2
- const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
3
- const DEFAULT_GC_CHECK_INTERVAL = 1000;
4
- export class DdchatDedupeStore {
5
- gcIntervalMs;
6
- gcCheckInterval;
7
- seen = new Map();
8
- ttlMs;
9
- lastGcAt = 0;
10
- checksSinceGc = 0;
11
- constructor(ttlMs = DEFAULT_TTL_MS, gcIntervalMs = DEFAULT_GC_INTERVAL_MS, gcCheckInterval = DEFAULT_GC_CHECK_INTERVAL) {
12
- this.gcIntervalMs = gcIntervalMs;
13
- this.gcCheckInterval = gcCheckInterval;
14
- this.ttlMs = ttlMs;
15
- }
16
- isDuplicate(accountId, messageId) {
17
- const key = `${accountId}:${messageId}`;
18
- const now = Date.now();
19
- this.gcIfNeeded(now);
20
- const expiresAt = this.seen.get(key);
21
- if (expiresAt && expiresAt > now) {
22
- return true;
23
- }
24
- this.seen.set(key, now + this.ttlMs);
25
- return false;
26
- }
27
- gcIfNeeded(now) {
28
- this.checksSinceGc += 1;
29
- if (now - this.lastGcAt < this.gcIntervalMs &&
30
- this.checksSinceGc < this.gcCheckInterval) {
31
- return;
32
- }
33
- this.lastGcAt = now;
34
- this.checksSinceGc = 0;
35
- this.gc(now);
36
- }
37
- gc(now) {
38
- for (const [key, expiresAt] of this.seen.entries()) {
39
- if (expiresAt <= now) {
40
- this.seen.delete(key);
41
- }
42
- }
43
- }
44
- }