@dcrays/dcgchat 0.1.3 → 0.1.5

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,26 +1,39 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for DCG Chat (WebSocket)",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
8
- "files": ["index.ts", "src", "openclaw.plugin.json"],
9
- "keywords": ["openclaw", "dcgchat", "websocket", "ai"],
8
+ "files": [
9
+ "index.ts",
10
+ "src",
11
+ "openclaw.plugin.json"
12
+ ],
13
+ "keywords": [
14
+ "openclaw",
15
+ "dcgchat",
16
+ "websocket",
17
+ "ai"
18
+ ],
10
19
  "scripts": {
11
20
  "typecheck": "tsc --noEmit"
12
21
  },
13
22
  "dependencies": {
23
+ "ali-oss": "^6.23.0",
24
+ "axios": "^1.13.6",
25
+ "md5": "^2.3.0",
14
26
  "ws": "^8.18.0"
15
27
  },
16
28
  "devDependencies": {
17
29
  "@types/node": "^22.0.0",
18
30
  "@types/ws": "^8.5.0",
19
- "openclaw": "2026.1.29",
31
+ "openclaw": "2026.2.13",
32
+ "tsx": "^4.19.0",
20
33
  "typescript": "^5.7.0"
21
34
  },
22
35
  "peerDependencies": {
23
- "openclaw": ">=2026.1.29"
36
+ "openclaw": ">=2026.2.13"
24
37
  },
