@wwlocal/aibot-plugin-node 20260409.20.0
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/README.md +489 -0
- package/config.example.json +169 -0
- package/dist/cjs/index.js +76 -0
- package/dist/cjs/src/adapters/anthropic-adapter.js +534 -0
- package/dist/cjs/src/adapters/base-adapter.js +176 -0
- package/dist/cjs/src/adapters/deepseek-adapter.js +328 -0
- package/dist/cjs/src/adapters/dify-adapter.js +636 -0
- package/dist/cjs/src/adapters/index.js +131 -0
- package/dist/cjs/src/adapters/openai-adapter.js +361 -0
- package/dist/cjs/src/adapters/webhook-adapter.js +260 -0
- package/dist/cjs/src/agent-forwarder.js +87 -0
- package/dist/cjs/src/ca-cert.js +162 -0
- package/dist/cjs/src/config.js +169 -0
- package/dist/cjs/src/const.js +124 -0
- package/dist/cjs/src/conversation-manager.js +147 -0
- package/dist/cjs/src/dm-policy.js +46 -0
- package/dist/cjs/src/group-policy.js +95 -0
- package/dist/cjs/src/media-handler.js +136 -0
- package/dist/cjs/src/media-loader.js +271 -0
- package/dist/cjs/src/media-storage.js +165 -0
- package/dist/cjs/src/media-uploader.js +203 -0
- package/dist/cjs/src/message-parser.js +133 -0
- package/dist/cjs/src/message-sender.js +87 -0
- package/dist/cjs/src/monitor.js +849 -0
- package/dist/cjs/src/reqid-store.js +87 -0
- package/dist/cjs/src/server.js +72 -0
- package/dist/cjs/src/service-manager.js +135 -0
- package/dist/cjs/src/state-manager.js +143 -0
- package/dist/cjs/src/template-card-parser.js +498 -0
- package/dist/cjs/src/timeout.js +41 -0
- package/dist/cjs/src/version.js +25 -0
- package/dist/esm/index.js +74 -0
- package/dist/esm/src/adapters/anthropic-adapter.js +512 -0
- package/dist/esm/src/adapters/base-adapter.js +174 -0
- package/dist/esm/src/adapters/deepseek-adapter.js +326 -0
- package/dist/esm/src/adapters/dify-adapter.js +634 -0
- package/dist/esm/src/adapters/index.js +123 -0
- package/dist/esm/src/adapters/openai-adapter.js +339 -0
- package/dist/esm/src/adapters/webhook-adapter.js +258 -0
- package/dist/esm/src/agent-forwarder.js +84 -0
- package/dist/esm/src/ca-cert.js +136 -0
- package/dist/esm/src/config.js +145 -0
- package/dist/esm/src/const.js +100 -0
- package/dist/esm/src/conversation-manager.js +144 -0
- package/dist/esm/src/dm-policy.js +44 -0
- package/dist/esm/src/group-policy.js +92 -0
- package/dist/esm/src/media-handler.js +133 -0
- package/dist/esm/src/media-loader.js +246 -0
- package/dist/esm/src/media-storage.js +143 -0
- package/dist/esm/src/media-uploader.js +198 -0
- package/dist/esm/src/message-parser.js +131 -0
- package/dist/esm/src/message-sender.js +83 -0
- package/dist/esm/src/monitor.js +841 -0
- package/dist/esm/src/reqid-store.js +85 -0
- package/dist/esm/src/server.js +69 -0
- package/dist/esm/src/service-manager.js +133 -0
- package/dist/esm/src/state-manager.js +134 -0
- package/dist/esm/src/template-card-parser.js +495 -0
- package/dist/esm/src/timeout.js +38 -0
- package/dist/esm/src/version.js +22 -0
- package/dist/esm/types/index.d.ts +14 -0
- package/dist/esm/types/src/adapters/anthropic-adapter.d.ts +93 -0
- package/dist/esm/types/src/adapters/base-adapter.d.ts +76 -0
- package/dist/esm/types/src/adapters/deepseek-adapter.d.ts +87 -0
- package/dist/esm/types/src/adapters/dify-adapter.d.ts +100 -0
- package/dist/esm/types/src/adapters/index.d.ts +60 -0
- package/dist/esm/types/src/adapters/openai-adapter.d.ts +82 -0
- package/dist/esm/types/src/adapters/types.d.ts +373 -0
- package/dist/esm/types/src/adapters/webhook-adapter.d.ts +54 -0
- package/dist/esm/types/src/agent-forwarder.d.ts +32 -0
- package/dist/esm/types/src/ca-cert.d.ts +53 -0
- package/dist/esm/types/src/config.d.ts +29 -0
- package/dist/esm/types/src/const.d.ts +74 -0
- package/dist/esm/types/src/conversation-manager.d.ts +81 -0
- package/dist/esm/types/src/dm-policy.d.ts +27 -0
- package/dist/esm/types/src/group-policy.d.ts +28 -0
- package/dist/esm/types/src/interface.d.ts +332 -0
- package/dist/esm/types/src/media-handler.d.ts +36 -0
- package/dist/esm/types/src/media-loader.d.ts +47 -0
- package/dist/esm/types/src/media-storage.d.ts +35 -0
- package/dist/esm/types/src/media-uploader.d.ts +65 -0
- package/dist/esm/types/src/message-parser.d.ts +89 -0
- package/dist/esm/types/src/message-sender.d.ts +34 -0
- package/dist/esm/types/src/monitor.d.ts +30 -0
- package/dist/esm/types/src/reqid-store.d.ts +23 -0
- package/dist/esm/types/src/server.d.ts +23 -0
- package/dist/esm/types/src/service-manager.d.ts +52 -0
- package/dist/esm/types/src/state-manager.d.ts +76 -0
- package/dist/esm/types/src/template-card-parser.d.ts +18 -0
- package/dist/esm/types/src/timeout.d.ts +20 -0
- package/dist/esm/types/src/version.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/package.json +51 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信私有部署渠道常量定义
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 渠道 ID
|
|
6
|
+
*/
|
|
7
|
+
const CHANNEL_ID = "wecomserver";
|
|
8
|
+
/**
|
|
9
|
+
* 企业微信私有部署 WebSocket 命令枚举
|
|
10
|
+
*/
|
|
11
|
+
var WeComCommand;
|
|
12
|
+
(function (WeComCommand) {
|
|
13
|
+
/** 认证订阅 */
|
|
14
|
+
WeComCommand["SUBSCRIBE"] = "aibot_subscribe";
|
|
15
|
+
/** 心跳 */
|
|
16
|
+
WeComCommand["PING"] = "ping";
|
|
17
|
+
/** 企业微信推送消息 */
|
|
18
|
+
WeComCommand["AIBOT_CALLBACK"] = "aibot_callback";
|
|
19
|
+
/** 响应消息 */
|
|
20
|
+
WeComCommand["AIBOT_RESPONSE"] = "aibot_response";
|
|
21
|
+
})(WeComCommand || (WeComCommand = {}));
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// 超时和重试配置
|
|
24
|
+
// ============================================================================
|
|
25
|
+
/** 图片下载超时时间(毫秒) */
|
|
26
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
27
|
+
/** 文件下载超时时间(毫秒) */
|
|
28
|
+
const FILE_DOWNLOAD_TIMEOUT_MS = 60000;
|
|
29
|
+
/** 消息发送超时时间(毫秒) */
|
|
30
|
+
const REPLY_SEND_TIMEOUT_MS = 15000;
|
|
31
|
+
/** WebSocket 心跳间隔(毫秒) */
|
|
32
|
+
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
33
|
+
/** WebSocket 连接断开时的最大重连次数 */
|
|
34
|
+
const WS_MAX_RECONNECT_ATTEMPTS = 10;
|
|
35
|
+
/** WebSocket 认证失败时的最大重试次数 */
|
|
36
|
+
const WS_MAX_AUTH_FAILURE_ATTEMPTS = 5;
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// 消息状态管理配置
|
|
39
|
+
// ============================================================================
|
|
40
|
+
/** messageStates Map 条目的最大 TTL(毫秒),防止内存泄漏 */
|
|
41
|
+
const MESSAGE_STATE_TTL_MS = 10 * 60 * 1000;
|
|
42
|
+
/** messageStates Map 清理间隔(毫秒) */
|
|
43
|
+
const MESSAGE_STATE_CLEANUP_INTERVAL_MS = 60000;
|
|
44
|
+
/** messageStates Map 最大条目数 */
|
|
45
|
+
const MESSAGE_STATE_MAX_SIZE = 500;
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// 消息模板
|
|
48
|
+
// ============================================================================
|
|
49
|
+
/** "思考中"流式消息占位内容 */
|
|
50
|
+
const THINKING_MESSAGE = "<think></think>";
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// 流式 deliver 节流配置(monitor 层统一节流)
|
|
53
|
+
// ============================================================================
|
|
54
|
+
/** monitor 层中间帧 deliver 节流:最小发送间隔(毫秒) */
|
|
55
|
+
const MONITOR_DELIVER_THROTTLE_MS = 1000;
|
|
56
|
+
/** monitor 层中间帧 deliver 节流:最少新增字符数才触发 deliver */
|
|
57
|
+
const MONITOR_DELIVER_MIN_DELTA = 30;
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// 默认值
|
|
60
|
+
// ============================================================================
|
|
61
|
+
/** 默认媒体大小上限(MB) */
|
|
62
|
+
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// 媒体上传相关常量
|
|
65
|
+
// ============================================================================
|
|
66
|
+
/** 图片大小上限(字节):10MB */
|
|
67
|
+
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
68
|
+
/** 视频大小上限(字节):10MB */
|
|
69
|
+
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
70
|
+
/** 语音大小上限(字节):2MB */
|
|
71
|
+
const VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
72
|
+
/** 文件大小上限(字节):20MB */
|
|
73
|
+
const FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
74
|
+
/** 文件绝对上限(字节):超过此值无法发送,等于 FILE_MAX_BYTES */
|
|
75
|
+
const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// 事件/命令名称常量
|
|
78
|
+
// ============================================================================
|
|
79
|
+
/** 版本检查事件名称(SDK 事件监听用) */
|
|
80
|
+
const EVENT_ENTER_CHECK_UPDATE = "event.enter_check_update";
|
|
81
|
+
/** 版本检查事件回复命令名称 */
|
|
82
|
+
const CMD_ENTER_EVENT_REPLY = "ww_ai_robot_enter_event";
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// SDK 连接配置
|
|
85
|
+
// ============================================================================
|
|
86
|
+
/** WSClient scene 参数:企微 AI 机器人场景 */
|
|
87
|
+
const SCENE_WECOM_AIBOT = 1;
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// 模板卡片配置
|
|
90
|
+
// ============================================================================
|
|
91
|
+
/** 合法的模板卡片 card_type 列表 */
|
|
92
|
+
const VALID_CARD_TYPES = [
|
|
93
|
+
"text_notice",
|
|
94
|
+
"news_notice",
|
|
95
|
+
"button_interaction",
|
|
96
|
+
"vote_interaction",
|
|
97
|
+
"multiple_interaction",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export { ABSOLUTE_MAX_BYTES, CHANNEL_ID, CMD_ENTER_EVENT_REPLY, DEFAULT_MEDIA_MAX_MB, EVENT_ENTER_CHECK_UPDATE, FILE_DOWNLOAD_TIMEOUT_MS, FILE_MAX_BYTES, IMAGE_DOWNLOAD_TIMEOUT_MS, IMAGE_MAX_BYTES, MESSAGE_STATE_CLEANUP_INTERVAL_MS, MESSAGE_STATE_MAX_SIZE, MESSAGE_STATE_TTL_MS, MONITOR_DELIVER_MIN_DELTA, MONITOR_DELIVER_THROTTLE_MS, REPLY_SEND_TIMEOUT_MS, SCENE_WECOM_AIBOT, THINKING_MESSAGE, VALID_CARD_TYPES, VIDEO_MAX_BYTES, VOICE_MAX_BYTES, WS_HEARTBEAT_INTERVAL_MS, WS_MAX_AUTH_FAILURE_ATTEMPTS, WS_MAX_RECONNECT_ATTEMPTS, WeComCommand };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对话历史管理器
|
|
3
|
+
*
|
|
4
|
+
* 按 chatId 维护多轮对话的 messages 数组(内存缓存)
|
|
5
|
+
* - 支持配置最大保留轮数(maxRounds)
|
|
6
|
+
* - 带 TTL 自动过期(默认 2 小时)
|
|
7
|
+
* - 带容量上限防止内存泄漏
|
|
8
|
+
*
|
|
9
|
+
* 一轮对话 = 一条 user 消息 + 一条 assistant 消息
|
|
10
|
+
*/
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// ConversationManager
|
|
13
|
+
// ============================================================================
|
|
14
|
+
class ConversationManager {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.cache = new Map();
|
|
17
|
+
this.maxRounds = options.maxRounds ?? 20;
|
|
18
|
+
this.ttlMs = options.ttlMs ?? 2 * 60 * 60 * 1000; // 2 小时
|
|
19
|
+
this.maxConversations = options.maxConversations ?? 500;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 获取指定对话的历史消息
|
|
23
|
+
*
|
|
24
|
+
* 返回按时间排列的 user/assistant 消息数组
|
|
25
|
+
* 如果对话已过期或不存在,返回空数组
|
|
26
|
+
*/
|
|
27
|
+
getHistory(chatId) {
|
|
28
|
+
const entry = this.cache.get(chatId);
|
|
29
|
+
if (!entry)
|
|
30
|
+
return [];
|
|
31
|
+
// 检查 TTL
|
|
32
|
+
if (Date.now() - entry.updatedAt > this.ttlMs) {
|
|
33
|
+
this.cache.delete(chatId);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
return [...entry.messages];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 添加一条用户消息到对话历史
|
|
40
|
+
*/
|
|
41
|
+
addUserMessage(chatId, content) {
|
|
42
|
+
this.ensureEntry(chatId);
|
|
43
|
+
const entry = this.cache.get(chatId);
|
|
44
|
+
entry.messages.push({ role: "user", content });
|
|
45
|
+
entry.updatedAt = Date.now();
|
|
46
|
+
this.trimMessages(entry);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 添加一条 AI 回复消息到对话历史
|
|
50
|
+
*/
|
|
51
|
+
addAssistantMessage(chatId, content) {
|
|
52
|
+
const entry = this.cache.get(chatId);
|
|
53
|
+
if (!entry)
|
|
54
|
+
return; // 如果没有对应的对话,跳过
|
|
55
|
+
entry.messages.push({ role: "assistant", content });
|
|
56
|
+
entry.updatedAt = Date.now();
|
|
57
|
+
this.trimMessages(entry);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 清除指定对话的历史
|
|
61
|
+
*/
|
|
62
|
+
clearHistory(chatId) {
|
|
63
|
+
this.cache.delete(chatId);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 清除所有对话历史
|
|
67
|
+
*/
|
|
68
|
+
clearAll() {
|
|
69
|
+
this.cache.clear();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 获取当前缓存的对话数量(用于调试/监控)
|
|
73
|
+
*/
|
|
74
|
+
get size() {
|
|
75
|
+
return this.cache.size;
|
|
76
|
+
}
|
|
77
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
78
|
+
// 内部方法
|
|
79
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* 确保 chatId 对应的条目存在
|
|
82
|
+
*/
|
|
83
|
+
ensureEntry(chatId) {
|
|
84
|
+
if (!this.cache.has(chatId)) {
|
|
85
|
+
this.cache.set(chatId, {
|
|
86
|
+
messages: [],
|
|
87
|
+
updatedAt: Date.now(),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// 容量限制:淘汰最旧的对话
|
|
91
|
+
if (this.cache.size > this.maxConversations) {
|
|
92
|
+
this.evictOldest();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 裁剪消息数量,保留最近 maxRounds 轮
|
|
97
|
+
*
|
|
98
|
+
* 一轮 = user + assistant = 2 条消息
|
|
99
|
+
* 最多保留 maxRounds * 2 条消息
|
|
100
|
+
*/
|
|
101
|
+
trimMessages(entry) {
|
|
102
|
+
const maxMessages = this.maxRounds * 2;
|
|
103
|
+
if (entry.messages.length > maxMessages) {
|
|
104
|
+
// 保留最新的 maxMessages 条
|
|
105
|
+
entry.messages = entry.messages.slice(-maxMessages);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 淘汰最旧的对话(LRU 策略)
|
|
110
|
+
*/
|
|
111
|
+
evictOldest() {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
// 先清理过期的
|
|
114
|
+
for (const [key, entry] of this.cache) {
|
|
115
|
+
if (now - entry.updatedAt > this.ttlMs) {
|
|
116
|
+
this.cache.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 如果仍超限,按 updatedAt 淘汰最旧的
|
|
120
|
+
if (this.cache.size > this.maxConversations) {
|
|
121
|
+
const entries = [...this.cache.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
122
|
+
const toRemove = entries.slice(0, this.cache.size - this.maxConversations);
|
|
123
|
+
for (const [key] of toRemove) {
|
|
124
|
+
this.cache.delete(key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// 全局单例
|
|
131
|
+
// ============================================================================
|
|
132
|
+
/** 全局对话管理器实例 */
|
|
133
|
+
let globalInstance = null;
|
|
134
|
+
/**
|
|
135
|
+
* 获取全局对话管理器(懒初始化)
|
|
136
|
+
*/
|
|
137
|
+
function getConversationManager(options) {
|
|
138
|
+
if (!globalInstance) {
|
|
139
|
+
globalInstance = new ConversationManager(options);
|
|
140
|
+
}
|
|
141
|
+
return globalInstance;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { ConversationManager, getConversationManager };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isSenderAllowed } from './group-policy.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DM(私聊)访问控制模块
|
|
5
|
+
*
|
|
6
|
+
* 仅保留 open/allowlist/disabled 三种策略,不包含 OpenClaw 的 pairing 机制
|
|
7
|
+
*/
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// 公开 API
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* 检查 DM Policy 访问控制
|
|
13
|
+
*
|
|
14
|
+
* 策略说明:
|
|
15
|
+
* - open: 允许所有人私聊
|
|
16
|
+
* - allowlist: 仅允许白名单中的用户私聊
|
|
17
|
+
* - disabled: 禁用所有私聊
|
|
18
|
+
*/
|
|
19
|
+
async function checkDmPolicy(params) {
|
|
20
|
+
const { senderId, isGroup, account, runtime } = params;
|
|
21
|
+
// 群聊消息不检查 DM Policy
|
|
22
|
+
if (isGroup) {
|
|
23
|
+
return { allowed: true };
|
|
24
|
+
}
|
|
25
|
+
const dmPolicy = account.dmPolicy;
|
|
26
|
+
// disabled: 直接拒绝所有私聊
|
|
27
|
+
if (dmPolicy === "disabled") {
|
|
28
|
+
runtime.log?.(`[wecom] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
29
|
+
return { allowed: false };
|
|
30
|
+
}
|
|
31
|
+
// open: 允许所有人
|
|
32
|
+
if (dmPolicy === "open") {
|
|
33
|
+
return { allowed: true };
|
|
34
|
+
}
|
|
35
|
+
// allowlist: 检查白名单
|
|
36
|
+
const allowFrom = account.allowFrom;
|
|
37
|
+
if (isSenderAllowed(senderId, allowFrom)) {
|
|
38
|
+
return { allowed: true };
|
|
39
|
+
}
|
|
40
|
+
runtime.log?.(`[wecom] Blocked unauthorized sender ${senderId} (dmPolicy=allowlist)`);
|
|
41
|
+
return { allowed: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { checkDmPolicy };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { CHANNEL_ID } from './const.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 群组访问控制模块
|
|
5
|
+
*
|
|
6
|
+
* 负责群组策略检查(groupPolicy、群组白名单、群内发送者白名单)
|
|
7
|
+
* 使用独立配置类型,不依赖 OpenClaw
|
|
8
|
+
*/
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// 内部辅助函数
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/**
|
|
13
|
+
* 检查群组是否在允许列表中
|
|
14
|
+
*/
|
|
15
|
+
function isWeComGroupAllowed(params) {
|
|
16
|
+
const { groupPolicy } = params;
|
|
17
|
+
if (groupPolicy === "disabled")
|
|
18
|
+
return false;
|
|
19
|
+
if (groupPolicy === "open")
|
|
20
|
+
return true;
|
|
21
|
+
// allowlist 模式:检查群组是否在允许列表中
|
|
22
|
+
const normalizedAllowFrom = params.allowFrom.map((entry) => String(entry).replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim());
|
|
23
|
+
if (normalizedAllowFrom.includes("*"))
|
|
24
|
+
return true;
|
|
25
|
+
const normalizedGroupId = params.groupId.trim();
|
|
26
|
+
return normalizedAllowFrom.some((entry) => entry === normalizedGroupId || entry.toLowerCase() === normalizedGroupId.toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 检查群组内发送者是否在允许列表中
|
|
30
|
+
*/
|
|
31
|
+
function isGroupSenderAllowed(params) {
|
|
32
|
+
const { senderId, groupId, account } = params;
|
|
33
|
+
// 查找该群的配置
|
|
34
|
+
const groupConfig = account.groups[groupId] || account.groups["*"];
|
|
35
|
+
const perGroupSenderAllowFrom = (groupConfig?.allowFrom ?? []).map((v) => String(v));
|
|
36
|
+
// 如果没有配置每群发送者白名单,默认允许所有人
|
|
37
|
+
if (perGroupSenderAllowFrom.length === 0)
|
|
38
|
+
return true;
|
|
39
|
+
if (perGroupSenderAllowFrom.includes("*"))
|
|
40
|
+
return true;
|
|
41
|
+
return perGroupSenderAllowFrom.some((entry) => {
|
|
42
|
+
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
|
|
43
|
+
return normalized === senderId || normalized === `user:${senderId}`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// 公开 API
|
|
48
|
+
// ============================================================================
|
|
49
|
+
/**
|
|
50
|
+
* 检查群组策略访问控制
|
|
51
|
+
*
|
|
52
|
+
* 两层检查:
|
|
53
|
+
* 1. 群组白名单(groupAllowFrom)
|
|
54
|
+
* 2. 每群发送者白名单(groups[chatId].allowFrom)
|
|
55
|
+
*/
|
|
56
|
+
function checkGroupPolicy(params) {
|
|
57
|
+
const { chatId, senderId, account, runtime } = params;
|
|
58
|
+
const groupPolicy = account.groupPolicy;
|
|
59
|
+
const groupAllowFrom = account.groupAllowFrom;
|
|
60
|
+
const groupAllowed = isWeComGroupAllowed({
|
|
61
|
+
groupPolicy,
|
|
62
|
+
allowFrom: groupAllowFrom,
|
|
63
|
+
groupId: chatId,
|
|
64
|
+
});
|
|
65
|
+
if (!groupAllowed) {
|
|
66
|
+
runtime.log?.(`[wecom] Group ${chatId} not allowed (groupPolicy=${groupPolicy})`);
|
|
67
|
+
return { allowed: false };
|
|
68
|
+
}
|
|
69
|
+
const senderAllowed = isGroupSenderAllowed({
|
|
70
|
+
senderId,
|
|
71
|
+
groupId: chatId,
|
|
72
|
+
account,
|
|
73
|
+
});
|
|
74
|
+
if (!senderAllowed) {
|
|
75
|
+
runtime.log?.(`[wecom] Sender ${senderId} not in group ${chatId} sender allowlist`);
|
|
76
|
+
return { allowed: false };
|
|
77
|
+
}
|
|
78
|
+
return { allowed: true };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 检查发送者是否在允许列表中(通用)
|
|
82
|
+
*/
|
|
83
|
+
function isSenderAllowed(senderId, allowFrom) {
|
|
84
|
+
if (allowFrom.includes("*"))
|
|
85
|
+
return true;
|
|
86
|
+
return allowFrom.some((entry) => {
|
|
87
|
+
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
|
|
88
|
+
return normalized === senderId || normalized === `user:${senderId}`;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { checkGroupPolicy, isSenderAllowed };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { fileTypeFromBuffer } from 'file-type';
|
|
2
|
+
import { DEFAULT_MEDIA_MAX_MB, IMAGE_DOWNLOAD_TIMEOUT_MS, FILE_DOWNLOAD_TIMEOUT_MS } from './const.js';
|
|
3
|
+
import { withTimeout } from './timeout.js';
|
|
4
|
+
import { saveMediaBuffer } from './media-storage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 入站媒体(图片/文件)下载和保存模块
|
|
8
|
+
*
|
|
9
|
+
* 负责下载、检测格式、保存媒体到本地文件系统
|
|
10
|
+
* 替代 OpenClaw 的 core.channel.media.saveMediaBuffer 和 fetchRemoteMedia
|
|
11
|
+
*/
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// 图片格式检测辅助函数
|
|
14
|
+
// ============================================================================
|
|
15
|
+
async function isImageBuffer(data) {
|
|
16
|
+
const type = await fileTypeFromBuffer(data);
|
|
17
|
+
return type?.mime.startsWith("image/") ?? false;
|
|
18
|
+
}
|
|
19
|
+
async function detectImageContentType(data) {
|
|
20
|
+
const type = await fileTypeFromBuffer(data);
|
|
21
|
+
if (type?.mime.startsWith("image/")) {
|
|
22
|
+
return type.mime;
|
|
23
|
+
}
|
|
24
|
+
return "application/octet-stream";
|
|
25
|
+
}
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// 远程媒体下载回退
|
|
28
|
+
// ============================================================================
|
|
29
|
+
/**
|
|
30
|
+
* HTTP 直接下载媒体文件(SDK downloadFile 失败时的回退方案)
|
|
31
|
+
*/
|
|
32
|
+
async function fetchRemoteMedia(url) {
|
|
33
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
throw new Error(`Failed to fetch media: HTTP ${res.status} ${res.statusText}`);
|
|
36
|
+
}
|
|
37
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
38
|
+
const contentType = res.headers.get("content-type")?.split(";")?.[0]?.trim();
|
|
39
|
+
return { buffer, contentType };
|
|
40
|
+
}
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// 图片下载和保存
|
|
43
|
+
// ============================================================================
|
|
44
|
+
/**
|
|
45
|
+
* 下载并保存所有图片到本地
|
|
46
|
+
*/
|
|
47
|
+
async function downloadAndSaveImages(params) {
|
|
48
|
+
const { imageUrls, config, runtime, wsClient } = params;
|
|
49
|
+
const dataDir = config.dataDir || "./data";
|
|
50
|
+
const mediaList = [];
|
|
51
|
+
for (const imageUrl of imageUrls) {
|
|
52
|
+
try {
|
|
53
|
+
runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
|
|
54
|
+
const mediaMaxMb = DEFAULT_MEDIA_MAX_MB;
|
|
55
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
56
|
+
let imageBuffer;
|
|
57
|
+
let imageContentType;
|
|
58
|
+
let originalFilename;
|
|
59
|
+
const imageAesKey = params.imageAesKeys?.get(imageUrl);
|
|
60
|
+
try {
|
|
61
|
+
// 优先使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
62
|
+
const result = await withTimeout(wsClient.downloadFile(imageUrl, imageAesKey), IMAGE_DOWNLOAD_TIMEOUT_MS, `Image download timed out: ${imageUrl}`);
|
|
63
|
+
imageBuffer = result.buffer;
|
|
64
|
+
originalFilename = result.filename;
|
|
65
|
+
imageContentType = await detectImageContentType(imageBuffer);
|
|
66
|
+
runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
67
|
+
}
|
|
68
|
+
catch (sdkError) {
|
|
69
|
+
// SDK 方法失败,回退到 HTTP fetch
|
|
70
|
+
runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
|
|
71
|
+
const fetched = await withTimeout(fetchRemoteMedia(imageUrl), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
|
|
72
|
+
runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
73
|
+
imageBuffer = fetched.buffer;
|
|
74
|
+
imageContentType = fetched.contentType ?? "application/octet-stream";
|
|
75
|
+
const isValidImage = await isImageBuffer(fetched.buffer);
|
|
76
|
+
if (!isValidImage) {
|
|
77
|
+
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 保存到本地文件系统
|
|
81
|
+
const saved = await saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename, dataDir);
|
|
82
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
83
|
+
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return mediaList;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 下载并保存所有文件到本地
|
|
93
|
+
*/
|
|
94
|
+
async function downloadAndSaveFiles(params) {
|
|
95
|
+
const { fileUrls, config, runtime, wsClient } = params;
|
|
96
|
+
const dataDir = config.dataDir || "./data";
|
|
97
|
+
const mediaList = [];
|
|
98
|
+
for (const fileUrl of fileUrls) {
|
|
99
|
+
try {
|
|
100
|
+
runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
|
|
101
|
+
const mediaMaxMb = DEFAULT_MEDIA_MAX_MB;
|
|
102
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
103
|
+
let fileBuffer;
|
|
104
|
+
let fileContentType;
|
|
105
|
+
let originalFilename;
|
|
106
|
+
const fileAesKey = params.fileAesKeys?.get(fileUrl);
|
|
107
|
+
try {
|
|
108
|
+
const result = await withTimeout(wsClient.downloadFile(fileUrl, fileAesKey), FILE_DOWNLOAD_TIMEOUT_MS, `File download timed out: ${fileUrl}`);
|
|
109
|
+
fileBuffer = result.buffer;
|
|
110
|
+
originalFilename = result.filename;
|
|
111
|
+
const type = await fileTypeFromBuffer(fileBuffer);
|
|
112
|
+
fileContentType = type?.mime ?? "application/octet-stream";
|
|
113
|
+
runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
114
|
+
}
|
|
115
|
+
catch (sdkError) {
|
|
116
|
+
runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
|
|
117
|
+
const fetched = await withTimeout(fetchRemoteMedia(fileUrl), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
|
|
118
|
+
runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
119
|
+
fileBuffer = fetched.buffer;
|
|
120
|
+
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
121
|
+
}
|
|
122
|
+
const saved = await saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename, dataDir);
|
|
123
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
124
|
+
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return mediaList;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { downloadAndSaveFiles, downloadAndSaveImages };
|