@dcrays/dcgchat 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for DCG Chat (WebSocket)",
6
6
  "main": "index.ts",
@@ -16,11 +16,11 @@
16
16
  "devDependencies": {
17
17
  "@types/node": "^22.0.0",
18
18
  "@types/ws": "^8.5.0",
19
- "openclaw": "2026.1.29",
19
+ "openclaw": "2026.2.13",
20
20
  "typescript": "^5.7.0"
21
21
  },
22
22
  "peerDependencies": {
23
- "openclaw": ">=2026.1.29"
23
+ "openclaw": ">=2026.2.13"
24
24
  },
25
25
  "openclaw": {
26
26
  "extensions": [
package/src/bot.ts CHANGED
@@ -1,9 +1,97 @@
1
+ import path from "node:path";
1
2
  import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
3
  import { createReplyPrefixContext } from "openclaw/plugin-sdk";
3
4
  import type { InboundMessage, OutboundReply } from "./types.js";
4
5
  import { getDcgchatRuntime } from "./runtime.js";
5
6
  import { resolveAccount } from "./channel.js";
6
7
 
8
+ type MediaInfo = {
9
+ path: string;
10
+ contentType: string;
11
+ placeholder: string;
12
+ };
13
+
14
+ async function resolveMediaFromUrls(
15
+ fileUrls: string[],
16
+ maxBytes: number,
17
+ log?: (msg: string) => void,
18
+ ): Promise<MediaInfo[]> {
19
+ const core = getDcgchatRuntime();
20
+ const out: MediaInfo[] = [];
21
+
22
+ log?.(`dcgchat media: starting resolve for ${fileUrls.length} file(s): ${JSON.stringify(fileUrls)}`);
23
+
24
+ for (let i = 0; i < fileUrls.length; i++) {
25
+ const url = fileUrls[i];
26
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] fetching ${url}`);
27
+ try {
28
+ const response = await fetch(url);
29
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] fetch response status=${response.status}, content-type=${response.headers.get("content-type")}, content-length=${response.headers.get("content-length")}`);
30
+ if (!response.ok) {
31
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] fetch failed with HTTP ${response.status}, skipping`);
32
+ continue;
33
+ }
34
+ const buffer = Buffer.from(await response.arrayBuffer());
35
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] downloaded buffer size=${buffer.length} bytes`);
36
+
37
+ let contentType = response.headers.get("content-type") || "";
38
+ if (!contentType) {
39
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] no content-type header, detecting mime...`);
40
+ contentType = await core.media.detectMime({ buffer });
41
+ }
42
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] resolved contentType=${contentType}`);
43
+
44
+ const fileName = path.basename(new URL(url).pathname) || "file";
45
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] fileName=${fileName}, saving to disk (maxBytes=${maxBytes})...`);
46
+
47
+ const saved = await core.channel.media.saveMediaBuffer(
48
+ buffer,
49
+ contentType,
50
+ "inbound",
51
+ maxBytes,
52
+ fileName,
53
+ );
54
+
55
+ const isImage = contentType.startsWith("image/");
56
+ out.push({
57
+ path: saved.path,
58
+ contentType: saved.contentType,
59
+ placeholder: isImage ? "<media:image>" : "<media:file>",
60
+ });
61
+
62
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] saved to ${saved.path} (contentType=${saved.contentType}, isImage=${isImage})`);
63
+ } catch (err) {
64
+ log?.(`dcgchat media: [${i + 1}/${fileUrls.length}] FAILED to process ${url}: ${String(err)}`);
65
+ }
66
+ }
67
+
68
+ log?.(`dcgchat media: resolve complete, ${out.length}/${fileUrls.length} file(s) succeeded`);
69
+
70
+ return out;
71
+ }
72
+
73
+ function buildMediaPayload(mediaList: MediaInfo[]): {
74
+ MediaPath?: string;
75
+ MediaType?: string;
76
+ MediaUrl?: string;
77
+ MediaPaths?: string[];
78
+ MediaUrls?: string[];
79
+ MediaTypes?: string[];
80
+ } {
81
+ if (mediaList.length === 0) return {};
82
+ const first = mediaList[0];
83
+ const mediaPaths = mediaList.map((m) => m.path);
84
+ const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean);
85
+ return {
86
+ MediaPath: first?.path,
87
+ MediaType: first?.contentType,
88
+ MediaUrl: first?.path,
89
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
90
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
91
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
92
+ };
93
+ }
94
+
7
95
  /**
8
96
  * 处理一条用户消息,调用 Agent 并返回回复
9
97
  */