25
38
  "openclaw": {
26
39
  "extensions": [
package/src/api.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { post } from "./request.js";
2
+ import type { IStsToken, IStsTokenReq } from "./types.js";
3
+ import { getUserTokenCache, setUserTokenCache } from "./userInfo.js";
4
+
5
+ export const getStsToken = async (name: string, botToken: string) => {
6
+ // 确保 userToken 已缓存(如果未缓存会自动获取并缓存)
7
+ await getUserToken(botToken);
8
+
9
+ const response = await post<IStsTokenReq, IStsToken>(
10
+ "/user/getStsToken",
11
+ {
12
+ sourceFileName: name,
13
+ isPrivate: 0,
14
+ },
15
+ { botToken },
16
+ );
17
+
18
+ if (!response || !response.data || !response.data.bucket) {
19
+ throw new Error("获取 OSS 临时凭证失败");
20
+ }
21
+
22
+ return response.data;
23
+ };
24
+
25
+ /**
26
+ * 通过 botToken 查询 userToken
27
+ * @param botToken 机器人 token
28
+ * @returns userToken
29
+ */
30
+ export const queryUserTokenByBotToken = async (botToken: string): Promise<string> => {
31
+ const response = await post<{botToken: string}, {token: string}>("/organization/queryUserTokenByBotToken", {
32
+ botToken,
33
+ });
34
+
35
+ if (!response || !response.data || !response.data.token) {
36
+ throw new Error("获取绑定的用户信息失败");
37
+ }
38
+
39
+ return response.data.token;
40
+ };
41
+
42
+ /**
43
+ * 获取 userToken(优先从缓存获取,缓存未命中则调用 API)
44
+ * @param botToken 机器人 token
45
+ * @returns userToken
46
+ */
47
+ export const getUserToken = async (botToken: string): Promise<string> => {
48
+ // 1. 尝试从缓存获取
49
+ const cachedToken = getUserTokenCache(botToken);
50
+ if (cachedToken) {
51
+ return cachedToken;
52
+ }
53
+
54
+ // 2. 缓存未命中,调用 API 获取
55
+ console.log(`[api] cache miss, fetching userToken for botToken=${botToken.slice(0, 10)}...`);
56
+ const userToken = await queryUserTokenByBotToken(botToken);
57
+
58
+ // 3. 缓存新获取的 token
59
+ setUserTokenCache(botToken, userToken);
60
+
61
+ return userToken;
62
+ };
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,6 +136,18 @@ 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
152
  // const messageBody = `${userId}: ${text}`;
41
153
  const messageBody = text;
@@ -52,24 +164,26 @@ export async function handleDcgchatMessage(params: {
52
164
  RawBody: text,
53
165
  CommandBody: text,
54
166
  From: userId,
55
- To: `user:${userId}`,
167
+ To: userId,
56
168
  SessionKey: route.sessionKey,
57
- AccountId: route.accountId,
169
+ AccountId: msg.content.session_id,
58
170
  ChatType: "direct",
59
171
  SenderName: userId,
60
172
  SenderId: userId,
61
173
  Provider: "dcgchat" as const,
62
174
  Surface: "dcgchat" as const,
63
- MessageSid: `dcg-${Date.now()}-${userId}`,
175
+ MessageSid: msg.content.message_id,
64
176
  Timestamp: Date.now(),
65
177
  WasMentioned: true,
66
178
  CommandAuthorized: true,
67
179
  OriginatingChannel: "dcgchat" as const,
68
180
  OriginatingTo: `user:${userId}`,
181
+ ...mediaPayload,
69
182
  });
70
183
 
184
+ log(`dcgchat[${accountId}]: ctxPayload=${JSON.stringify(ctxPayload)}`);
185
+
71
186
  const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
72
- const replyChunks: string[] = [];
73
187
 
74
188
  const { dispatcher, replyOptions, markDispatchIdle } =
75
189
  core.channel.reply.createReplyDispatcherWithTyping({
@@ -78,8 +192,26 @@ export async function handleDcgchatMessage(params: {
78
192
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
79
193
  onReplyStart: async () => {},
80
194
  deliver: async (payload) => {
195
+ log(`dcgchat[${accountId}][deliver]: received chunk, text length=${payload.text?.length || 0}`);
81
196
  const t = payload.text?.trim();
82
- if (t) replyChunks.push(t);
197
+ if (t) {
198
+ log(`dcgchat[${accountId}][deliver]: sending chunk to user ${msg._userId}, text="${t.slice(0, 50)}..."`);
199
+ params.onChunk({
200
+ messageType: "openclaw_bot_chat",
201
+ _userId: msg._userId,
202
+ source: "client",
203
+ content: {
204
+ bot_token: msg.content.bot_token,
205
+ session_id: msg.content.session_id,
206
+ message_id: msg.content.message_id,
207
+ response: t,
208
+ state: 'chunk',
209
+ },
210
+ });
211
+ log(`dcgchat[${accountId}][deliver]: chunk sent successfully`);
212
+ } else {
213
+ log(`dcgchat[${accountId}][deliver]: skipping empty chunk`);
214
+ }
83
215
  },
84
216
  onError: (err, info) => {
85
217
  error(`dcgchat[${accountId}] ${info.kind} reply failed: ${String(err)}`);
@@ -99,13 +231,8 @@ export async function handleDcgchatMessage(params: {
99
231
  },
100
232
  });
101
233
 
102
- markDispatchIdle();
103
-
104
- const reply = replyChunks.join("\n\n").trim();
105
- log(`dcgchat[${accountId}]: dispatch complete, reply length=${reply.length}`);
106
-
107
- // return { type: "reply", userId, text: reply || "[无回复]" };
108
- return {
234
+ log(`dcgchat[${accountId}]: dispatch complete, sending final state`);
235
+ params.onChunk({
109
236
  messageType: "openclaw_bot_chat",
110
237
  _userId: msg._userId,
111
238
  source: "client",
@@ -113,12 +240,28 @@ export async function handleDcgchatMessage(params: {
113
240
  bot_token: msg.content.bot_token,
114
241
  session_id: msg.content.session_id,
115
242
  message_id: msg.content.message_id,
116
- response: reply,
117
- }
118
- }
243
+ response: '',
244
+ state: 'final',
245
+ },
246
+ });
247
+ log(`dcgchat[${accountId}]: final state sent`);
248
+
249
+ markDispatchIdle();
250
+ log(`dcgchat[${accountId}]: message handling complete`);
119
251
 
120
252
  } catch (err) {
121
253
  error(`dcgchat[${accountId}]: handle message failed: ${String(err)}`);
122
- return { type: "reply", userId, text: `[错误] ${err instanceof Error ? err.message : String(err)}` };
254
+ params.onChunk({
255
+ messageType: "openclaw_bot_chat",
256
+ _userId: msg._userId,
257
+ source: "client",
258
+ content: {
259
+ bot_token: msg.content.bot_token,
260
+ session_id: msg.content.session_id,
261
+ message_id: msg.content.message_id,
262
+ response: `[错误] ${err instanceof Error ? err.message : String(err)}`,
263
+ state: 'final',
264
+ },
265
+ });
123
266
  }
124
267
  }
package/src/channel.ts CHANGED
@@ -1,6 +1,9 @@
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";
6
+ import { ossUpload } from "./oss.js";
4
7
 
5
8
  export function resolveAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedDcgchatAccount {
6
9
  const id = accountId ?? DEFAULT_ACCOUNT_ID;
@@ -29,11 +32,13 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
29
32
  capabilities: {
30
33
  chatTypes: ["direct"],
31
34
  polls: false,
32
- threads: false,
33
- media: false,
35
+ threads: true,
36
+ media: true,
37
+ nativeCommands: true,
34
38
  reactions: false,
35
39
  edit: false,
36
40
  reply: true,
41
+ blockStreaming: true,
37
42
  },
38
43
  reload: { configPrefixes: ["channels.dcgchat"] },
39
44
  configSchema: {
@@ -45,6 +50,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
45
50
  wsUrl: { type: "string" },
46
51
  botToken: { type: "string" },
47
52
  userId: { type: "string" },
53
+ capabilities: { type: "array", items: { type: "string" } },
48
54
  },
49
55
  },
50
56
  },
@@ -81,16 +87,77 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
81
87
  },
82
88
  outbound: {
83
89
  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)}`);
90
+ textChunkLimit: 25,
91
+ sendText: async (ctx) => {
92
+ const target = ctx.to || "(implicit)";
93
+ logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> sendText ${target}: ${ctx.text}`);
94
+ const ws = getWsConnection()
95
+ if (ws?.readyState === WebSocket.OPEN) {
96
+ const {botToken} = resolveAccount(ctx.cfg, ctx.accountId);
97
+ const content = {
98
+ messageType: "openclaw_bot_chat",
99
+ _userId: target,
100
+ source: "client",
101
+ content: {
102
+ bot_token: botToken,
103
+ response: ctx.text,
104
+ session_id:ctx.accountId || Date.now().toString(),
105
+ message_id: Date.now().toString(),
106
+ },
107
+ };
108
+ ws.send(JSON.stringify(content));
109
+ logDcgchat.info(`dcgchat[${ctx.accountId}]: sendText to ${target}, ${JSON.stringify(content)}`);
110
+ }
88
111
  return {
89
112
  channel: "dcgchat",
90
113
  messageId: `dcg-${Date.now()}`,
91
114
  chatId: target,
92
115
  };
