@yanhaidao/wecom 2.3.160 → 2.3.180
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 +235 -399
- package/SKILLS_CAL.md +895 -0
- package/SKILLS_DOC.md +2136 -0
- package/changelog/v2.3.18.md +22 -0
- package/index.ts +39 -3
- package/package.json +2 -3
- package/src/agent/handler.event-filter.test.ts +11 -0
- package/src/agent/handler.ts +732 -643
- package/src/app/account-runtime.ts +46 -20
- package/src/app/index.ts +19 -1
- package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
- package/src/capability/calendar/client.ts +815 -0
- package/src/capability/calendar/index.ts +3 -0
- package/src/capability/calendar/schema.ts +417 -0
- package/src/capability/calendar/tool.ts +417 -0
- package/src/capability/calendar/types.ts +309 -0
- package/src/capability/doc/client.ts +567 -62
- package/src/capability/doc/schema.ts +419 -318
- package/src/capability/doc/tool.ts +1510 -1178
- package/src/capability/doc/types.ts +130 -14
- package/src/capability/mcp/index.ts +10 -0
- package/src/capability/mcp/schema.ts +107 -0
- package/src/capability/mcp/tool.ts +170 -0
- package/src/capability/mcp/transport.ts +394 -0
- package/src/channel.ts +70 -28
- package/src/config/schema.ts +71 -102
- package/src/outbound.test.ts +91 -14
- package/src/outbound.ts +143 -30
- package/src/runtime/reply-orchestrator.test.ts +35 -2
- package/src/runtime/reply-orchestrator.ts +14 -2
- package/src/runtime/session-manager.ts +20 -6
- package/src/runtime/source-registry.ts +165 -0
- package/src/transport/bot-ws/media.ts +269 -0
- package/src/transport/bot-ws/reply.test.ts +85 -17
- package/src/transport/bot-ws/reply.ts +109 -21
- package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
- package/src/transport/bot-ws/sdk-adapter.ts +88 -12
- package/.claude/settings.local.json +0 -11
- package/docs/update-content-fix.md +0 -135
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
-
|
|
3
|
-
import { resolveRuntimeRoute } from "./routing-bridge.js";
|
|
4
|
-
import type { UnifiedInboundEvent } from "../types/index.js";
|
|
5
2
|
import type { WecomMediaService } from "../shared/media-service.js";
|
|
3
|
+
import type { UnifiedInboundEvent } from "../types/index.js";
|
|
4
|
+
import { resolveRuntimeRoute } from "./routing-bridge.js";
|
|
5
|
+
import { registerWecomSourceSnapshot } from "./source-registry.js";
|
|
6
6
|
|
|
7
7
|
export type PreparedSession = {
|
|
8
8
|
route: ReturnType<typeof resolveRuntimeRoute>;
|
|
@@ -18,6 +18,14 @@ export async function prepareInboundSession(params: {
|
|
|
18
18
|
}): Promise<PreparedSession> {
|
|
19
19
|
const { core, cfg, event, mediaService } = params;
|
|
20
20
|
const route = resolveRuntimeRoute({ core, cfg, event });
|
|
21
|
+
if (event.transport === "bot-ws") {
|
|
22
|
+
registerWecomSourceSnapshot({
|
|
23
|
+
accountId: event.accountId,
|
|
24
|
+
source: "bot-ws",
|
|
25
|
+
messageId: event.messageId,
|
|
26
|
+
sessionKey: route.sessionKey,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
21
29
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
22
30
|
agentId: route.agentId,
|
|
23
31
|
});
|
|
@@ -47,7 +55,10 @@ export async function prepareInboundSession(params: {
|
|
|
47
55
|
event.conversation.peerKind === "group"
|
|
48
56
|
? `wecom:group:${event.conversation.peerId}`
|
|
49
57
|
: `wecom:user:${event.conversation.senderId}`,
|
|
50
|
-
To:
|
|
58
|
+
To:
|
|
59
|
+
event.conversation.peerKind === "group"
|
|
60
|
+
? `wecom:group:${event.conversation.peerId}`
|
|
61
|
+
: `wecom:user:${event.conversation.peerId}`,
|
|
51
62
|
SessionKey: route.sessionKey,
|
|
52
63
|
AccountId: route.accountId,
|
|
53
64
|
ChatType: event.conversation.peerKind,
|
|
@@ -57,7 +68,10 @@ export async function prepareInboundSession(params: {
|
|
|
57
68
|
Provider: "wecom",
|
|
58
69
|
Surface: "wecom",
|
|
59
70
|
OriginatingChannel: "wecom",
|
|
60
|
-
OriginatingTo:
|
|
71
|
+
OriginatingTo:
|
|
72
|
+
event.conversation.peerKind === "group"
|
|
73
|
+
? `wecom:group:${event.conversation.peerId}`
|
|
74
|
+
: `wecom:user:${event.conversation.peerId}`,
|
|
61
75
|
MessageSid: event.messageId,
|
|
62
76
|
CommandAuthorized: true,
|
|
63
77
|
MediaPath: mediaPath,
|
|
@@ -69,7 +83,7 @@ export async function prepareInboundSession(params: {
|
|
|
69
83
|
storePath,
|
|
70
84
|
sessionKey: ctx.SessionKey ?? route.sessionKey,
|
|
71
85
|
ctx,
|
|
72
|
-
onRecordError: () => {
|
|
86
|
+
onRecordError: () => {},
|
|
73
87
|
});
|
|
74
88
|
|
|
75
89
|
return { route, ctx, storePath };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
export type WecomSourcePlane = "bot-ws" | "agent-callback";
|
|
2
|
+
|
|
3
|
+
export type WecomSourceSnapshot = {
|
|
4
|
+
accountId: string;
|
|
5
|
+
source: WecomSourcePlane;
|
|
6
|
+
recordedAt: number;
|
|
7
|
+
messageId?: string;
|
|
8
|
+
sessionKey?: string;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const MAX_MESSAGE_FACTS = 2048;
|
|
13
|
+
const MAX_SESSION_SNAPSHOTS = 1024;
|
|
14
|
+
|
|
15
|
+
const messageFacts = new Map<string, WecomSourceSnapshot>();
|
|
16
|
+
const sessionSnapshotsByAccountKey = new Map<string, WecomSourceSnapshot>();
|
|
17
|
+
const sessionSnapshotsByLooseKey = new Map<string, WecomSourceSnapshot>();
|
|
18
|
+
|
|
19
|
+
function normalizeOptional(value: string | null | undefined): string | undefined {
|
|
20
|
+
const trimmed = String(value ?? "").trim();
|
|
21
|
+
return trimmed || undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function messageFactKey(accountId: string, messageId: string): string {
|
|
25
|
+
return `${accountId}::${messageId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function accountScopedSessionKey(
|
|
29
|
+
accountId: string,
|
|
30
|
+
kind: "sessionKey" | "sessionId",
|
|
31
|
+
value: string,
|
|
32
|
+
): string {
|
|
33
|
+
return `${accountId}::${kind}::${value}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pruneOldest<T>(map: Map<string, T>, maxSize: number): void {
|
|
37
|
+
while (map.size > maxSize) {
|
|
38
|
+
const oldestKey = map.keys().next().value;
|
|
39
|
+
if (!oldestKey) return;
|
|
40
|
+
map.delete(oldestKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeSessionSnapshot(snapshot: WecomSourceSnapshot): void {
|
|
45
|
+
const sessionKey = normalizeOptional(snapshot.sessionKey);
|
|
46
|
+
const sessionId = normalizeOptional(snapshot.sessionId);
|
|
47
|
+
if (sessionKey) {
|
|
48
|
+
sessionSnapshotsByAccountKey.set(
|
|
49
|
+
accountScopedSessionKey(snapshot.accountId, "sessionKey", sessionKey),
|
|
50
|
+
snapshot,
|
|
51
|
+
);
|
|
52
|
+
sessionSnapshotsByLooseKey.set(`sessionKey::${sessionKey}`, snapshot);
|
|
53
|
+
}
|
|
54
|
+
if (sessionId) {
|
|
55
|
+
sessionSnapshotsByAccountKey.set(
|
|
56
|
+
accountScopedSessionKey(snapshot.accountId, "sessionId", sessionId),
|
|
57
|
+
snapshot,
|
|
58
|
+
);
|
|
59
|
+
sessionSnapshotsByLooseKey.set(`sessionId::${sessionId}`, snapshot);
|
|
60
|
+
}
|
|
61
|
+
pruneOldest(sessionSnapshotsByAccountKey, MAX_SESSION_SNAPSHOTS * 2);
|
|
62
|
+
pruneOldest(sessionSnapshotsByLooseKey, MAX_SESSION_SNAPSHOTS * 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerWecomSourceSnapshot(params: {
|
|
66
|
+
accountId: string;
|
|
67
|
+
source: WecomSourcePlane;
|
|
68
|
+
messageId?: string | null;
|
|
69
|
+
sessionKey?: string | null;
|
|
70
|
+
sessionId?: string | null;
|
|
71
|
+
}): void {
|
|
72
|
+
const accountId = normalizeOptional(params.accountId);
|
|
73
|
+
if (!accountId) return;
|
|
74
|
+
|
|
75
|
+
const snapshot: WecomSourceSnapshot = {
|
|
76
|
+
accountId,
|
|
77
|
+
source: params.source,
|
|
78
|
+
recordedAt: Date.now(),
|
|
79
|
+
...(normalizeOptional(params.messageId)
|
|
80
|
+
? { messageId: normalizeOptional(params.messageId) }
|
|
81
|
+
: {}),
|
|
82
|
+
...(normalizeOptional(params.sessionKey)
|
|
83
|
+
? { sessionKey: normalizeOptional(params.sessionKey) }
|
|
84
|
+
: {}),
|
|
85
|
+
...(normalizeOptional(params.sessionId)
|
|
86
|
+
? { sessionId: normalizeOptional(params.sessionId) }
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (snapshot.messageId) {
|
|
91
|
+
messageFacts.set(messageFactKey(accountId, snapshot.messageId), snapshot);
|
|
92
|
+
pruneOldest(messageFacts, MAX_MESSAGE_FACTS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
writeSessionSnapshot(snapshot);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveWecomSourceSnapshot(params: {
|
|
99
|
+
accountId?: string | null;
|
|
100
|
+
sessionKey?: string | null;
|
|
101
|
+
sessionId?: string | null;
|
|
102
|
+
}): WecomSourceSnapshot | undefined {
|
|
103
|
+
const accountId = normalizeOptional(params.accountId);
|
|
104
|
+
const sessionKey = normalizeOptional(params.sessionKey);
|
|
105
|
+
const sessionId = normalizeOptional(params.sessionId);
|
|
106
|
+
|
|
107
|
+
if (accountId && sessionKey) {
|
|
108
|
+
const scoped = sessionSnapshotsByAccountKey.get(
|
|
109
|
+
accountScopedSessionKey(accountId, "sessionKey", sessionKey),
|
|
110
|
+
);
|
|
111
|
+
if (scoped) return scoped;
|
|
112
|
+
}
|
|
113
|
+
if (accountId && sessionId) {
|
|
114
|
+
const scoped = sessionSnapshotsByAccountKey.get(
|
|
115
|
+
accountScopedSessionKey(accountId, "sessionId", sessionId),
|
|
116
|
+
);
|
|
117
|
+
if (scoped) return scoped;
|
|
118
|
+
}
|
|
119
|
+
if (sessionKey) {
|
|
120
|
+
const loose = sessionSnapshotsByLooseKey.get(`sessionKey::${sessionKey}`);
|
|
121
|
+
if (loose) return loose;
|
|
122
|
+
}
|
|
123
|
+
if (sessionId) {
|
|
124
|
+
const loose = sessionSnapshotsByLooseKey.get(`sessionId::${sessionId}`);
|
|
125
|
+
if (loose) return loose;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function clearWecomSourceAccount(accountId: string): void {
|
|
131
|
+
const normalized = normalizeOptional(accountId);
|
|
132
|
+
if (!normalized) return;
|
|
133
|
+
|
|
134
|
+
for (const [key, value] of messageFacts) {
|
|
135
|
+
if (value.accountId === normalized || key.startsWith(`${normalized}::`)) {
|
|
136
|
+
messageFacts.delete(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const [key, value] of sessionSnapshotsByAccountKey) {
|
|
140
|
+
if (value.accountId === normalized || key.startsWith(`${normalized}::`)) {
|
|
141
|
+
sessionSnapshotsByAccountKey.delete(key);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const [key, value] of sessionSnapshotsByLooseKey) {
|
|
145
|
+
if (value.accountId === normalized) {
|
|
146
|
+
sessionSnapshotsByLooseKey.delete(key);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function isWecomBotWsSource(params: {
|
|
152
|
+
accountId?: string | null;
|
|
153
|
+
sessionKey?: string | null;
|
|
154
|
+
sessionId?: string | null;
|
|
155
|
+
}): boolean {
|
|
156
|
+
return resolveWecomSourceSnapshot(params)?.source === "bot-ws";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isWecomAgentSource(params: {
|
|
160
|
+
accountId?: string | null;
|
|
161
|
+
sessionKey?: string | null;
|
|
162
|
+
sessionId?: string | null;
|
|
163
|
+
}): boolean {
|
|
164
|
+
return resolveWecomSourceSnapshot(params)?.source === "agent-callback";
|
|
165
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { WeComMediaType, WsFrameHeaders, WSClient } from "@wecom/aibot-node-sdk";
|
|
2
|
+
import { detectMime, loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
5
|
+
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
type FileSizeCheckResult = {
|
|
10
|
+
finalType: WeComMediaType;
|
|
11
|
+
shouldReject: boolean;
|
|
12
|
+
rejectReason?: string;
|
|
13
|
+
downgraded: boolean;
|
|
14
|
+
downgradeNote?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type BotWsMediaSendResult = {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
messageId?: string;
|
|
20
|
+
finalType?: WeComMediaType;
|
|
21
|
+
rejected?: boolean;
|
|
22
|
+
rejectReason?: string;
|
|
23
|
+
downgraded?: boolean;
|
|
24
|
+
downgradeNote?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ResolvedMediaFile = {
|
|
29
|
+
buffer: Buffer;
|
|
30
|
+
contentType: string;
|
|
31
|
+
fileName: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
|
|
35
|
+
|
|
36
|
+
function detectWeComMediaType(mimeType: string): WeComMediaType {
|
|
37
|
+
const mime = mimeType.toLowerCase();
|
|
38
|
+
if (mime.startsWith("image/")) return "image";
|
|
39
|
+
if (mime.startsWith("video/")) return "video";
|
|
40
|
+
if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
|
|
41
|
+
return "file";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mimeToExtension(mime: string): string {
|
|
45
|
+
const map: Record<string, string> = {
|
|
46
|
+
"image/jpeg": ".jpg",
|
|
47
|
+
"image/png": ".png",
|
|
48
|
+
"image/gif": ".gif",
|
|
49
|
+
"image/webp": ".webp",
|
|
50
|
+
"image/bmp": ".bmp",
|
|
51
|
+
"image/svg+xml": ".svg",
|
|
52
|
+
"video/mp4": ".mp4",
|
|
53
|
+
"video/quicktime": ".mov",
|
|
54
|
+
"video/x-msvideo": ".avi",
|
|
55
|
+
"video/webm": ".webm",
|
|
56
|
+
"audio/mpeg": ".mp3",
|
|
57
|
+
"audio/ogg": ".ogg",
|
|
58
|
+
"audio/wav": ".wav",
|
|
59
|
+
"audio/amr": ".amr",
|
|
60
|
+
"audio/aac": ".aac",
|
|
61
|
+
"application/pdf": ".pdf",
|
|
62
|
+
"application/zip": ".zip",
|
|
63
|
+
"application/msword": ".doc",
|
|
64
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
65
|
+
"application/vnd.ms-excel": ".xls",
|
|
66
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
67
|
+
"text/plain": ".txt",
|
|
68
|
+
};
|
|
69
|
+
return map[mime] || ".bin";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractFileName(
|
|
73
|
+
mediaUrl: string,
|
|
74
|
+
providedFileName?: string,
|
|
75
|
+
contentType?: string,
|
|
76
|
+
): string {
|
|
77
|
+
if (providedFileName) return providedFileName;
|
|
78
|
+
try {
|
|
79
|
+
const url = new URL(mediaUrl, "file://");
|
|
80
|
+
const lastPart = url.pathname.split("/").filter(Boolean).pop();
|
|
81
|
+
if (lastPart && lastPart.includes(".")) {
|
|
82
|
+
return decodeURIComponent(lastPart);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
const lastPart = mediaUrl.split("/").filter(Boolean).pop();
|
|
86
|
+
if (lastPart && lastPart.includes(".")) {
|
|
87
|
+
return lastPart;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return `media_${Date.now()}${mimeToExtension(contentType || "application/octet-stream")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyFileSizeLimits(
|
|
94
|
+
fileSize: number,
|
|
95
|
+
detectedType: WeComMediaType,
|
|
96
|
+
contentType?: string,
|
|
97
|
+
): FileSizeCheckResult {
|
|
98
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
99
|
+
if (fileSize > FILE_MAX_BYTES) {
|
|
100
|
+
return {
|
|
101
|
+
finalType: detectedType,
|
|
102
|
+
shouldReject: true,
|
|
103
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。`,
|
|
104
|
+
downgraded: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (detectedType) {
|
|
109
|
+
case "image":
|
|
110
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
111
|
+
return {
|
|
112
|
+
finalType: "file",
|
|
113
|
+
shouldReject: false,
|
|
114
|
+
downgraded: true,
|
|
115
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
case "video":
|
|
120
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
121
|
+
return {
|
|
122
|
+
finalType: "file",
|
|
123
|
+
shouldReject: false,
|
|
124
|
+
downgraded: true,
|
|
125
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case "voice":
|
|
130
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
131
|
+
return {
|
|
132
|
+
finalType: "file",
|
|
133
|
+
shouldReject: false,
|
|
134
|
+
downgraded: true,
|
|
135
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
139
|
+
return {
|
|
140
|
+
finalType: "file",
|
|
141
|
+
shouldReject: false,
|
|
142
|
+
downgraded: true,
|
|
143
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
default:
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
finalType: detectedType,
|
|
153
|
+
shouldReject: false,
|
|
154
|
+
downgraded: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveMediaFile(
|
|
159
|
+
mediaUrl: string,
|
|
160
|
+
mediaLocalRoots?: readonly string[],
|
|
161
|
+
): Promise<ResolvedMediaFile> {
|
|
162
|
+
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
163
|
+
maxBytes: FILE_MAX_BYTES,
|
|
164
|
+
mediaLocalRoots,
|
|
165
|
+
});
|
|
166
|
+
let contentType = result.contentType || "application/octet-stream";
|
|
167
|
+
if (contentType === "application/octet-stream" || contentType === "text/plain") {
|
|
168
|
+
const detected = await detectMime({
|
|
169
|
+
buffer: result.buffer,
|
|
170
|
+
filePath: result.fileName ?? mediaUrl,
|
|
171
|
+
});
|
|
172
|
+
if (detected) {
|
|
173
|
+
contentType = detected;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
buffer: result.buffer,
|
|
178
|
+
contentType,
|
|
179
|
+
fileName: extractFileName(mediaUrl, result.fileName, contentType),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function uploadAndSendBotWsMedia(params: {
|
|
184
|
+
wsClient: WSClient;
|
|
185
|
+
mediaUrl: string;
|
|
186
|
+
chatId: string;
|
|
187
|
+
mediaLocalRoots?: readonly string[];
|
|
188
|
+
}): Promise<BotWsMediaSendResult> {
|
|
189
|
+
try {
|
|
190
|
+
const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots);
|
|
191
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
192
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
193
|
+
if (sizeCheck.shouldReject) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
rejected: true,
|
|
197
|
+
rejectReason: sizeCheck.rejectReason,
|
|
198
|
+
finalType: sizeCheck.finalType,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const uploadResult = await params.wsClient.uploadMedia(media.buffer, {
|
|
203
|
+
type: sizeCheck.finalType,
|
|
204
|
+
filename: media.fileName,
|
|
205
|
+
});
|
|
206
|
+
const sendResult = await params.wsClient.sendMediaMessage(
|
|
207
|
+
params.chatId,
|
|
208
|
+
sizeCheck.finalType,
|
|
209
|
+
uploadResult.media_id,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
messageId: sendResult?.headers?.req_id ?? `wecom-media-${Date.now()}`,
|
|
215
|
+
finalType: sizeCheck.finalType,
|
|
216
|
+
downgraded: sizeCheck.downgraded,
|
|
217
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
218
|
+
};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: error instanceof Error ? error.message : String(error),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function uploadAndReplyBotWsMedia(params: {
|
|
228
|
+
wsClient: WSClient;
|
|
229
|
+
frame: WsFrameHeaders;
|
|
230
|
+
mediaUrl: string;
|
|
231
|
+
mediaLocalRoots?: readonly string[];
|
|
232
|
+
}): Promise<BotWsMediaSendResult> {
|
|
233
|
+
try {
|
|
234
|
+
const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots);
|
|
235
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
236
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
237
|
+
if (sizeCheck.shouldReject) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
rejected: true,
|
|
241
|
+
rejectReason: sizeCheck.rejectReason,
|
|
242
|
+
finalType: sizeCheck.finalType,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const uploadResult = await params.wsClient.uploadMedia(media.buffer, {
|
|
247
|
+
type: sizeCheck.finalType,
|
|
248
|
+
filename: media.fileName,
|
|
249
|
+
});
|
|
250
|
+
const replyResult = await params.wsClient.replyMedia(
|
|
251
|
+
params.frame,
|
|
252
|
+
sizeCheck.finalType,
|
|
253
|
+
uploadResult.media_id,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
messageId: replyResult?.headers?.req_id ?? `wecom-reply-media-${Date.now()}`,
|
|
259
|
+
finalType: sizeCheck.finalType,
|
|
260
|
+
downgraded: sizeCheck.downgraded,
|
|
261
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
262
|
+
};
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
error: error instanceof Error ? error.message : String(error),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
|
|
3
1
|
import type { WSClient } from "@wecom/aibot-node-sdk";
|
|
4
|
-
|
|
2
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
5
3
|
import { createBotWsReplyHandle } from "./reply.js";
|
|
6
4
|
|
|
7
5
|
type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
|
|
@@ -43,10 +41,10 @@ describe("createBotWsReplyHandle", () => {
|
|
|
43
41
|
vi.advanceTimersByTime(3000);
|
|
44
42
|
// Let promises flush
|
|
45
43
|
await Promise.resolve();
|
|
46
|
-
|
|
44
|
+
|
|
47
45
|
expect(mockClient.replyStream).toHaveBeenCalledWith(
|
|
48
46
|
expect.objectContaining({
|
|
49
|
-
headers: { req_id: "req-1" }
|
|
47
|
+
headers: { req_id: "req-1" },
|
|
50
48
|
}),
|
|
51
49
|
expect.any(String),
|
|
52
50
|
"正在思考...",
|
|
@@ -69,7 +67,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
69
67
|
vi.advanceTimersByTime(3000);
|
|
70
68
|
// Flush the microtasks so `placeholderInFlight` becomes false
|
|
71
69
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
72
|
-
|
|
70
|
+
|
|
73
71
|
// Now trigger the next timer
|
|
74
72
|
vi.advanceTimersByTime(3000);
|
|
75
73
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
@@ -90,7 +88,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
90
88
|
// Ensure interval is cleared
|
|
91
89
|
vi.advanceTimersByTime(6000);
|
|
92
90
|
await Promise.resolve();
|
|
93
|
-
expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
|
|
91
|
+
expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
|
|
94
92
|
});
|
|
95
93
|
|
|
96
94
|
it("does not auto-send placeholder when disabled", async () => {
|
|
@@ -149,6 +147,73 @@ describe("createBotWsReplyHandle", () => {
|
|
|
149
147
|
);
|
|
150
148
|
});
|
|
151
149
|
|
|
150
|
+
it("streams block text even when media is deferred to final", async () => {
|
|
151
|
+
const handle = createBotWsReplyHandle({
|
|
152
|
+
client: mockClient,
|
|
153
|
+
frame: {
|
|
154
|
+
headers: { req_id: "req-block-media" },
|
|
155
|
+
body: {},
|
|
156
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
157
|
+
accountId: "default",
|
|
158
|
+
inboundKind: "text",
|
|
159
|
+
autoSendPlaceholder: false,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await handle.deliver(
|
|
163
|
+
{
|
|
164
|
+
text: "正文先发",
|
|
165
|
+
mediaUrls: ["/tmp/a.png", "/tmp/b.png"],
|
|
166
|
+
isReasoning: false,
|
|
167
|
+
},
|
|
168
|
+
{ kind: "block" },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(mockClient.replyStream).toHaveBeenCalledWith(
|
|
172
|
+
expect.objectContaining({ headers: { req_id: "req-block-media" } }),
|
|
173
|
+
expect.any(String),
|
|
174
|
+
"正文先发",
|
|
175
|
+
false,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("stops placeholder keepalive when the first block contains media", async () => {
|
|
180
|
+
const handle = createBotWsReplyHandle({
|
|
181
|
+
client: mockClient,
|
|
182
|
+
frame: {
|
|
183
|
+
headers: { req_id: "req-placeholder-media" },
|
|
184
|
+
body: {},
|
|
185
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
186
|
+
accountId: "default",
|
|
187
|
+
inboundKind: "text",
|
|
188
|
+
placeholderContent: "正在思考...",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
vi.advanceTimersByTime(3000);
|
|
192
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
193
|
+
expect(mockClient.replyStream).toHaveBeenCalledTimes(1);
|
|
194
|
+
|
|
195
|
+
await handle.deliver(
|
|
196
|
+
{
|
|
197
|
+
text: "正文先发",
|
|
198
|
+
mediaUrls: ["/tmp/a.png"],
|
|
199
|
+
isReasoning: false,
|
|
200
|
+
},
|
|
201
|
+
{ kind: "block" },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
vi.advanceTimersByTime(6000);
|
|
205
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
206
|
+
|
|
207
|
+
expect(mockClient.replyStream).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect(mockClient.replyStream).toHaveBeenNthCalledWith(
|
|
209
|
+
2,
|
|
210
|
+
expect.objectContaining({ headers: { req_id: "req-placeholder-media" } }),
|
|
211
|
+
expect.any(String),
|
|
212
|
+
"正文先发",
|
|
213
|
+
false,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
152
217
|
it("swallows expired stream update errors during delivery", async () => {
|
|
153
218
|
const expiredError = {
|
|
154
219
|
headers: { req_id: "req-expired" },
|
|
@@ -157,7 +222,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
157
222
|
};
|
|
158
223
|
mockClient.replyStream.mockRejectedValueOnce(expiredError);
|
|
159
224
|
const onFail = vi.fn();
|
|
160
|
-
|
|
225
|
+
|
|
161
226
|
const handle = createBotWsReplyHandle({
|
|
162
227
|
client: mockClient,
|
|
163
228
|
frame: {
|
|
@@ -178,7 +243,13 @@ describe("createBotWsReplyHandle", () => {
|
|
|
178
243
|
|
|
179
244
|
it.each([
|
|
180
245
|
[{ headers: { req_id: "req-invalid" }, errcode: 846605, errmsg: "invalid req_id" }],
|
|
181
|
-
[
|
|
246
|
+
[
|
|
247
|
+
{
|
|
248
|
+
headers: { req_id: "req-expired" },
|
|
249
|
+
errcode: 846608,
|
|
250
|
+
errmsg: "stream message update expired (>6 minutes), cannot update",
|
|
251
|
+
},
|
|
252
|
+
],
|
|
182
253
|
])("does not retry error reply when the ws reply window is already closed", async (error) => {
|
|
183
254
|
const onFail = vi.fn();
|
|
184
255
|
const handle = createBotWsReplyHandle({
|
|
@@ -218,13 +289,10 @@ describe("createBotWsReplyHandle", () => {
|
|
|
218
289
|
handle.deliver({ text: "Event Reply", isReasoning: false }, { kind: "final" });
|
|
219
290
|
await Promise.resolve();
|
|
220
291
|
|
|
221
|
-
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
|
222
|
-
"
|
|
223
|
-
{
|
|
224
|
-
|
|
225
|
-
markdown: { content: "Event Reply" },
|
|
226
|
-
}
|
|
227
|
-
);
|
|
292
|
+
expect(mockClient.sendMessage).toHaveBeenCalledWith("alice", {
|
|
293
|
+
msgtype: "markdown",
|
|
294
|
+
markdown: { content: "Event Reply" },
|
|
295
|
+
});
|
|
228
296
|
});
|
|
229
297
|
|
|
230
298
|
it("sends replyWelcome for welcome events", async () => {
|
|
@@ -246,7 +314,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
246
314
|
{
|
|
247
315
|
msgtype: "text",
|
|
248
316
|
text: { content: "Hello Bob" },
|
|
249
|
-
}
|
|
317
|
+
},
|
|
250
318
|
);
|
|
251
319
|
});
|
|
252
320
|
});
|