@dcrays/dcgchat 0.1.4 → 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 +16 -3
- package/src/api.ts +62 -0
- package/src/bot.ts +8 -3
- package/src/channel.ts +39 -2
- package/src/monitor.ts +10 -8
- package/src/oss.ts +72 -0
- package/src/request.ts +194 -0
- package/src/types.ts +54 -23
- package/src/userInfo.ts +97 -0
package/package.json
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
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
31
|
"openclaw": "2026.2.13",
|
|
32
|
+
"tsx": "^4.19.0",
|
|
20
33
|
"typescript": "^5.7.0"
|
|
21
34
|
},
|
|
22
35
|
"peerDependencies": {
|
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
|
@@ -192,9 +192,10 @@ export async function handleDcgchatMessage(params: {
|
|
|
192
192
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
193
193
|
onReplyStart: async () => {},
|
|
194
194
|
deliver: async (payload) => {
|
|
195
|
-
log(`dcgchat[
|
|
195
|
+
log(`dcgchat[${accountId}][deliver]: received chunk, text length=${payload.text?.length || 0}`);
|
|
196
196
|
const t = payload.text?.trim();
|
|
197
197
|
if (t) {
|
|
198
|
+
log(`dcgchat[${accountId}][deliver]: sending chunk to user ${msg._userId}, text="${t.slice(0, 50)}..."`);
|
|
198
199
|
params.onChunk({
|
|
199
200
|
messageType: "openclaw_bot_chat",
|
|
200
201
|
_userId: msg._userId,
|
|
@@ -207,6 +208,9 @@ export async function handleDcgchatMessage(params: {
|
|
|
207
208
|
state: 'chunk',
|
|
208
209
|
},
|
|
209
210
|
});
|
|
211
|
+
log(`dcgchat[${accountId}][deliver]: chunk sent successfully`);
|
|
212
|
+
} else {
|
|
213
|
+
log(`dcgchat[${accountId}][deliver]: skipping empty chunk`);
|
|
210
214
|
}
|
|
211
215
|
},
|
|
212
216
|
onError: (err, info) => {
|
|
@@ -227,7 +231,7 @@ export async function handleDcgchatMessage(params: {
|
|
|
227
231
|
},
|
|
228
232
|
});
|
|
229
233
|
|
|
230
|
-
log(`dcgchat[
|
|
234
|
+
log(`dcgchat[${accountId}]: dispatch complete, sending final state`);
|
|
231
235
|
params.onChunk({
|
|
232
236
|
messageType: "openclaw_bot_chat",
|
|
233
237
|
_userId: msg._userId,
|
|
@@ -240,9 +244,10 @@ export async function handleDcgchatMessage(params: {
|
|
|
240
244
|
state: 'final',
|
|
241
245
|
},
|
|
242
246
|
});
|
|
247
|
+
log(`dcgchat[${accountId}]: final state sent`);
|
|
243
248
|
|
|
244
249
|
markDispatchIdle();
|
|
245
|
-
log(`dcgchat[${accountId}]:
|
|
250
|
+
log(`dcgchat[${accountId}]: message handling complete`);
|
|
246
251
|
|
|
247
252
|
} catch (err) {
|
|
248
253
|
error(`dcgchat[${accountId}]: handle message failed: ${String(err)}`);
|
package/src/channel.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
|
3
3
|
import type { ResolvedDcgchatAccount, DcgchatConfig } from "./types.js";
|
|
4
4
|
import { logDcgchat } from "./log.js";
|
|
5
5
|
import { getWsConnection } from "./connection.js";
|
|
6
|
+
import { ossUpload } from "./oss.js";
|
|
6
7
|
|
|
7
8
|
export function resolveAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedDcgchatAccount {
|
|
8
9
|
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
@@ -49,6 +50,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
49
50
|
wsUrl: { type: "string" },
|
|
50
51
|
botToken: { type: "string" },
|
|
51
52
|
userId: { type: "string" },
|
|
53
|
+
capabilities: { type: "array", items: { type: "string" } },
|
|
52
54
|
},
|
|
53
55
|
},
|
|
54
56
|
},
|
|
@@ -88,7 +90,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
88
90
|
textChunkLimit: 25,
|
|
89
91
|
sendText: async (ctx) => {
|
|
90
92
|
const target = ctx.to || "(implicit)";
|
|
91
|
-
logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> : ${ctx.text}`);
|
|
93
|
+
logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> sendText ${target}: ${ctx.text}`);
|
|
92
94
|
const ws = getWsConnection()
|
|
93
95
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
94
96
|
const {botToken} = resolveAccount(ctx.cfg, ctx.accountId);
|
|
@@ -114,7 +116,42 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
114
116
|
},
|
|
115
117
|
sendMedia: async (ctx) => {
|
|
116
118
|
const target = ctx.to || "(implicit)";
|
|
117
|
-
logDcgchat.info(`[dcgchat][${ctx.accountId ?? DEFAULT_ACCOUNT_ID}] outbound -> ${target}: ${ctx.text}`);
|
|
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
|
+
}
|
|
118
155
|
return {
|
|
119
156
|
channel: "dcgchat",
|
|
120
157
|
messageId: `dcg-${Date.now()}`,
|
package/src/monitor.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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";
|
|
5
|
-
import
|
|
4
|
+
import { resolveAccount } from "./channel.js";
|
|
6
5
|
import { setWsConnection } from "./connection.js";
|
|
6
|
+
import type { InboundMessage, OutboundReply } from "./types.js";
|
|
7
7
|
|
|
8
8
|
export type MonitorDcgchatOpts = {
|
|
9
9
|
config?: ClawdbotConfig;
|
|
@@ -66,7 +66,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
|
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
ws.send(JSON.stringify(heartbeat));
|
|
69
|
-
log(`dcgchat[${account.accountId}]: heartbeat sent, ${JSON.stringify(heartbeat)}`);
|
|
69
|
+
// log(`dcgchat[${account.accountId}]: heartbeat sent, ${JSON.stringify(heartbeat)}`);
|
|
70
70
|
}
|
|
71
71
|
}, HEARTBEAT_INTERVAL_MS);
|
|
72
72
|
};
|
|
@@ -86,7 +86,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
|
|
|
86
86
|
|
|
87
87
|
ws.on("message", async (data) => {
|
|
88
88
|
log(`dcgchat[${account.accountId}]: on message, ${data.toString()}`);
|
|
89
|
-
let parsed: { messageType?: string
|
|
89
|
+
let parsed: { messageType?: string; content: string };
|
|
90
90
|
try {
|
|
91
91
|
parsed = JSON.parse(data.toString());
|
|
92
92
|
} catch {
|
|
@@ -104,7 +104,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
|
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
parsed.content = JSON.parse(parsed.content)
|
|
107
|
+
parsed.content = JSON.parse(parsed.content);
|
|
108
108
|
|
|
109
109
|
const msg = parsed as unknown as InboundMessage;
|
|
110
110
|
await handleDcgchatMessage({
|
|
@@ -121,10 +121,12 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
ws.on("close", () => {
|
|
124
|
+
ws.on("close", (code, reason) => {
|
|
125
125
|
stopHeartbeat();
|
|
126
126
|
setWsConnection(null);
|
|
127
|
-
log(
|
|
127
|
+
log(
|
|
128
|
+
`dcgchat[${account.accountId}]: disconnected (code=${code}, reason=${reason?.toString() || ""})`,
|
|
129
|
+
);
|
|
128
130
|
if (shouldReconnect) {
|
|
129
131
|
log(`dcgchat[${account.accountId}]: reconnecting in ${RECONNECT_DELAY_MS}ms...`);
|
|
130
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,18 +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
|
-
|
|
43
|
-
}
|
|
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
|
+
};
|
|
44
44
|
|
|
45
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机器人对话"}
|
|
46
46
|
|
|
@@ -54,15 +54,46 @@ export type InboundMessage = {
|
|
|
54
54
|
// };
|
|
55
55
|
|
|
56
56
|
export type OutboundReply = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
68
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
|
+
}
|