93
116
  },
117
+ sendMedia: async (ctx) => {
118
+ const target = ctx.to || "(implicit)";
119
+ logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> sendMedia ${target}: ${ctx.text + ctx.mediaUrl}`);
120
+ const ws = getWsConnection()
121
+ if (ws?.readyState === WebSocket.OPEN) {
122
+ const {botToken} = resolveAccount(ctx.cfg, ctx.accountId);
123
+ try {
124
+ const url = ctx.mediaUrl ? await ossUpload(ctx.mediaUrl, botToken) : '';
125
+ const fileName = ctx.mediaUrl?.split(/[\\/]/).pop() || ''
126
+ const content = {
127
+ messageType: "openclaw_bot_chat",
128
+ _userId: target,
129
+ source: "client",
130
+ content: {
131
+ bot_token: botToken,
132
+ response: ctx.text + '\n' + `[${fileName}](${url})`,
133
+ session_id:ctx.accountId || Date.now().toString(),
134
+ message_id: Date.now().toString(),
135
+ },
136
+ };
137
+ ws.send(JSON.stringify(content));
138
+ logDcgchat.info(`dcgchat[${ctx.accountId}]: sendText to ${target}, ${JSON.stringify(content)}`);
139
+ } catch (error) {
140
+ const content = {
141
+ messageType: "openclaw_bot_chat",
142
+ _userId: target,
143
+ source: "client",
144
+ content: {
145
+ bot_token: botToken,
146
+ response: ctx.text + '\n' + ctx.mediaUrl,
147
+ session_id:ctx.accountId || Date.now().toString(),
148
+ message_id: Date.now().toString(),
149
+ },
150
+ };
151
+ ws.send(JSON.stringify(content));
152
+ logDcgchat.info(`dcgchat[${ctx.accountId}]: sendText to ${target}, ${JSON.stringify(content)}`);
153
+ }
154
+ }
155
+ return {
156
+ channel: "dcgchat",
157
+ messageId: `dcg-${Date.now()}`,
158
+ chatId: target,
159
+ };
160
+ },
94
161
  },
95
162
  gateway: {
96
163
  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
@@ -1,7 +1,8 @@
1
- import WebSocket from "ws";
2
1
  import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
3
- import { resolveAccount } from "./channel.js";
2
+ import WebSocket from "ws";
4
3
  import { handleDcgchatMessage } from "./bot.js";
4
+ import { resolveAccount } from "./channel.js";
5
+ import { setWsConnection } from "./connection.js";
5
6
  import type { InboundMessage, OutboundReply } from "./types.js";
6
7
 
7
8
  export type MonitorDcgchatOpts = {
@@ -65,7 +66,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
65
66
  },
66
67
  };
67
68
  ws.send(JSON.stringify(heartbeat));
68
- log(`dcgchat[${account.accountId}]: heartbeat sent, ${JSON.stringify(heartbeat)}`);
69
+ // log(`dcgchat[${account.accountId}]: heartbeat sent, ${JSON.stringify(heartbeat)}`);
69
70
  }
70
71
  }, HEARTBEAT_INTERVAL_MS);
71
72
  };
@@ -79,12 +80,13 @@ 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
 
85
87
  ws.on("message", async (data) => {
86
88
  log(`dcgchat[${account.accountId}]: on message, ${data.toString()}`);
87
- let parsed: { messageType?: string, content: string };
89
+ let parsed: { messageType?: string; content: string };
88
90
  try {
89
91
  parsed = JSON.parse(data.toString());
90
92
  } catch {
@@ -102,28 +104,29 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
102
104
  return;
103
105
  }
104
106
 
105
- parsed.content = JSON.parse(parsed.content)
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
- ws.on("close", () => {
124
+ ws.on("close", (code, reason) => {
125
125
  stopHeartbeat();
126
- log(`dcgchat[${account.accountId}]: disconnected`);
126
+ setWsConnection(null);
127
+ log(
128
+ `dcgchat[${account.accountId}]: disconnected (code=${code}, reason=${reason?.toString() || ""})`,
129
+ );
127
130
  if (shouldReconnect) {
128
131
  log(`dcgchat[${account.accountId}]: reconnecting in ${RECONNECT_DELAY_MS}ms...`);
129
132
  setTimeout(connect, RECONNECT_DELAY_MS);
package/src/oss.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { createReadStream } from "node:fs";
2
+ import OSS from "ali-oss";
3
+ import { getStsToken, getUserToken } from "./api";
4
+
5
+
6
+ /** 将 File/路径/Buffer 转为 ali-oss put 所需的 Buffer 或 ReadableStream */
7
+ async function toUploadContent(
8
+ input: File | string | Buffer,
9
+ ): Promise<{ content: Buffer | ReturnType<typeof createReadStream>; fileName: string }> {
10
+ if (Buffer.isBuffer(input)) {
11
+ return { content: input, fileName: "file" };
12
+ }
13
+ if (typeof input === "string") {
14
+ return {
15
+ content: createReadStream(input),
16
+ fileName: input.split("/").pop() ?? "file",
17
+ };
18
+ }
19
+ // File: ali-oss 需要 Buffer/Stream,用 arrayBuffer 转 Buffer
20
+ const buf = Buffer.from(await input.arrayBuffer());
21
+ return { content: buf, fileName: input.name };
22
+ }
23
+
24
+ export const ossUpload = async (file: File | string | Buffer, botToken: string) => {
25
+ await getUserToken(botToken);
26
+
27
+ const { content, fileName } = await toUploadContent(file);
28
+ const data = await getStsToken(fileName, botToken);
29
+
30
+ const options: OSS.Options = {
31
+ // 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
32
+ accessKeyId: data.tempAccessKeyId,
33
+ accessKeySecret: data.tempAccessKeySecret,
34
+ // 从STS服务获取的安全令牌(SecurityToken)。
35
+ stsToken: data.tempSecurityToken,
36
+ // 填写Bucket名称。
37
+ bucket: data.bucket,
38
+ endpoint: data.endPoint,
39
+ region: data.region,
40
+ secure: true,
41
+ // refreshSTSToken: async () => {
42
+ // const tokenResponse = await getStsToken(fileName);
43
+ // return {
44
+ // accessKeyId: tokenResponse.tempAccessKeyId,
45
+ // accessKeySecret: tokenResponse.tempAccessKeySecret,
46
+ // stsToken: tokenResponse.tempSecurityToken,
47
+ // }
48
+ // },
49
+ // // 5 seconds
50
+ // refreshSTSTokenInterval: 5 * 1000,
51
+ // // // 5 minutes
52
+ // // refreshSTSTokenInterval: 5 * 60 * 1000,
53
+ };
54
+
55
+ const client = new OSS(options);
56
+
57
+ const name = `${data.uploadDir}${data.ossFileKey}`;
58
+
59
+ try {
60
+ const objectResult = await client.put(name, content);
61
+ if (objectResult?.res?.status !== 200) {
62
+ throw new Error("OSS 上传失败");
63
+ }
64
+ console.log(objectResult.url);
65
+ // const url = `${data.protocol || 'http'}://${data.bucket}.${data.endPoint}/${data.uploadDir}${data.ossFileKey}`
66
+ return objectResult.url;
67
+ } catch (error) {
68
+ console.error("OSS 上传失败:", error);
69
+ throw error;
70
+ }
71
+ };
72
+
package/src/request.ts ADDED
@@ -0,0 +1,194 @@
1
+ import axios from "axios";
2
+ import md5 from "md5";
3
+ import type { IResponse } from "./types.js";
4
+ import { getUserTokenCache } from "./userInfo.js";
5
+
6
+ export const apiUrlMap = {
7
+ production: "https://api-gateway.shuwenda.com",
8
+ test: "https://api-gateway.shuwenda.icu",
9
+ develop: "https://shenyu-dev.shuwenda.icu",
10
+ };
11
+
12
+ export const appKey = {
13
+ production: "2A1C74D315CB4A01BF3DA8983695AFE2",
14
+ test: "7374A073CCBD4C8CA84FAD33896F0B69",
15
+ develop: "7374A073CCBD4C8CA84FAD33896F0B69",
16
+ };
17
+
18
+ export const signKey = {
19
+ production: "34E9023008EA445AAE6CC075CC954F46",
20
+ test: "FE93D3322CB94E978CE95BD4AA2A37D7",
21
+ develop: "FE93D3322CB94E978CE95BD4AA2A37D7",
22
+ };
23
+
24
+ const env = "test";
25
+ export const version = "1.0.0";
26
+
27
+ /**
28
+ * 根据 axios 请求配置生成等价 curl,便于复制给后端排查
29
+ */
30
+ function toCurl(config: {
31
+ baseURL?: string;
32
+ url?: string;
33
+ method?: string;
34
+ headers?: Record<string, string | number | undefined>;
35
+ data?: unknown;
36
+ }): string {
37
+ const base = config.baseURL ?? "";
38
+ const path = config.url ?? "";
39
+ const url = path.startsWith("http")
40
+ ? path
41
+ : `${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
42
+ const method = (config.method ?? "GET").toUpperCase();
43
+ const headers = config.headers ?? {};
44
+ const parts = ["curl", "-X", method, `'${url}'`];
45
+ for (const [k, v] of Object.entries(headers)) {
46
+ if (v !== undefined && v !== "") {
47
+ parts.push("-H", `'${k}: ${v}'`);
48
+ }
49
+ }
50
+ if (method !== "GET" && config.data !== undefined) {
51
+ const body = typeof config.data === "string" ? config.data : JSON.stringify(config.data);
52
+ parts.push("-d", `'${body.replace(/'/g, "'\\''")}'`);
53
+ }
54
+ return parts.join(" ");
55
+ }
56
+
57
+ /**
58
+ * 生成签名
59
+ * @param {Object} body 请求体
60
+ * @param {number} timestamp 时间戳
61
+ * @param {string} path 请求地址
62
+ * @param {'production' | 'test' | 'develop'} env 请求环境
63
+ * @param {string} version 版本号
64
+ * @returns {string} 大写 MD5 签名
65
+ */
66
+ export function getSignature(
67
+ body: Record<string, unknown>,
68
+ timestamp: number,
69
+ path: string,
70
+ env: "production" | "test" | "develop",
71
+ version: string = "1.0.0",
72
+ ) {
73
+ // 1. 构造 map
74
+ const map = {
75
+ timestamp,
76
+ path,
77
+ version,
78
+ ...body,
79
+ };
80
+ // 2. 按 key 进行自然排序
81
+ const sortedKeys = Object.keys(map).sort();
82
+
83
+ // 3. 拼接 key + value
84
+ const signStr =
85
+ sortedKeys
86
+ .map((key) => {
87
+ const val = map[key as keyof typeof map];
88
+ return val === undefined
89
+ ? ""
90
+ : `${key}${typeof val === "object" ? JSON.stringify(val) : val}`;
91
+ })
92
+ .join("") + signKey[env];
93
+
94
+ // 4. MD5 加密并转大写
95
+ return md5(signStr).toUpperCase();
96
+ }
97
+
98
+ function buildHeaders(data: Record<string, unknown>, url: string, userToken?: string) {
99
+ const timestamp = Date.now();
100
+
101
+ const headers: Record<string, string | number> = {
102
+ "Content-Type": "application/json",
103
+ appKey: appKey[env],
104
+ sign: getSignature(data, timestamp, url, env, version),
105
+ timestamp,
106
+ version,
107
+ };
108
+
109
+ // 如果提供了 userToken,添加到 headers
110
+ if (userToken) {
111
+ headers.authorization = userToken;
112
+ }
113
+
114
+ return headers;
115
+ }
116
+
117
+ const axiosInstance = axios.create({
118
+ baseURL: apiUrlMap[env],
119
+ timeout: 10000,
120
+ });
121
+
122
+ // 请求拦截器:自动注入 userToken
123
+ axiosInstance.interceptors.request.use(
124
+ (config) => {
125
+ // 如果请求配置中已经有 authorization,优先使用
126
+ if (config.headers?.authorization) {
127
+ return config;
128
+ }
129
+
130
+ // 从请求上下文中获取 botToken(需要在调用时设置)
131
+ const botToken = (config as any).__botToken as string | undefined;
132
+ if (botToken) {
133
+ const cachedToken = getUserTokenCache(botToken);
134
+ if (cachedToken) {
135
+ config.headers = config.headers || {};
136
+ config.headers.authorization = cachedToken;
137
+ console.log(`[request] auto-injected userToken from cache for botToken=${botToken.slice(0, 10)}...`);
138
+ }
139
+ }
140
+
141
+ return config;
142
+ },
143
+ (error) => {
144
+ return Promise.reject(error);
145
+ },
146
+ );
147
+
148
+ // 响应拦截器:打印 curl 便于调试
149
+ axiosInstance.interceptors.response.use(
150
+ (response) => {
151
+ const curl = toCurl(response.config);
152
+ console.log("[request] curl for backend:", curl);
153
+ return response.data;
154
+ },
155
+ (error) => {
156
+ const config = error.config ?? {};
157
+ const curl = toCurl(config);
158
+ console.log("[request] curl for backend (failed request):", curl);
159
+ return Promise.reject(error);
160
+ },
161
+ );
162
+
163
+
164
+
165
+ /**
166
+ * POST 请求(支持可选的 userToken 和 botToken)
167
+ * @param url 请求路径
168
+ * @param data 请求体
169
+ * @param options 可选配置
170
+ * @param options.userToken 直接提供的 userToken(优先级最高)
171
+ * @param options.botToken 用于从缓存获取 userToken 的 botToken
172
+ */
173
+ export function post<T = Record<string, unknown>, R = unknown>(
174
+ url: string,
175
+ data: T,
176
+ options?: {
177
+ userToken?: string;
178
+ botToken?: string;
179
+ },
180
+ ): Promise<IResponse<R>> {
181
+ const config: any = {
182
+ method: "POST",
183
+ url,
184
+ data,
185
+ headers: buildHeaders(data as Record<string, unknown>, url, options?.userToken),
186
+ };
187
+
188
+ // 将 botToken 附加到配置中,供请求拦截器使用
189
+ if (options?.botToken) {
190
+ config.__botToken = options.botToken;
191
+ }
192
+
193
+ return axiosInstance.request(config);
194
+ }
package/src/types.ts CHANGED
@@ -29,17 +29,18 @@ export type ResolvedDcgchatAccount = {
29
29
  // text: string;
30
30
  // };
31
31
  export type InboundMessage = {
32
- messageType: string; // "openclaw_bot_chat",
33
- _userId: number;
34
- source: string; // 'server',
35
- // content: string;
36
- content: {
37
- bot_token: string;
38
- session_id: string;
39
- message_id: string;
40
- text: string;
41
- }
42
- }
32
+ messageType: string; // "openclaw_bot_chat",
33
+ _userId: number;
34
+ source: string; // 'server',
35
+ // content: string;
36
+ content: {
37
+ bot_token: string;
38
+ session_id: string;
39
+ message_id: string;
40
+ text: string;
41
+ file_urls?: string[];
42
+ };
43
+ };
43
44
 
