@netcat-ai/openclaw-weixin 2.4.7
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/CHANGELOG.md +156 -0
- package/CHANGELOG.zh_CN.md +156 -0
- package/LICENSE +27 -0
- package/README.md +355 -0
- package/README.zh_CN.md +351 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/src/api/api.js +484 -0
- package/dist/src/api/api.js.map +1 -0
- package/dist/src/api/config-cache.js +64 -0
- package/dist/src/api/config-cache.js.map +1 -0
- package/dist/src/api/session-guard.js +49 -0
- package/dist/src/api/session-guard.js.map +1 -0
- package/dist/src/api/types.js +37 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/auth/accounts.js +328 -0
- package/dist/src/auth/accounts.js.map +1 -0
- package/dist/src/auth/login-qr.js +331 -0
- package/dist/src/auth/login-qr.js.map +1 -0
- package/dist/src/auth/pairing.js +104 -0
- package/dist/src/auth/pairing.js.map +1 -0
- package/dist/src/cdn/aes-ecb.js +19 -0
- package/dist/src/cdn/aes-ecb.js.map +1 -0
- package/dist/src/cdn/cdn-upload.js +74 -0
- package/dist/src/cdn/cdn-upload.js.map +1 -0
- package/dist/src/cdn/cdn-url.js +14 -0
- package/dist/src/cdn/cdn-url.js.map +1 -0
- package/dist/src/cdn/pic-decrypt.js +89 -0
- package/dist/src/cdn/pic-decrypt.js.map +1 -0
- package/dist/src/cdn/upload.js +115 -0
- package/dist/src/cdn/upload.js.map +1 -0
- package/dist/src/channel.js +478 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/compat.js +67 -0
- package/dist/src/compat.js.map +1 -0
- package/dist/src/config/config-schema.js +20 -0
- package/dist/src/config/config-schema.js.map +1 -0
- package/dist/src/config/reply-progress.js +5 -0
- package/dist/src/config/reply-progress.js.map +1 -0
- package/dist/src/media/media-download.js +95 -0
- package/dist/src/media/media-download.js.map +1 -0
- package/dist/src/media/mime.js +73 -0
- package/dist/src/media/mime.js.map +1 -0
- package/dist/src/media/silk-transcode.js +64 -0
- package/dist/src/media/silk-transcode.js.map +1 -0
- package/dist/src/messaging/debug-mode.js +63 -0
- package/dist/src/messaging/debug-mode.js.map +1 -0
- package/dist/src/messaging/error-notice.js +25 -0
- package/dist/src/messaging/error-notice.js.map +1 -0
- package/dist/src/messaging/inbound.js +201 -0
- package/dist/src/messaging/inbound.js.map +1 -0
- package/dist/src/messaging/markdown-filter.js +368 -0
- package/dist/src/messaging/markdown-filter.js.map +1 -0
- package/dist/src/messaging/outbound-hooks.js +58 -0
- package/dist/src/messaging/outbound-hooks.js.map +1 -0
- package/dist/src/messaging/process-message.js +406 -0
- package/dist/src/messaging/process-message.js.map +1 -0
- package/dist/src/messaging/reply-progress-sender.js +93 -0
- package/dist/src/messaging/reply-progress-sender.js.map +1 -0
- package/dist/src/messaging/send-media.js +54 -0
- package/dist/src/messaging/send-media.js.map +1 -0
- package/dist/src/messaging/send.js +220 -0
- package/dist/src/messaging/send.js.map +1 -0
- package/dist/src/messaging/slash-commands.js +70 -0
- package/dist/src/messaging/slash-commands.js.map +1 -0
- package/dist/src/monitor/monitor.js +146 -0
- package/dist/src/monitor/monitor.js.map +1 -0
- package/dist/src/storage/state-dir.js +9 -0
- package/dist/src/storage/state-dir.js.map +1 -0
- package/dist/src/storage/sync-buf.js +64 -0
- package/dist/src/storage/sync-buf.js.map +1 -0
- package/dist/src/util/logger.js +120 -0
- package/dist/src/util/logger.js.map +1 -0
- package/dist/src/util/random.js +16 -0
- package/dist/src/util/random.js.map +1 -0
- package/dist/src/util/redact.js +54 -0
- package/dist/src/util/redact.js.map +1 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +75 -0
- package/src/api/api.ts +586 -0
- package/src/api/config-cache.ts +79 -0
- package/src/api/session-guard.ts +58 -0
- package/src/api/types.ts +278 -0
- package/src/auth/accounts.ts +394 -0
- package/src/auth/login-qr.ts +458 -0
- package/src/auth/pairing.ts +120 -0
- package/src/cdn/aes-ecb.ts +21 -0
- package/src/cdn/cdn-upload.ts +93 -0
- package/src/cdn/cdn-url.ts +20 -0
- package/src/cdn/pic-decrypt.ts +101 -0
- package/src/cdn/upload.ts +168 -0
- package/src/channel.ts +548 -0
- package/src/compat.ts +77 -0
- package/src/config/config-schema.ts +23 -0
- package/src/config/reply-progress.ts +10 -0
- package/src/media/media-download.ts +149 -0
- package/src/media/mime.ts +76 -0
- package/src/media/silk-transcode.ts +74 -0
- package/src/messaging/debug-mode.ts +69 -0
- package/src/messaging/error-notice.ts +32 -0
- package/src/messaging/inbound.ts +262 -0
- package/src/messaging/markdown-filter.ts +361 -0
- package/src/messaging/outbound-hooks.ts +88 -0
- package/src/messaging/process-message.ts +525 -0
- package/src/messaging/reply-progress-sender.ts +122 -0
- package/src/messaging/send-media.ts +72 -0
- package/src/messaging/send.ts +294 -0
- package/src/messaging/slash-commands.ts +110 -0
- package/src/monitor/monitor.ts +224 -0
- package/src/storage/state-dir.ts +11 -0
- package/src/storage/sync-buf.ts +81 -0
- package/src/util/logger.ts +145 -0
- package/src/util/random.ts +17 -0
- package/src/util/redact.ts +54 -0
- package/src/vendor.d.ts +25 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { WeixinApiOptions } from "../api/api.js";
|
|
3
|
+
import { logger } from "../util/logger.js";
|
|
4
|
+
import { getMimeFromFilename } from "../media/mime.js";
|
|
5
|
+
import { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from "./send.js";
|
|
6
|
+
import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from "../cdn/upload.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Upload a local file and send it as a weixin message, routing by MIME type:
|
|
10
|
+
* video/* → uploadVideoToWeixin + sendVideoMessageWeixin
|
|
11
|
+
* image/* → uploadFileToWeixin + sendImageMessageWeixin
|
|
12
|
+
* else → uploadFileAttachmentToWeixin + sendFileMessageWeixin
|
|
13
|
+
*
|
|
14
|
+
* Used by both the auto-reply deliver path (monitor.ts) and the outbound
|
|
15
|
+
* sendMedia path (channel.ts) so they stay in sync.
|
|
16
|
+
*/
|
|
17
|
+
export async function sendWeixinMediaFile(params: {
|
|
18
|
+
filePath: string;
|
|
19
|
+
to: string;
|
|
20
|
+
text: string;
|
|
21
|
+
opts: WeixinApiOptions & { contextToken?: string; runId?: string };
|
|
22
|
+
cdnBaseUrl: string;
|
|
23
|
+
}): Promise<{ messageId: string }> {
|
|
24
|
+
const { filePath, to, text, opts, cdnBaseUrl } = params;
|
|
25
|
+
const mime = getMimeFromFilename(filePath);
|
|
26
|
+
const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };
|
|
27
|
+
|
|
28
|
+
if (mime.startsWith("video/")) {
|
|
29
|
+
logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
|
|
30
|
+
const uploaded = await uploadVideoToWeixin({
|
|
31
|
+
filePath,
|
|
32
|
+
toUserId: to,
|
|
33
|
+
opts: uploadOpts,
|
|
34
|
+
cdnBaseUrl,
|
|
35
|
+
});
|
|
36
|
+
logger.info(
|
|
37
|
+
`[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
|
38
|
+
);
|
|
39
|
+
return sendVideoMessageWeixin({ to, text, uploaded, opts });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (mime.startsWith("image/")) {
|
|
43
|
+
logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`);
|
|
44
|
+
const uploaded = await uploadFileToWeixin({
|
|
45
|
+
filePath,
|
|
46
|
+
toUserId: to,
|
|
47
|
+
opts: uploadOpts,
|
|
48
|
+
cdnBaseUrl,
|
|
49
|
+
});
|
|
50
|
+
logger.info(
|
|
51
|
+
`[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
|
52
|
+
);
|
|
53
|
+
return sendImageMessageWeixin({ to, text, uploaded, opts });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// File attachment: pdf, doc, zip, etc.
|
|
57
|
+
const fileName = path.basename(filePath);
|
|
58
|
+
logger.info(
|
|
59
|
+
`[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`,
|
|
60
|
+
);
|
|
61
|
+
const uploaded = await uploadFileAttachmentToWeixin({
|
|
62
|
+
filePath,
|
|
63
|
+
fileName,
|
|
64
|
+
toUserId: to,
|
|
65
|
+
opts: uploadOpts,
|
|
66
|
+
cdnBaseUrl,
|
|
67
|
+
});
|
|
68
|
+
logger.info(
|
|
69
|
+
`[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
|
70
|
+
);
|
|
71
|
+
return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
|
|
72
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
|
2
|
+
|
|
3
|
+
import { sendMessage as sendMessageApi } from "../api/api.js";
|
|
4
|
+
import type { WeixinApiOptions } from "../api/api.js";
|
|
5
|
+
import { logger } from "../util/logger.js";
|
|
6
|
+
import { generateId } from "../util/random.js";
|
|
7
|
+
import type { MessageItem, SendMessageReq } from "../api/types.js";
|
|
8
|
+
import { MessageItemType, MessageState, MessageType } from "../api/types.js";
|
|
9
|
+
import type { UploadedFileInfo } from "../cdn/upload.js";
|
|
10
|
+
|
|
11
|
+
export { StreamingMarkdownFilter } from "./markdown-filter.js";
|
|
12
|
+
|
|
13
|
+
type WeixinMessageSendOptions = WeixinApiOptions & {
|
|
14
|
+
contextToken?: string;
|
|
15
|
+
runId?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function generateClientId(): string {
|
|
19
|
+
return generateId("openclaw-weixin");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Build a SendMessageReq containing a single text message. */
|
|
23
|
+
function buildTextMessageReq(params: {
|
|
24
|
+
to: string;
|
|
25
|
+
text: string;
|
|
26
|
+
contextToken?: string;
|
|
27
|
+
runId?: string;
|
|
28
|
+
clientId: string;
|
|
29
|
+
}): SendMessageReq {
|
|
30
|
+
const { to, text, contextToken, runId, clientId } = params;
|
|
31
|
+
const item_list: MessageItem[] = text
|
|
32
|
+
? [{ type: MessageItemType.TEXT, text_item: { text } }]
|
|
33
|
+
: [];
|
|
34
|
+
return {
|
|
35
|
+
msg: {
|
|
36
|
+
from_user_id: "",
|
|
37
|
+
to_user_id: to,
|
|
38
|
+
client_id: clientId,
|
|
39
|
+
message_type: MessageType.BOT,
|
|
40
|
+
message_state: MessageState.FINISH,
|
|
41
|
+
item_list: item_list.length ? item_list : undefined,
|
|
42
|
+
context_token: contextToken ?? undefined,
|
|
43
|
+
run_id: runId ?? undefined,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */
|
|
49
|
+
function buildSendMessageReq(params: {
|
|
50
|
+
to: string;
|
|
51
|
+
contextToken?: string;
|
|
52
|
+
runId?: string;
|
|
53
|
+
payload: ReplyPayload;
|
|
54
|
+
clientId: string;
|
|
55
|
+
}): SendMessageReq {
|
|
56
|
+
const { to, contextToken, runId, payload, clientId } = params;
|
|
57
|
+
return buildTextMessageReq({
|
|
58
|
+
to,
|
|
59
|
+
text: payload.text ?? "",
|
|
60
|
+
contextToken,
|
|
61
|
+
runId,
|
|
62
|
+
clientId,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Send a plain text message downstream.
|
|
68
|
+
*/
|
|
69
|
+
export async function sendMessageWeixin(params: {
|
|
70
|
+
to: string;
|
|
71
|
+
text: string;
|
|
72
|
+
opts: WeixinMessageSendOptions;
|
|
73
|
+
}): Promise<{ messageId: string }> {
|
|
74
|
+
const { to, text, opts } = params;
|
|
75
|
+
if (!opts.contextToken) {
|
|
76
|
+
logger.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
77
|
+
}
|
|
78
|
+
const clientId = generateClientId();
|
|
79
|
+
const req = buildSendMessageReq({
|
|
80
|
+
to,
|
|
81
|
+
contextToken: opts.contextToken,
|
|
82
|
+
runId: opts.runId,
|
|
83
|
+
payload: { text },
|
|
84
|
+
clientId,
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await sendMessageApi({
|
|
88
|
+
baseUrl: opts.baseUrl,
|
|
89
|
+
token: opts.token,
|
|
90
|
+
timeoutMs: opts.timeoutMs,
|
|
91
|
+
body: req,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
return { messageId: clientId };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Send a single structured MessageItem downstream. */
|
|
101
|
+
export async function sendMessageItemWeixin(params: {
|
|
102
|
+
to: string;
|
|
103
|
+
item: MessageItem;
|
|
104
|
+
opts: WeixinMessageSendOptions;
|
|
105
|
+
clientId?: string;
|
|
106
|
+
label?: string;
|
|
107
|
+
}): Promise<{ messageId: string }> {
|
|
108
|
+
const { to, item, opts } = params;
|
|
109
|
+
if (!opts.contextToken) {
|
|
110
|
+
logger.warn(`sendMessageItemWeixin: contextToken missing for to=${to}, sending without context`);
|
|
111
|
+
}
|
|
112
|
+
const clientId = params.clientId ?? generateClientId();
|
|
113
|
+
const req: SendMessageReq = {
|
|
114
|
+
msg: {
|
|
115
|
+
from_user_id: "",
|
|
116
|
+
to_user_id: to,
|
|
117
|
+
client_id: clientId,
|
|
118
|
+
message_type: MessageType.BOT,
|
|
119
|
+
message_state: MessageState.FINISH,
|
|
120
|
+
item_list: [item],
|
|
121
|
+
context_token: opts.contextToken ?? undefined,
|
|
122
|
+
run_id: opts.runId,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
await sendMessageApi({
|
|
127
|
+
baseUrl: opts.baseUrl,
|
|
128
|
+
token: opts.token,
|
|
129
|
+
timeoutMs: opts.timeoutMs,
|
|
130
|
+
body: req,
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.error(
|
|
134
|
+
`${params.label ?? "sendMessageItemWeixin"}: failed to=${to} clientId=${clientId} err=${String(err)}`,
|
|
135
|
+
);
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
return { messageId: clientId };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Send one or more MessageItems (optionally preceded by a text caption) downstream.
|
|
143
|
+
* Each item is sent as its own request so that item_list always has exactly one entry.
|
|
144
|
+
*/
|
|
145
|
+
async function sendMediaItems(params: {
|
|
146
|
+
to: string;
|
|
147
|
+
text: string;
|
|
148
|
+
mediaItem: MessageItem;
|
|
149
|
+
opts: WeixinMessageSendOptions;
|
|
150
|
+
label: string;
|
|
151
|
+
}): Promise<{ messageId: string }> {
|
|
152
|
+
const { to, text, mediaItem, opts, label } = params;
|
|
153
|
+
const runId = opts.runId;
|
|
154
|
+
|
|
155
|
+
const items: MessageItem[] = [];
|
|
156
|
+
if (text) {
|
|
157
|
+
items.push({ type: MessageItemType.TEXT, text_item: { text } });
|
|
158
|
+
}
|
|
159
|
+
items.push(mediaItem);
|
|
160
|
+
|
|
161
|
+
let lastClientId = "";
|
|
162
|
+
for (const item of items) {
|
|
163
|
+
lastClientId = generateClientId();
|
|
164
|
+
const req: SendMessageReq = {
|
|
165
|
+
msg: {
|
|
166
|
+
from_user_id: "",
|
|
167
|
+
to_user_id: to,
|
|
168
|
+
client_id: lastClientId,
|
|
169
|
+
message_type: MessageType.BOT,
|
|
170
|
+
message_state: MessageState.FINISH,
|
|
171
|
+
item_list: [item],
|
|
172
|
+
context_token: opts.contextToken ?? undefined,
|
|
173
|
+
run_id: runId,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
await sendMessageApi({
|
|
178
|
+
baseUrl: opts.baseUrl,
|
|
179
|
+
token: opts.token,
|
|
180
|
+
timeoutMs: opts.timeoutMs,
|
|
181
|
+
body: req,
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
logger.error(
|
|
185
|
+
`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
|
|
186
|
+
);
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
logger.info(`${label}: success to=${to} clientId=${lastClientId}`);
|
|
192
|
+
return { messageId: lastClientId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send an image message downstream using a previously uploaded file.
|
|
197
|
+
* Optionally include a text caption as a separate TEXT item before the image.
|
|
198
|
+
*
|
|
199
|
+
* ImageItem fields:
|
|
200
|
+
* - media.encrypt_query_param: CDN download param
|
|
201
|
+
* - media.aes_key: AES key, base64-encoded
|
|
202
|
+
* - mid_size: original ciphertext file size
|
|
203
|
+
*/
|
|
204
|
+
export async function sendImageMessageWeixin(params: {
|
|
205
|
+
to: string;
|
|
206
|
+
text: string;
|
|
207
|
+
uploaded: UploadedFileInfo;
|
|
208
|
+
opts: WeixinMessageSendOptions;
|
|
209
|
+
}): Promise<{ messageId: string }> {
|
|
210
|
+
const { to, text, uploaded, opts } = params;
|
|
211
|
+
if (!opts.contextToken) {
|
|
212
|
+
logger.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
213
|
+
}
|
|
214
|
+
logger.info(
|
|
215
|
+
`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const imageItem: MessageItem = {
|
|
219
|
+
type: MessageItemType.IMAGE,
|
|
220
|
+
image_item: {
|
|
221
|
+
media: {
|
|
222
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
223
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
224
|
+
encrypt_type: 1,
|
|
225
|
+
},
|
|
226
|
+
mid_size: uploaded.fileSizeCiphertext,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Send a video message downstream using a previously uploaded file.
|
|
235
|
+
* VideoItem: media (CDN ref), video_size (ciphertext bytes).
|
|
236
|
+
* Includes an optional text caption sent as a separate TEXT item first.
|
|
237
|
+
*/
|
|
238
|
+
export async function sendVideoMessageWeixin(params: {
|
|
239
|
+
to: string;
|
|
240
|
+
text: string;
|
|
241
|
+
uploaded: UploadedFileInfo;
|
|
242
|
+
opts: WeixinMessageSendOptions;
|
|
243
|
+
}): Promise<{ messageId: string }> {
|
|
244
|
+
const { to, text, uploaded, opts } = params;
|
|
245
|
+
if (!opts.contextToken) {
|
|
246
|
+
logger.warn(`sendVideoMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const videoItem: MessageItem = {
|
|
250
|
+
type: MessageItemType.VIDEO,
|
|
251
|
+
video_item: {
|
|
252
|
+
media: {
|
|
253
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
254
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
255
|
+
encrypt_type: 1,
|
|
256
|
+
},
|
|
257
|
+
video_size: uploaded.fileSizeCiphertext,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Send a file attachment downstream using a previously uploaded file.
|
|
266
|
+
* FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
|
|
267
|
+
* Includes an optional text caption sent as a separate TEXT item first.
|
|
268
|
+
*/
|
|
269
|
+
export async function sendFileMessageWeixin(params: {
|
|
270
|
+
to: string;
|
|
271
|
+
text: string;
|
|
272
|
+
fileName: string;
|
|
273
|
+
uploaded: UploadedFileInfo;
|
|
274
|
+
opts: WeixinMessageSendOptions;
|
|
275
|
+
}): Promise<{ messageId: string }> {
|
|
276
|
+
const { to, text, fileName, uploaded, opts } = params;
|
|
277
|
+
if (!opts.contextToken) {
|
|
278
|
+
logger.warn(`sendFileMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
279
|
+
}
|
|
280
|
+
const fileItem: MessageItem = {
|
|
281
|
+
type: MessageItemType.FILE,
|
|
282
|
+
file_item: {
|
|
283
|
+
media: {
|
|
284
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
285
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
286
|
+
encrypt_type: 1,
|
|
287
|
+
},
|
|
288
|
+
file_name: fileName,
|
|
289
|
+
len: String(uploaded.fileSize),
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
|
|
294
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weixin 斜杠指令处理模块
|
|
3
|
+
*
|
|
4
|
+
* 支持的指令:
|
|
5
|
+
* - /echo <message> 直接回复消息(不经过 AI),并附带通道耗时统计
|
|
6
|
+
* - /toggle-debug 开关 debug 模式,启用后每条 AI 回复追加全链路耗时
|
|
7
|
+
*/
|
|
8
|
+
import type { WeixinApiOptions } from "../api/api.js";
|
|
9
|
+
import { logger } from "../util/logger.js";
|
|
10
|
+
|
|
11
|
+
import { toggleDebugMode, isDebugMode } from "./debug-mode.js";
|
|
12
|
+
import { sendMessageWeixin } from "./send.js";
|
|
13
|
+
|
|
14
|
+
export interface SlashCommandResult {
|
|
15
|
+
/** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */
|
|
16
|
+
handled: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SlashCommandContext {
|
|
20
|
+
to: string;
|
|
21
|
+
contextToken?: string;
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
token?: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
log: (msg: string) => void;
|
|
26
|
+
errLog: (msg: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 发送回复消息 */
|
|
30
|
+
async function sendReply(ctx: SlashCommandContext, text: string): Promise<void> {
|
|
31
|
+
const opts: WeixinApiOptions & { contextToken?: string } = {
|
|
32
|
+
baseUrl: ctx.baseUrl,
|
|
33
|
+
token: ctx.token,
|
|
34
|
+
contextToken: ctx.contextToken,
|
|
35
|
+
};
|
|
36
|
+
await sendMessageWeixin({ to: ctx.to, text, opts });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 处理 /echo 指令 */
|
|
40
|
+
async function handleEcho(
|
|
41
|
+
ctx: SlashCommandContext,
|
|
42
|
+
args: string,
|
|
43
|
+
receivedAt: number,
|
|
44
|
+
eventTimestamp?: number,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const message = args.trim();
|
|
47
|
+
if (message) {
|
|
48
|
+
await sendReply(ctx, message);
|
|
49
|
+
}
|
|
50
|
+
const eventTs = eventTimestamp ?? 0;
|
|
51
|
+
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
|
|
52
|
+
const timing = [
|
|
53
|
+
"⏱ 通道耗时",
|
|
54
|
+
`├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
|
|
55
|
+
`├ 平台→插件: ${platformDelay}`,
|
|
56
|
+
`└ 插件处理: ${Date.now() - receivedAt}ms`,
|
|
57
|
+
].join("\n");
|
|
58
|
+
await sendReply(ctx, timing);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 尝试处理斜杠指令
|
|
63
|
+
*
|
|
64
|
+
* @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道
|
|
65
|
+
*/
|
|
66
|
+
export async function handleSlashCommand(
|
|
67
|
+
content: string,
|
|
68
|
+
ctx: SlashCommandContext,
|
|
69
|
+
receivedAt: number,
|
|
70
|
+
eventTimestamp?: number,
|
|
71
|
+
): Promise<SlashCommandResult> {
|
|
72
|
+
const trimmed = content.trim();
|
|
73
|
+
if (!trimmed.startsWith("/")) {
|
|
74
|
+
return { handled: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
78
|
+
const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
|
|
79
|
+
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
|
|
80
|
+
|
|
81
|
+
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
switch (command) {
|
|
85
|
+
case "/echo":
|
|
86
|
+
await handleEcho(ctx, args, receivedAt, eventTimestamp);
|
|
87
|
+
return { handled: true };
|
|
88
|
+
case "/toggle-debug": {
|
|
89
|
+
const enabled = toggleDebugMode(ctx.accountId);
|
|
90
|
+
await sendReply(
|
|
91
|
+
ctx,
|
|
92
|
+
enabled
|
|
93
|
+
? "Debug 模式已开启"
|
|
94
|
+
: "Debug 模式已关闭",
|
|
95
|
+
);
|
|
96
|
+
return { handled: true };
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
return { handled: false };
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.error(`[weixin] Slash command error: ${String(err)}`);
|
|
103
|
+
try {
|
|
104
|
+
await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
|
|
105
|
+
} catch {
|
|
106
|
+
// 发送错误消息也失败了,只能记日志
|
|
107
|
+
}
|
|
108
|
+
return { handled: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
import { getUpdates, classifyFetchError } from "../api/api.js";
|
|
5
|
+
import { WeixinConfigManager } from "../api/config-cache.js";
|
|
6
|
+
import { STALE_TOKEN_ERRCODE, pauseSession, getRemainingPauseMs } from "../api/session-guard.js";
|
|
7
|
+
import { processOneMessage } from "../messaging/process-message.js";
|
|
8
|
+
import { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js";
|
|
9
|
+
import { logger } from "../util/logger.js";
|
|
10
|
+
import type { Logger } from "../util/logger.js";
|
|
11
|
+
import { redactBody } from "../util/redact.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
14
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
15
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
16
|
+
const RETRY_DELAY_MS = 2_000;
|
|
17
|
+
|
|
18
|
+
export type MonitorWeixinOpts = {
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
cdnBaseUrl: string;
|
|
21
|
+
token?: string;
|
|
22
|
+
accountId: string;
|
|
23
|
+
/** When non-empty, only messages whose from_user_id is in this list are processed. */
|
|
24
|
+
allowFrom?: string[];
|
|
25
|
+
config: import("openclaw/plugin-sdk/core").OpenClawConfig;
|
|
26
|
+
runtime?: { log?: (msg: string) => void; error?: (msg: string) => void };
|
|
27
|
+
/**
|
|
28
|
+
* Gateway-injected channel runtime surface (reply/routing/session/media/commands/...).
|
|
29
|
+
* Required for inbound message processing; provided by `ChannelGatewayContext.channelRuntime`.
|
|
30
|
+
*/
|
|
31
|
+
channelRuntime: PluginRuntime["channel"];
|
|
32
|
+
abortSignal?: AbortSignal;
|
|
33
|
+
longPollTimeoutMs?: number;
|
|
34
|
+
/** Gateway status callback — called on each successful poll and inbound message. */
|
|
35
|
+
setStatus?: (next: ChannelAccountSnapshot) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.
|
|
40
|
+
* Runs until abort.
|
|
41
|
+
*/
|
|
42
|
+
export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
|
|
43
|
+
const {
|
|
44
|
+
baseUrl,
|
|
45
|
+
cdnBaseUrl,
|
|
46
|
+
token,
|
|
47
|
+
accountId,
|
|
48
|
+
config,
|
|
49
|
+
channelRuntime,
|
|
50
|
+
abortSignal,
|
|
51
|
+
longPollTimeoutMs,
|
|
52
|
+
setStatus,
|
|
53
|
+
} = opts;
|
|
54
|
+
const log = opts.runtime?.log ?? (() => {});
|
|
55
|
+
const errLog = opts.runtime?.error ?? ((m: string) => log(m));
|
|
56
|
+
const aLog: Logger = logger.withAccount(accountId);
|
|
57
|
+
|
|
58
|
+
if (!channelRuntime) {
|
|
59
|
+
const msg =
|
|
60
|
+
"channelRuntime missing on monitor opts; gateway must inject ChannelGatewayContext.channelRuntime";
|
|
61
|
+
aLog.error(msg);
|
|
62
|
+
throw new Error(msg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log(`weixin monitor started (${baseUrl}, account=${accountId})`);
|
|
66
|
+
aLog.info(
|
|
67
|
+
`Monitor started: baseUrl=${baseUrl} timeoutMs=${longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS}`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const syncFilePath = getSyncBufFilePath(accountId);
|
|
71
|
+
aLog.debug(`syncFilePath: ${syncFilePath}`);
|
|
72
|
+
|
|
73
|
+
const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
|
|
74
|
+
let getUpdatesBuf = previousGetUpdatesBuf ?? "";
|
|
75
|
+
|
|
76
|
+
if (previousGetUpdatesBuf) {
|
|
77
|
+
log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
|
|
78
|
+
aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`);
|
|
79
|
+
} else {
|
|
80
|
+
log(`[weixin] no previous sync buf, starting fresh`);
|
|
81
|
+
aLog.info(`No previous get_updates_buf found, starting fresh`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const configManager = new WeixinConfigManager({ baseUrl, token }, log);
|
|
85
|
+
|
|
86
|
+
let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
87
|
+
let consecutiveFailures = 0;
|
|
88
|
+
|
|
89
|
+
while (!abortSignal?.aborted) {
|
|
90
|
+
try {
|
|
91
|
+
aLog.debug(
|
|
92
|
+
`getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`,
|
|
93
|
+
);
|
|
94
|
+
const resp = await getUpdates({
|
|
95
|
+
baseUrl,
|
|
96
|
+
token,
|
|
97
|
+
get_updates_buf: getUpdatesBuf,
|
|
98
|
+
timeoutMs: nextTimeoutMs,
|
|
99
|
+
// Stop/hot-reload should cancel the in-flight long-poll immediately
|
|
100
|
+
// instead of waiting for the server-side long-poll timeout.
|
|
101
|
+
abortSignal,
|
|
102
|
+
});
|
|
103
|
+
aLog.debug(
|
|
104
|
+
`getUpdates response: ret=${resp.ret}, msgs=${resp.msgs?.length ?? 0}, get_updates_buf_length=${resp.get_updates_buf?.length ?? 0}`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
108
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
109
|
+
aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`);
|
|
110
|
+
}
|
|
111
|
+
const isApiError =
|
|
112
|
+
(resp.ret !== undefined && resp.ret !== 0) ||
|
|
113
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
114
|
+
if (isApiError) {
|
|
115
|
+
const isStaleToken =
|
|
116
|
+
resp.errcode === STALE_TOKEN_ERRCODE || resp.ret === STALE_TOKEN_ERRCODE;
|
|
117
|
+
|
|
118
|
+
if (isStaleToken) {
|
|
119
|
+
pauseSession(accountId);
|
|
120
|
+
const pauseMs = getRemainingPauseMs(accountId);
|
|
121
|
+
aLog.error(
|
|
122
|
+
`getUpdates: token for ${accountId} is stale, pausing all requests for ${Math.ceil(pauseMs / 60_000)} min`,
|
|
123
|
+
);
|
|
124
|
+
consecutiveFailures = 0;
|
|
125
|
+
await sleep(pauseMs, abortSignal);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
consecutiveFailures += 1;
|
|
130
|
+
errLog(
|
|
131
|
+
`weixin getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`,
|
|
132
|
+
);
|
|
133
|
+
aLog.error(
|
|
134
|
+
`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg} response=${redactBody(JSON.stringify(resp))}`,
|
|
135
|
+
);
|
|
136
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
137
|
+
errLog(
|
|
138
|
+
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
|
139
|
+
);
|
|
140
|
+
aLog.error(
|
|
141
|
+
`getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
|
142
|
+
);
|
|
143
|
+
consecutiveFailures = 0;
|
|
144
|
+
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
145
|
+
} else {
|
|
146
|
+
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
consecutiveFailures = 0;
|
|
151
|
+
setStatus?.({ accountId, lastEventAt: Date.now() });
|
|
152
|
+
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
|
|
153
|
+
saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
|
|
154
|
+
getUpdatesBuf = resp.get_updates_buf;
|
|
155
|
+
aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`);
|
|
156
|
+
}
|
|
157
|
+
const list = resp.msgs ?? [];
|
|
158
|
+
for (const full of list) {
|
|
159
|
+
aLog.info(
|
|
160
|
+
`inbound message: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
setStatus?.({ accountId, lastEventAt: now, lastInboundAt: now });
|
|
165
|
+
|
|
166
|
+
// allowFrom filtering is delegated to processOneMessage via the framework
|
|
167
|
+
// authorization pipeline (resolveSenderCommandAuthorizationWithRuntime).
|
|
168
|
+
|
|
169
|
+
const fromUserId = full.from_user_id ?? "";
|
|
170
|
+
const cachedConfig = await configManager.getForUser(fromUserId, full.context_token);
|
|
171
|
+
|
|
172
|
+
await processOneMessage(full, {
|
|
173
|
+
accountId,
|
|
174
|
+
config,
|
|
175
|
+
channelRuntime,
|
|
176
|
+
baseUrl,
|
|
177
|
+
cdnBaseUrl,
|
|
178
|
+
token,
|
|
179
|
+
typingTicket: cachedConfig.typingTicket,
|
|
180
|
+
log: opts.runtime?.log ?? (() => {}),
|
|
181
|
+
errLog,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (abortSignal?.aborted) {
|
|
186
|
+
aLog.info(`Monitor stopped (aborted)`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
consecutiveFailures += 1;
|
|
190
|
+
const classified = classifyFetchError(err);
|
|
191
|
+
errLog(
|
|
192
|
+
`weixin getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)} type=${classified.type} description=${classified.description}${classified.code ? ` code=${classified.code}` : ""}`,
|
|
193
|
+
);
|
|
194
|
+
aLog.error(`getUpdates error: ${String(err)}, type=${classified.type} code=${classified.code ?? "none"}, stack=${(err as Error).stack}`);
|
|
195
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
196
|
+
errLog(
|
|
197
|
+
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
|
198
|
+
);
|
|
199
|
+
aLog.error(
|
|
200
|
+
`getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
|
|
201
|
+
);
|
|
202
|
+
consecutiveFailures = 0;
|
|
203
|
+
await sleep(30_000, abortSignal);
|
|
204
|
+
} else {
|
|
205
|
+
await sleep(2000, abortSignal);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
aLog.info(`Monitor ended`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const t = setTimeout(resolve, ms);
|
|
215
|
+
signal?.addEventListener(
|
|
216
|
+
"abort",
|
|
217
|
+
() => {
|
|
218
|
+
clearTimeout(t);
|
|
219
|
+
reject(new Error("aborted"));
|
|
220
|
+
},
|
|
221
|
+
{ once: true },
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
}
|