添加完整的微信插件测试框架,包含: - 微信插件源码(weixin-plugin/) - TypeScript 类型定义(types.ts) - 插件编译脚本(scripts/build-weixin-plugin.sh) - 简化测试脚本(scripts/test-weixin-simple.js) - 完整测试脚本(scripts/test-weixin-plugin.js) - 项目配置和依赖管理(package.json) - 使用说明文档(README.md) 主要功能: - 支持二维码登录认证 - HTTP 长轮询消息接收 - 文本、图片、视频、文件消息收发 - 自动回复测试 - 运行状态统计 Generated with [Claude Code](https://claude.com/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
209 lines
6.2 KiB
TypeScript
209 lines
6.2 KiB
TypeScript
import { stripMarkdown } from "./strip-markdown.js";
|
|
|
|
import { sendMessage as sendMessageApi } from "../api/api.js";
|
|
import type { WeixinApiOptions } from "../api/api.js";
|
|
import { logger } from "../util/logger.js";
|
|
import { generateId } from "../util/random.js";
|
|
import type { MessageItem, SendMessageReq } from "../api/types.js";
|
|
import { MessageItemType, MessageState, MessageType } from "../api/types.js";
|
|
import type { UploadedFileInfo } from "../cdn/upload.js";
|
|
|
|
function generateClientId(): string {
|
|
return generateId("openclaw-weixin");
|
|
}
|
|
|
|
export function markdownToPlainText(text: string): string {
|
|
let result = text;
|
|
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_: string, code: string) => code.trim());
|
|
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
|
|
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
|
result = result.replace(/^\|[\s:|-]+\|$/gm, "");
|
|
result = result.replace(/^\|(.+)\|$/gm, (_: string, inner: string) =>
|
|
inner.split("|").map((cell) => cell.trim()).join(" "),
|
|
);
|
|
result = stripMarkdown(result);
|
|
return result;
|
|
}
|
|
|
|
function buildTextMessageReq(params: {
|
|
to: string;
|
|
text: string;
|
|
contextToken?: string;
|
|
clientId: string;
|
|
}): SendMessageReq {
|
|
const { to, text, contextToken, clientId } = params;
|
|
const item_list: MessageItem[] = text
|
|
? [{ type: MessageItemType.TEXT, text_item: { text } }]
|
|
: [];
|
|
return {
|
|
msg: {
|
|
from_user_id: "",
|
|
to_user_id: to,
|
|
client_id: clientId,
|
|
message_type: MessageType.BOT,
|
|
message_state: MessageState.FINISH,
|
|
item_list: item_list.length ? item_list : undefined,
|
|
context_token: contextToken ?? undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function sendMessageWeixin(params: {
|
|
to: string;
|
|
text: string;
|
|
opts: WeixinApiOptions & { contextToken?: string };
|
|
}): Promise<{ messageId: string }> {
|
|
const { to, text, opts } = params;
|
|
if (!opts.contextToken) {
|
|
logger.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
}
|
|
const clientId = generateClientId();
|
|
const req = buildTextMessageReq({
|
|
to,
|
|
text,
|
|
contextToken: opts.contextToken,
|
|
clientId,
|
|
});
|
|
try {
|
|
await sendMessageApi({
|
|
baseUrl: opts.baseUrl,
|
|
token: opts.token,
|
|
timeoutMs: opts.timeoutMs,
|
|
body: req,
|
|
});
|
|
} catch (err) {
|
|
logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
|
|
throw err;
|
|
}
|
|
return { messageId: clientId };
|
|
}
|
|
|
|
async function sendMediaItems(params: {
|
|
to: string;
|
|
text: string;
|
|
mediaItem: MessageItem;
|
|
opts: WeixinApiOptions & { contextToken?: string };
|
|
label: string;
|
|
}): Promise<{ messageId: string }> {
|
|
const { to, text, mediaItem, opts, label } = params;
|
|
|
|
const items: MessageItem[] = [];
|
|
if (text) {
|
|
items.push({ type: MessageItemType.TEXT, text_item: { text } });
|
|
}
|
|
items.push(mediaItem);
|
|
|
|
let lastClientId = "";
|
|
for (const item of items) {
|
|
lastClientId = generateClientId();
|
|
const req: SendMessageReq = {
|
|
msg: {
|
|
from_user_id: "",
|
|
to_user_id: to,
|
|
client_id: lastClientId,
|
|
message_type: MessageType.BOT,
|
|
message_state: MessageState.FINISH,
|
|
item_list: [item],
|
|
context_token: opts.contextToken ?? undefined,
|
|
},
|
|
};
|
|
try {
|
|
await sendMessageApi({
|
|
baseUrl: opts.baseUrl,
|
|
token: opts.token,
|
|
timeoutMs: opts.timeoutMs,
|
|
body: req,
|
|
});
|
|
} catch (err) {
|
|
logger.error(
|
|
`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
logger.info(`${label}: success to=${to} clientId=${lastClientId}`);
|
|
return { messageId: lastClientId };
|
|
}
|
|
|
|
export async function sendImageMessageWeixin(params: {
|
|
to: string;
|
|
text: string;
|
|
uploaded: UploadedFileInfo;
|
|
opts: WeixinApiOptions & { contextToken?: string };
|
|
}): Promise<{ messageId: string }> {
|
|
const { to, text, uploaded, opts } = params;
|
|
if (!opts.contextToken) {
|
|
logger.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
}
|
|
logger.info(
|
|
`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
|
|
);
|
|
|
|
const imageItem: MessageItem = {
|
|
type: MessageItemType.IMAGE,
|
|
image_item: {
|
|
media: {
|
|
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
encrypt_type: 1,
|
|
},
|
|
mid_size: uploaded.fileSizeCiphertext,
|
|
},
|
|
};
|
|
|
|
return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
|
|
}
|
|
|
|
export async function sendVideoMessageWeixin(params: {
|
|
to: string;
|
|
text: string;
|
|
uploaded: UploadedFileInfo;
|
|
opts: WeixinApiOptions & { contextToken?: string };
|
|
}): Promise<{ messageId: string }> {
|
|
const { to, text, uploaded, opts } = params;
|
|
if (!opts.contextToken) {
|
|
logger.warn(`sendVideoMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
}
|
|
|
|
const videoItem: MessageItem = {
|
|
type: MessageItemType.VIDEO,
|
|
video_item: {
|
|
media: {
|
|
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
encrypt_type: 1,
|
|
},
|
|
video_size: uploaded.fileSizeCiphertext,
|
|
},
|
|
};
|
|
|
|
return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
|
|
}
|
|
|
|
export async function sendFileMessageWeixin(params: {
|
|
to: string;
|
|
text: string;
|
|
fileName: string;
|
|
uploaded: UploadedFileInfo;
|
|
opts: WeixinApiOptions & { contextToken?: string };
|
|
}): Promise<{ messageId: string }> {
|
|
const { to, text, fileName, uploaded, opts } = params;
|
|
if (!opts.contextToken) {
|
|
logger.warn(`sendFileMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
}
|
|
const fileItem: MessageItem = {
|
|
type: MessageItemType.FILE,
|
|
file_item: {
|
|
media: {
|
|
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
encrypt_type: 1,
|
|
},
|
|
file_name: fileName,
|
|
len: String(uploaded.fileSize),
|
|
},
|
|
};
|
|
|
|
return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
|
|
} |