@@ -12,7 +100,8 @@ export async function handleDcgchatMessage(params: {
12
100
  msg: InboundMessage;
13
101
  accountId: string;
14
102
  runtime?: RuntimeEnv;
15
- }): Promise<OutboundReply> {
103
+ onChunk: (reply: OutboundReply) => void;
104
+ }): Promise<void> {
16
105
  const { cfg, msg, accountId, runtime } = params;
17
106
  const log = runtime?.log ?? console.log;
18
107
  const error = runtime?.error ?? console.error;
@@ -23,7 +112,18 @@ export async function handleDcgchatMessage(params: {
23
112
  const text = msg.content.text?.trim();
24
113
 
25
114
  if (!userId || !text) {
26
- return { type: "reply", userId: userId || "unknown", text: "[错误] 消息格式不正确" };
115
+ params.onChunk({
116
+ messageType: "openclaw_bot_chat",
117
+ _userId: msg._userId,
118
+ source: "client",
119
+ content: {
120
+ bot_token: msg.content.bot_token,
121
+ session_id: msg.content.session_id,
122
+ message_id: msg.content.message_id,
123
+ response: "[错误] 消息格式不正确",
124
+ },
125
+ });
126
+ return;
27
127
  }
28
128
 
29
129
  try {
@@ -36,8 +136,21 @@ export async function handleDcgchatMessage(params: {
36
136
  peer: { kind: "direct", id: userId },
37
137
  });
38
138
 
139
+ // 处理用户上传的文件
140
+ const fileUrls = msg.content.file_urls ?? [];
141
+ log(`dcgchat[${accountId}]: incoming message from user=${userId}, text="${text?.slice(0, 80)}", file_urls count=${fileUrls.length}`);
142
+ let mediaPayload: Record<string, unknown> = {};
143
+ if (fileUrls.length > 0) {
144
+ log(`dcgchat[${accountId}]: processing ${fileUrls.length} file(s): ${JSON.stringify(fileUrls)}`);
145
+ const mediaMaxBytes = 30 * 1024 * 1024;
146
+ const mediaList = await resolveMediaFromUrls(fileUrls, mediaMaxBytes, log);
147
+ mediaPayload = buildMediaPayload(mediaList);
148
+ log(`dcgchat[${accountId}]: media resolved ${mediaList.length}/${fileUrls.length} file(s), payload=${JSON.stringify(mediaPayload)}`);
149
+ }
150
+
39
151
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
40
- const messageBody = `${userId}: ${text}`;
152
+ // const messageBody = `${userId}: ${text}`;
153
+ const messageBody = text;
41
154
  const bodyFormatted = core.channel.reply.formatAgentEnvelope({
42
155
  channel: "DCG Chat",
43
156
  from: userId,
@@ -51,24 +164,26 @@ export async function handleDcgchatMessage(params: {
51
164
  RawBody: text,
52
165
  CommandBody: text,
53
166
  From: userId,
54
- To: `user:${userId}`,
167
+ To: userId,
55
168
  SessionKey: route.sessionKey,
56
- AccountId: route.accountId,
169
+ AccountId: msg.content.session_id,
57
170
  ChatType: "direct",
58
171
  SenderName: userId,
59
172
  SenderId: userId,
60
173
  Provider: "dcgchat" as const,
61
174
  Surface: "dcgchat" as const,
62
- MessageSid: `dcg-${Date.now()}-${userId}`,
175
+ MessageSid: msg.content.message_id,
63
176
  Timestamp: Date.now(),
64
177
  WasMentioned: true,
65
178
  CommandAuthorized: true,
66
179
  OriginatingChannel: "dcgchat" as const,
67
180
  OriginatingTo: `user:${userId}`,
181
+ ...mediaPayload,
68
182
  });
69
183
 
184
+ log(`dcgchat[${accountId}]: ctxPayload=${JSON.stringify(ctxPayload)}`);
185
+
70
186
  const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
71
- const replyChunks: string[] = [];
72
187
 
73
188
  const { dispatcher, replyOptions, markDispatchIdle } =
74
189
  core.channel.reply.createReplyDispatcherWithTyping({
@@ -77,8 +192,22 @@ export async function handleDcgchatMessage(params: {
77
192
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
78
193
  onReplyStart: async () => {},
79
194
  deliver: async (payload) => {
195
+ log(`dcgchat[chunk]: ${payload.text}`);
80
196
  const t = payload.text?.trim();
81
- if (t) replyChunks.push(t);
197
+ if (t) {
198
+ params.onChunk({
199
+ messageType: "openclaw_bot_chat",
200
+ _userId: msg._userId,
201
+ source: "client",
202
+ content: {
203
+ bot_token: msg.content.bot_token,
204
+ session_id: msg.content.session_id,
205
+ message_id: msg.content.message_id,
206
+ response: t,
207
+ state: 'chunk',
208
+ },
209
+ });
210
+ }
82
211
  },
83
212
  onError: (err, info) => {
84
213
  error(`dcgchat[${accountId}] ${info.kind} reply failed: ${String(err)}`);
@@ -98,13 +227,8 @@ export async function handleDcgchatMessage(params: {
98
227
  },
99
228
  });
100
229
 
101
- markDispatchIdle();
102
-
103
- const reply = replyChunks.join("\n\n").trim();
104
- log(`dcgchat[${accountId}]: dispatch complete, reply length=${reply.length}`);
105
-
106
- // return { type: "reply", userId, text: reply || "[无回复]" };
107
- return {
230
+ log(`dcgchat[chunk]: all_finished`);
231
+ params.onChunk({
108
232
  messageType: "openclaw_bot_chat",
109
233
  _userId: msg._userId,
110
234
  source: "client",
@@ -112,12 +236,27 @@ export async function handleDcgchatMessage(params: {
112
236
  bot_token: msg.content.bot_token,
113
237
  session_id: msg.content.session_id,
114
238
  message_id: msg.content.message_id,
115
- response: reply,
116
- }
117
- }
239
+ response: '',
240
+ state: 'final',
241
+ },
242
+ });
243
+
244
+ markDispatchIdle();
245
+ log(`dcgchat[${accountId}]: dispatch complete`);
118
246
 
119
247
  } catch (err) {
120
248
  error(`dcgchat[${accountId}]: handle message failed: ${String(err)}`);
121
- return { type: "reply", userId, text: `[错误] ${err instanceof Error ? err.message : String(err)}` };
249
+ params.onChunk({
250
+ messageType: "openclaw_bot_chat",
251
+ _userId: msg._userId,
252
+ source: "client",
253
+ content: {
254
+ bot_token: msg.content.bot_token,
255
+ session_id: msg.content.session_id,
256
+ message_id: msg.content.message_id,
257
+ response: `[错误] ${err instanceof Error ? err.message : String(err)}`,
258
+ state: 'final',
259
+ },
260
+ });
122
261
  }
123
262
  }
package/src/channel.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
3
  import type { ResolvedDcgchatAccount, DcgchatConfig } from "./types.js";
4
+ import { logDcgchat } from "./log.js";
5
+ import { getWsConnection } from "./connection.js";
4
6
 
5
7
  export function resolveAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedDcgchatAccount {
6
8
  const id = accountId ?? DEFAULT_ACCOUNT_ID;
@@ -29,11 +31,13 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
29
31
  capabilities: {
30
32
  chatTypes: ["direct"],
31
33
  polls: false,
32
- threads: false,
33
- media: false,
34
+ threads: true,
35
+ media: true,
36
+ nativeCommands: true,
34
37
  reactions: false,
35
38
  edit: false,
36
39
  reply: true,
40
+ blockStreaming: true,
37
41
  },
38
42
  reload: { configPrefixes: ["channels.dcgchat"] },
39
43
  configSchema: {
@@ -81,16 +85,42 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
81
85
  },
82
86
  outbound: {
83
87
  deliveryMode: "direct",
84
- textChunkLimit: 4000,
85
- sendText: async ({ text, to, accountId }) => {
86
- const target = to || "(implicit)";
87
- console.log(`[dcgchat][${accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> ${target}: ${text?.slice(0, 100)}`);
88
+ textChunkLimit: 25,
89
+ sendText: async (ctx) => {
90
+ const target = ctx.to || "(implicit)";
91
+ logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> : ${ctx.text}`);
92
+ const ws = getWsConnection()
93
+ if (ws?.readyState === WebSocket.OPEN) {
94
+ const {botToken} = resolveAccount(ctx.cfg, ctx.accountId);
95
+ const content = {
96
+ messageType: "openclaw_bot_chat",
97
+ _userId: target,
98
+ source: "client",
99
+ content: {
100
+ bot_token: botToken,
101
+ response: ctx.text,
102
+ session_id:ctx.accountId || Date.now().toString(),
103
+ message_id: Date.now().toString(),
104
+ },
105
+ };
106
+ ws.send(JSON.stringify(content));
107
+ logDcgchat.info(`dcgchat[${ctx.accountId}]: sendText to ${target}, ${JSON.stringify(content)}`);
108
+ }
88
109
  return {
89
110
  channel: "dcgchat",
90
111
  messageId: `dcg-${Date.now()}`,
91
112
  chatId: target,
92
113
  };
93
114
  },
115
+ sendMedia: async (ctx) => {
116
+ const target = ctx.to || "(implicit)";
117
+ logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> ${target}: ${ctx.text}`);
118
+ return {
119
+ channel: "dcgchat",
120
+ messageId: `dcg-${Date.now()}`,
121
+ chatId: target,
122
+ };
123
+ },
94
124
  },
95
125
  gateway: {
96
126
  startAccount: async (ctx) => {
@@ -0,0 +1,11 @@
1
+ import type WebSocket from "ws";
2
+
3
+ let ws: WebSocket | null = null;
4
+
5
+ export function setWsConnection(next: WebSocket | null) {
6
+ ws = next;
7
+ }
8
+
9
+ export function getWsConnection(): WebSocket | null {
10
+ return ws;
11
+ }
package/src/log.ts ADDED
@@ -0,0 +1,46 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const logsDir = path.resolve(__dirname, "../logs");
7
+
8
+ function getLogFilePath(): string {
9
+ const date = new Date();
10
+ const yyyy = date.getFullYear();
11
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
12
+ const dd = String(date.getDate()).padStart(2, "0");
13
+ return path.join(logsDir, `${yyyy}-${mm}-${dd}.log`);
14
+ }
15
+
16
+ function formatLine(level: string, message: string, extra?: unknown): string {
17
+ const now = new Date().toISOString();
18
+ const suffix = extra !== undefined ? " " + JSON.stringify(extra) : "";
19
+ return `[${now}] [${level}] ${message}${suffix}\n`;
20
+ }
21
+
22
+ function writeLog(level: string, message: string, extra?: unknown): void {
23
+ try {
24
+ if (!fs.existsSync(logsDir)) {
25
+ fs.mkdirSync(logsDir, { recursive: true });
26
+ }
27
+ fs.appendFileSync(getLogFilePath(), formatLine(level, message, extra), "utf-8");
28
+ } catch {
29
+ // 写日志失败时静默处理,避免影响主流程
30
+ }
31
+ }
32
+
33
+ export const logDcgchat = {
34
+ info(message: string, extra?: unknown): void {
35
+ writeLog("INFO", message, extra);
36
+ },
37
+ warn(message: string, extra?: unknown): void {
38
+ writeLog("WARN", message, extra);
39
+ },
40
+ error(message: string, extra?: unknown): void {
41
+ writeLog("ERROR", message, extra);
42
+ },
43
+ debug(message: string, extra?: unknown): void {
44
+ writeLog("DEBUG", message, extra);
45
+ },
46
+ };
package/src/monitor.ts CHANGED
@@ -3,6 +3,7 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
3
3
  import { resolveAccount } from "./channel.js";
4
4
  import { handleDcgchatMessage } from "./bot.js";
5
5
  import type { InboundMessage, OutboundReply } from "./types.js";
6
+ import { setWsConnection } from "./connection.js";
6
7
 
7
8
  export type MonitorDcgchatOpts = {
8
9
  config?: ClawdbotConfig;
@@ -79,6 +80,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
79
80
 
80
81
  ws.on("open", () => {
81
82
  log(`dcgchat[${account.accountId}]: connected`);
83
+ setWsConnection(ws);
82
84
  startHeartbeat();
83
85
  });
84
86
 
@@ -105,24 +107,23 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
105
107
  parsed.content = JSON.parse(parsed.content)
106
108
 
107
109
  const msg = parsed as unknown as InboundMessage;
108
- const reply = await handleDcgchatMessage({
110
+ await handleDcgchatMessage({
109
111
  cfg,
110
112
  msg,
111
113
  accountId: account.accountId,
112
114
  runtime,
115
+ onChunk: (reply) => {
116
+ if (ws?.readyState === WebSocket.OPEN) {
117
+ const res = { ...reply, content: JSON.stringify(reply.content) };
118
+ ws.send(JSON.stringify(res));
119
+ }
120
+ },
113
121
  });
114
-
115
- if (ws?.readyState === WebSocket.OPEN) {
116
- const res = {
117
- ...reply,
118
- content: JSON.stringify(reply.content)
119
- }
120
- ws.send(JSON.stringify(res));
121
- }
122
122
  });
123
123
 
124
124
  ws.on("close", () => {
125
125
  stopHeartbeat();
126
+ setWsConnection(null);
126
127
  log(`dcgchat[${account.accountId}]: disconnected`);
127
128
  if (shouldReconnect) {
128
129
  log(`dcgchat[${account.accountId}]: reconnecting in ${RECONNECT_DELAY_MS}ms...`);
package/src/types.ts CHANGED
@@ -38,6 +38,7 @@ export type InboundMessage = {
38
38
  session_id: string;
39
39
  message_id: string;
40
40
  text: string;
41
+ file_urls?: string[];
41
42
  }
42
43
  }
43
44
 
@@ -62,5 +63,6 @@ export type OutboundReply = {
62
63
  session_id: string; // ""
63
64
  message_id: string; // ""
64
65
  response: string; // ""
66
+ state: string; // final, chunk
65
67
  }
66
68
  }