44
45
  // {"_userId":40,"content":"{\"bot_token\":\"sk_b7f8a3e1c5d24e6f8a1b3c4d5e6f7a8b\",\"session_id\":\"1\",\"message_id\":\"1\",\"text\":\"你好\"}","messageType":"openclaw_bot_chat","msgId":398599,"source":"server","title":"OPENCLAW机器人对话"}
45
46
 
@@ -53,14 +54,46 @@ export type InboundMessage = {
53
54
  // };
54
55
 
55
56
  export type OutboundReply = {
56
- messageType: string; // "openclaw_bot_chat",
57
- _userId: number; // 100
58
- source: string; // 'client',
59
- // content: string;
60
- content:{
61
- bot_token: string; // ""
62
- session_id: string; // ""
63
- message_id: string; // ""
64
- response: string; // ""
65
- }
57
+ messageType: string; // "openclaw_bot_chat",
58
+ _userId: number; // 100
59
+ source: string; // 'client',
60
+ // content: string;
61
+ content: {
62
+ bot_token: string; // ""
63
+ session_id: string; // ""
64
+ message_id: string; // ""
65
+ response: string; // ""
66
+ state: string; // final, chunk
67
+ };
68
+ };
69
+
70
+ export interface IResponse<T = unknown> {
71
+ /** 响应状态码 */
72
+ code?: number | string;
73
+ /** 响应数据 */
74
+ data?: T;
75
+ /** 响应消息 */
76
+ message?: string;
77
+ }
78
+
79
+ export interface IStsToken {
80
+ bucket: string;
81
+ endPoint: string;
82
+ expiration: string;
83
+ ossFileKey: string;
84
+ policy: string;
85
+ region: string;
86
+ signature: string;
87
+ sourceFileName: string;
88
+ stsEndPoint: string;
89
+ tempAccessKeyId: string;
90
+ tempAccessKeySecret: string;
91
+ tempSecurityToken: string;
92
+ uploadDir: string;
93
+ protocol: string;
94
+ }
95
+
96
+ export interface IStsTokenReq {
97
+ sourceFileName: string
98
+ isPrivate: number
66
99
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * userToken 缓存管理模块
3
+ * 负责维护 botToken -> userToken 的映射关系,支持自动过期
4
+ */
5
+
6
+ // userToken 缓存配置
7
+ const TOKEN_CACHE_DURATION = 60 * 60 * 1000; // 1小时
8
+
9
+ type TokenCacheEntry = {
10
+ token: string;
11
+ expiresAt: number;
12
+ };
13
+
14
+ // 内存缓存:botToken -> { token, expiresAt }
15
+ const tokenCache = new Map<string, TokenCacheEntry>();
16
+
17
+ /**
18
+ * 设置 userToken 缓存
19
+ * @param botToken 机器人 token
20
+ * @param userToken 用户 token
21
+ */
22
+ export function setUserTokenCache(botToken: string, userToken: string): void {
23
+ const expiresAt = Date.now() + TOKEN_CACHE_DURATION;
24
+ tokenCache.set(botToken, { token: userToken, expiresAt });
25
+ console.log(
26
+ `[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * 获取 userToken 缓存(自动检查过期)
32
+ * @param botToken 机器人 token
33
+ * @returns userToken 或 null(未找到或已过期)
34
+ */
35
+ export function getUserTokenCache(botToken: string): string | null {
36
+ const entry = tokenCache.get(botToken);
37
+ if (!entry) {
38
+ console.log(`[token-cache] no cache found for botToken=${botToken.slice(0, 10)}...`);
39
+ return null;
40
+ }
41
+
42
+ // 检查是否过期
43
+ if (Date.now() >= entry.expiresAt) {
44
+ console.log(`[token-cache] cache expired for botToken=${botToken.slice(0, 10)}..., removing`);
45
+ tokenCache.delete(botToken);
46
+ return null;
47
+ }
48
+
49
+ console.log(
50
+ `[token-cache] cache hit for botToken=${botToken.slice(0, 10)}..., valid until ${new Date(entry.expiresAt).toISOString()}`,
51
+ );
52
+ return entry.token;
53
+ }
54
+
55
+ /**
56
+ * 清除指定 botToken 的缓存
57
+ * @param botToken 机器人 token
58
+ */
59
+ export function clearUserTokenCache(botToken: string): void {
60
+ tokenCache.delete(botToken);
61
+ console.log(`[token-cache] cleared cache for botToken=${botToken.slice(0, 10)}...`);
62
+ }
63
+
64
+ /**
65
+ * 清除所有缓存
66
+ */
67
+ export function clearAllUserTokenCache(): void {
68
+ tokenCache.clear();
69
+ console.log(`[token-cache] cleared all token cache`);
70
+ }
71
+
72
+ /**
73
+ * 获取缓存统计信息(用于调试)
74
+ */
75
+ export function getTokenCacheStats(): {
76
+ total: number;
77
+ valid: number;
78
+ expired: number;
79
+ } {
80
+ const now = Date.now();
81
+ let valid = 0;
82
+ let expired = 0;
83
+
84
+ for (const entry of tokenCache.values()) {
85
+ if (now < entry.expiresAt) {
86
+ valid++;
87
+ } else {
88
+ expired++;
89
+ }
90
+ }
91
+
92
+ return {
93
+ total: tokenCache.size,
94
+ valid,
95
+ expired,
96
+ };
97
+ }