@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 +18 -5
- package/src/api.ts +62 -0
- package/src/bot.ts +161 -18
- package/src/channel.ts +73 -6
- package/src/connection.ts +11 -0
- package/src/log.ts +46 -0
- package/src/monitor.ts +19 -16
- package/src/oss.ts +72 -0
- package/src/request.ts +194 -0
- package/src/types.ts +54 -21
- package/src/userInfo.ts +97 -0
package/package.json
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcrays/dcgchat",
|
|
3
|
-
"version": "0.1.
|
|
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": [
|
|
9
|
-
|
|
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.
|
|
31
|
+
"openclaw": "2026.2.13",
|
|
32
|
+
"tsx": "^4.19.0",
|
|
20
33
|
"typescript": "^5.7.0"
|
|
21
34
|
},
|
|
22
35
|
"peerDependencies": {
|
|
23
|
-
"openclaw": ">=2026.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
167
|
+
To: userId,
|
|
56
168
|
SessionKey: route.sessionKey,
|
|
57
|
-
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:
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
33
|
-
media:
|
|
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:
|
|
85
|
-
sendText: async (
|
|
86
|
-
const target = to || "(implicit)";
|
|
87
|
-
|
|
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) => {
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|
package/src/userInfo.ts
ADDED
|
@@ -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
|
+
}
|