@yanhaidao/wecom 2.3.270 → 2.4.160

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.
Files changed (44) hide show
  1. package/README.md +79 -3
  2. package/UPSTREAM_CONFIG.md +170 -0
  3. package/UPSTREAM_PLAN.md +175 -0
  4. package/changelog/v2.4.12.md +37 -0
  5. package/changelog/v2.4.16.md +19 -0
  6. package/package.json +1 -1
  7. package/src/agent/handler.event-filter.test.ts +30 -1
  8. package/src/agent/handler.ts +226 -17
  9. package/src/app/account-runtime.ts +1 -1
  10. package/src/capability/agent/upstream-delivery-service.ts +96 -0
  11. package/src/capability/bot/sandbox-media.test.ts +221 -0
  12. package/src/capability/bot/sandbox-media.ts +176 -0
  13. package/src/capability/bot/stream-orchestrator.ts +19 -0
  14. package/src/channel.meta.test.ts +10 -0
  15. package/src/channel.ts +4 -1
  16. package/src/config/index.ts +5 -1
  17. package/src/config/network.ts +33 -0
  18. package/src/config/schema.ts +4 -0
  19. package/src/context-store.ts +41 -8
  20. package/src/http.ts +9 -1
  21. package/src/outbound.test.ts +211 -2
  22. package/src/outbound.ts +323 -70
  23. package/src/runtime/session-manager.test.ts +39 -0
  24. package/src/runtime/session-manager.ts +17 -0
  25. package/src/runtime/source-registry.ts +5 -0
  26. package/src/shared/media-asset.ts +78 -0
  27. package/src/shared/media-service.test.ts +111 -0
  28. package/src/shared/media-service.ts +42 -14
  29. package/src/target.ts +40 -0
  30. package/src/transport/agent-api/client.ts +233 -0
  31. package/src/transport/agent-api/core.ts +101 -5
  32. package/src/transport/agent-api/upstream-delivery.ts +45 -0
  33. package/src/transport/agent-api/upstream-media-upload.ts +70 -0
  34. package/src/transport/agent-api/upstream-reply.ts +43 -0
  35. package/src/transport/bot-webhook/inbound-normalizer.test.ts +433 -0
  36. package/src/transport/bot-webhook/inbound-normalizer.ts +240 -53
  37. package/src/transport/bot-webhook/message-shape.ts +3 -0
  38. package/src/transport/bot-ws/inbound.test.ts +195 -1
  39. package/src/transport/bot-ws/inbound.ts +57 -10
  40. package/src/types/config.ts +22 -0
  41. package/src/types/message.ts +11 -7
  42. package/src/upstream/index.ts +150 -0
  43. package/src/upstream.test.ts +84 -0
  44. package/vitest.config.ts +15 -4
@@ -22,6 +22,8 @@ function resolveInboundKind(message: BaseMessage | EventMessage): WecomInboundKi
22
22
  return "file";
23
23
  case "voice":
24
24
  return "voice";
25
+ case "video":
26
+ return "video";
25
27
  case "mixed":
26
28
  return "mixed";
27
29
  default:
@@ -29,6 +31,18 @@ function resolveInboundKind(message: BaseMessage | EventMessage): WecomInboundKi
29
31
  }
30
32
  }
31
33
 
34
+ function pushAttachment(
35
+ list: NonNullable<UnifiedInboundEvent["attachments"]>,
36
+ name: "image" | "file" | "video",
37
+ remoteUrl?: string,
38
+ aesKey?: string,
39
+ ): void {
40
+ if (!remoteUrl) {
41
+ return;
42
+ }
43
+ list.push({ name, remoteUrl, aesKey });
44
+ }
45
+
32
46
  function resolveEventText(message: BaseMessage | EventMessage, account: ResolvedBotAccount): string {
33
47
  if (message.msgtype !== "event") {
34
48
  return buildInboundBody(message as WecomBotInboundMessage);
@@ -56,27 +70,60 @@ export function mapBotWsFrameToInboundEvent(params: {
56
70
  const inboundKind = resolveInboundKind(body);
57
71
 
58
72
  let attachments: UnifiedInboundEvent["attachments"];
73
+ const collected: NonNullable<UnifiedInboundEvent["attachments"]> = [];
59
74
  if (body.msgtype === "image") {
60
- attachments = [{ name: "image", remoteUrl: (body as any).image?.url, aesKey: (body as any).image?.aeskey }];
75
+ pushAttachment(collected, "image", (body as any).image?.url, (body as any).image?.aeskey);
61
76
  } else if (body.msgtype === "file") {
62
- attachments = [{ name: "file", remoteUrl: (body as any).file?.url, aesKey: (body as any).file?.aeskey }];
77
+ pushAttachment(collected, "file", (body as any).file?.url, (body as any).file?.aeskey);
78
+ } else if (body.msgtype === "video") {
79
+ pushAttachment(collected, "video", (body as any).video?.url, (body as any).video?.aeskey);
63
80
  } else if (body.msgtype === "mixed") {
64
81
  const items = (body as any).mixed?.msg_item;
65
82
  if (Array.isArray(items)) {
66
- attachments = [];
67
83
  for (const item of items) {
68
- if (item.msgtype === "image" && item.image?.url) {
69
- attachments.push({ name: "image", remoteUrl: item.image.url, aesKey: item.image.aeskey });
70
- } else if (item.msgtype === "file" && item.file?.url) {
71
- attachments.push({ name: "file", remoteUrl: item.file.url, aesKey: item.file.aeskey });
84
+ const itemType = String(item.msgtype ?? "").toLowerCase();
85
+ if (itemType === "image") {
86
+ pushAttachment(collected, "image", item.image?.url, item.image?.aeskey);
87
+ } else if (itemType === "file") {
88
+ pushAttachment(collected, "file", item.file?.url, item.file?.aeskey);
89
+ } else if (itemType === "video") {
90
+ pushAttachment(collected, "video", item.video?.url, item.video?.aeskey);
72
91
  }
73
92
  }
74
- if (attachments.length === 0) {
75
- attachments = undefined;
93
+ }
94
+ }
95
+
96
+ // 新增支持:如果没有顶层媒体,尝试从引用中提取媒体附件
97
+ // 优先级:quote.image/file/video 优先,其次 quote.mixed 中第一个图片
98
+ if (collected.length === 0) {
99
+ const quote = (body as any).quote;
100
+ if (quote) {
101
+ const quoteType = String(quote.msgtype ?? "").toLowerCase();
102
+ // 处理单个媒体类型的引用
103
+ if (quoteType === "image") {
104
+ pushAttachment(collected, "image", quote.image?.url, quote.image?.aeskey);
105
+ } else if (quoteType === "file") {
106
+ pushAttachment(collected, "file", quote.file?.url, quote.file?.aeskey);
107
+ } else if (quoteType === "video") {
108
+ pushAttachment(collected, "video", quote.video?.url, quote.video?.aeskey);
109
+ }
110
+ // 处理图文混合类型:只提取第一个图片以保持与 webhook 一致
111
+ else if (quoteType === "mixed" && Array.isArray(quote.mixed?.msg_item)) {
112
+ for (const item of quote.mixed.msg_item) {
113
+ const itemType = String(item.msgtype ?? "").toLowerCase();
114
+ if (itemType === "image") {
115
+ pushAttachment(collected, "image", item.image?.url, item.image?.aeskey);
116
+ break;
117
+ }
118
+ }
76
119
  }
77
120
  }
78
121
  }
79
122
 
123
+ if (collected.length > 0) {
124
+ attachments = collected;
125
+ }
126
+
80
127
  return {
81
128
  accountId: account.accountId,
82
129
  capability: "bot",
@@ -111,6 +158,6 @@ export function mapBotWsFrameToInboundEvent(params: {
111
158
  envelopeType: "ws",
112
159
  },
113
160
  },
114
- attachments,
161
+ ...(attachments && { attachments }),
115
162
  };
116
163
  }
@@ -11,11 +11,14 @@ export type WecomMediaConfig = {
11
11
  retentionHours?: number;
12
12
  cleanupOnStart?: boolean;
13
13
  maxBytes?: number;
14
+ downloadTimeoutMs?: number;
14
15
  localRoots?: string[];
15
16
  };
16
17
 
17
18
  export type WecomNetworkConfig = {
18
19
  egressProxyUrl?: string;
20
+ timeoutMs?: number;
21
+ mediaDownloadTimeoutMs?: number;
19
22
  };
20
23
 
21
24
  export type WecomRoutingConfig = {
@@ -48,6 +51,16 @@ export type WecomBotConfig = {
48
51
  webhook?: WecomBotWebhookConfig;
49
52
  };
50
53
 
54
+ /**
55
+ * 上下游企业配置
56
+ * 根据企业微信文档,只需要配置下游企业的 CorpID 和 AgentID
57
+ * 不需要下游企业的 agentSecret,使用主企业的 corpSecret 获取下游企业的 access_token
58
+ */
59
+ export type WecomUpstreamCorpConfig = {
60
+ corpId: string;
61
+ agentId: number;
62
+ };
63
+
51
64
  export type WecomAgentConfig = {
52
65
  corpId: string;
53
66
  agentSecret?: string;
@@ -61,6 +74,14 @@ export type WecomAgentConfig = {
61
74
  encodingAESKey: string;
62
75
  welcomeText?: string;
63
76
  dm?: WecomDmConfig;
77
+ /**
78
+ * 上下游企业配置映射
79
+ * key: 配置名称(可自定义)
80
+ * value: 下游企业的 CorpID 和 AgentID
81
+ *
82
+ * 注意:不需要配置 agentSecret,使用主企业的 corpSecret 获取下游企业的 access_token
83
+ */
84
+ upstreamCorps?: Record<string, WecomUpstreamCorpConfig>;
64
85
  };
65
86
 
66
87
  export type WecomDynamicAgentsConfig = {
@@ -81,6 +102,7 @@ export type WecomAccountConfig = {
81
102
  export type WecomConfig = {
82
103
  enabled?: boolean;
83
104
  mediaMaxMb?: number;
105
+ mediaDownloadTimeoutMs?: number;
84
106
  bot?: WecomBotConfig;
85
107
  agent?: WecomAgentConfig;
86
108
  accounts?: Record<string, WecomAccountConfig>;
@@ -59,14 +59,16 @@ export type WecomBotInboundEvent = WecomBotInboundBase & {
59
59
  * **WecomInboundQuote (引用消息)**
60
60
  *
61
61
  * 消息中引用的原始内容(如回复某条消息)。
62
- * 支持引用文本、图片、混合类型、语音、文件等。
62
+ * 支持引用文本、图片、混合类型、语音、文件、视频等多种媒体类型。
63
+ *
64
+ * 注意:引用中的媒体 URL 时效约 5 分钟,必须尽快下载和解密。
63
65
  */
64
66
  export type WecomInboundQuote = {
65
- msgtype?: "text" | "image" | "mixed" | "voice" | "file";
67
+ msgtype?: "text" | "image" | "mixed" | "voice" | "file" | "video";
66
68
  /** 引用文本内容 */
67
69
  text?: { content?: string };
68
- /** 引用图片 URL */
69
- image?: { url?: string };
70
+ /** 引用图片 URL,可包含出现时的加密密钥 aeskey */
71
+ image?: { url?: string; aeskey?: string };
70
72
  /** 引用混合消息 (图文) */
71
73
  mixed?: {
72
74
  msg_item?: Array<{
@@ -75,10 +77,12 @@ export type WecomInboundQuote = {
75
77
  image?: { url?: string };
76
78
  }>;
77
79
  };
78
- /** 引用语音 */
80
+ /** 引用语音 - 仅含转写文本,无 URL 需下载(按纯文本处理) */
79
81
  voice?: { content?: string };
80
- /** 引用文件 */
81
- file?: { url?: string };
82
+ /** 引用文件 URL 及其加密密钥 */
83
+ file?: { url?: string; aeskey?: string };
84
+ /** 引用视频 URL 及其加密密钥(新增支持) */
85
+ video?: { url?: string; aeskey?: string };
82
86
  };
83
87
 
84
88
  export type WecomBotInboundMessage =
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 上下游企业支持模块
3
+ *
4
+ * 根据企业微信文档:https://developer.work.weixin.qq.com/document/path/97213
5
+ *
6
+ * 关键逻辑:
7
+ * 1. 上下游企业消息中的 ToUserName 是下游企业的 CorpID
8
+ * 2. 需要使用下游企业的 access_token 来发送消息
9
+ * 3. 获取下游企业 access_token 的接口:
10
+ * POST https://qyapi.weixin.qq.com/cgi-bin/corpgroup/corp/gettoken?access_token=ACCESS_TOKEN
11
+ * {
12
+ * "corpid": "下游企业corpid",
13
+ * "business_type": 1, // 1 表示上下游企业
14
+ * "agentid": 下游企业应用ID
15
+ * }
16
+ * 4. 需要使用上游企业的 access_token 作为调用凭证
17
+ */
18
+
19
+ import type { ResolvedAgentAccount } from "../types/index.js";
20
+
21
+ export type UpstreamCorpConfig = {
22
+ corpId: string;
23
+ agentId: number;
24
+ };
25
+
26
+ /**
27
+ * 从消息中检测是否是上下游用户
28
+ * 通过比较消息中的 ToUserName(CorpID)与配置的 CorpID
29
+ */
30
+ export function detectUpstreamUser(params: {
31
+ messageToUserName: string;
32
+ primaryCorpId: string;
33
+ }): boolean {
34
+ const { messageToUserName, primaryCorpId } = params;
35
+ if (!messageToUserName?.trim() || !primaryCorpId?.trim()) {
36
+ return false;
37
+ }
38
+ const normalizedMessageCorpId = messageToUserName.trim().toLowerCase();
39
+ const normalizedPrimaryCorpId = primaryCorpId.trim().toLowerCase();
40
+
41
+ // 如果消息中的 CorpID 与主 CorpID 不同,则是上下游用户
42
+ return normalizedMessageCorpId !== normalizedPrimaryCorpId;
43
+ }
44
+
45
+ /**
46
+ * 为上下游用户创建临时的 Agent 配置
47
+ * 使用下游企业的 CorpID 和 AgentID,但保持主企业的 corpSecret
48
+ *
49
+ * 注意:这个配置用于发送消息,但获取 access_token 时需要使用专门的
50
+ * corpgroup/corp/gettoken 接口
51
+ */
52
+ export function createUpstreamAgentConfig(params: {
53
+ baseAgent: ResolvedAgentAccount;
54
+ upstreamCorpId: string;
55
+ upstreamAgentId: number;
56
+ }): ResolvedAgentAccount {
57
+ const { baseAgent, upstreamCorpId, upstreamAgentId } = params;
58
+
59
+ return {
60
+ ...baseAgent,
61
+ corpId: upstreamCorpId,
62
+ agentId: upstreamAgentId,
63
+ // corpSecret 保持主企业的,用于获取下游企业的 access_token
64
+ // token 和 encodingAESKey 保持主企业的,用于回调验证
65
+ };
66
+ }
67
+
68
+ /**
69
+ * 从配置中解析上下游企业映射
70
+ * 支持在 agent 配置中添加 upstreamCorps 字段
71
+ */
72
+ export function resolveUpstreamCorpConfig(params: {
73
+ upstreamCorpId: string;
74
+ upstreamCorps?: Record<string, UpstreamCorpConfig> | UpstreamCorpConfig[];
75
+ }): UpstreamCorpConfig | undefined {
76
+ const { upstreamCorpId, upstreamCorps } = params;
77
+
78
+ if (!upstreamCorps) {
79
+ return undefined;
80
+ }
81
+
82
+ // Normalize to array format (support both Record<string, ...> and array)
83
+ const entries: Array<[string, UpstreamCorpConfig]> = Array.isArray(upstreamCorps)
84
+ ? upstreamCorps.map((item, i) => [String(i), item])
85
+ : Object.entries(upstreamCorps);
86
+
87
+ // Find matching upstream config
88
+ const normalizedTargetCorpId = upstreamCorpId.trim().toLowerCase();
89
+
90
+ for (const [key, config] of entries) {
91
+ const normalizedConfigCorpId = config.corpId.trim().toLowerCase();
92
+
93
+ if (normalizedConfigCorpId === normalizedTargetCorpId) {
94
+ return config;
95
+ }
96
+ }
97
+
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * 构建上下游用户的回复目标
103
+ * 格式: wecom-agent-upstream:{accountId}:{corpId}:{userId}
104
+ */
105
+ export function buildUpstreamAgentSessionTarget(
106
+ userId: string,
107
+ accountId: string,
108
+ upstreamCorpId: string,
109
+ ): string {
110
+ return `wecom-agent-upstream:${accountId}:${upstreamCorpId}:${userId}`;
111
+ }
112
+
113
+ /**
114
+ * 解析上下游用户的回复目标
115
+ */
116
+ export function parseUpstreamAgentSessionTarget(
117
+ target: string,
118
+ ): { accountId: string; upstreamCorpId: string; userId: string } | undefined {
119
+ const prefix = "wecom-agent-upstream:";
120
+ if (target.startsWith(prefix)) {
121
+ const parts = target.slice(prefix.length).split(":");
122
+ if (parts.length !== 3) {
123
+ return undefined;
124
+ }
125
+
126
+ return {
127
+ accountId: parts[0]!,
128
+ upstreamCorpId: parts[1]!,
129
+ userId: parts[2]!,
130
+ };
131
+ }
132
+
133
+ // 兼容当前工作区里尚未持久化的新格式,避免旧会话目标失效。
134
+ const queryIndex = target.indexOf("?upstream_corp=");
135
+ if (queryIndex < 0 || !target.startsWith("wecom-agent:")) {
136
+ return undefined;
137
+ }
138
+ const pathPart = target.slice(0, queryIndex);
139
+ const upstreamCorpId = target.slice(queryIndex + "?upstream_corp=".length).trim();
140
+ const match = pathPart.match(/^wecom-agent:([^:]+):user:(.+)$/i);
141
+ if (!match || !upstreamCorpId) {
142
+ return undefined;
143
+ }
144
+
145
+ return {
146
+ accountId: match[1]!.trim(),
147
+ upstreamCorpId,
148
+ userId: match[2]!.trim(),
149
+ };
150
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveScopedWecomTarget } from "./target.js";
4
+ import {
5
+ buildUpstreamAgentSessionTarget,
6
+ parseUpstreamAgentSessionTarget,
7
+ } from "./upstream/index.js";
8
+
9
+ describe("upstream target helpers", () => {
10
+ it("builds and parses the canonical upstream agent target", () => {
11
+ const target = buildUpstreamAgentSessionTarget("zhangsan", "acct-a", "corp-up");
12
+
13
+ expect(target).toBe("wecom-agent-upstream:acct-a:corp-up:zhangsan");
14
+ expect(parseUpstreamAgentSessionTarget(target)).toEqual({
15
+ accountId: "acct-a",
16
+ upstreamCorpId: "corp-up",
17
+ userId: "zhangsan",
18
+ });
19
+ });
20
+
21
+ it("keeps compatibility with legacy upstream-prefixed targets", () => {
22
+ expect(
23
+ parseUpstreamAgentSessionTarget("wecom-agent-upstream:acct-a:corp-up:zhangsan"),
24
+ ).toEqual({
25
+ accountId: "acct-a",
26
+ upstreamCorpId: "corp-up",
27
+ userId: "zhangsan",
28
+ });
29
+ });
30
+
31
+ it("keeps compatibility with query-style upstream targets", () => {
32
+ expect(
33
+ parseUpstreamAgentSessionTarget(
34
+ "wecom-agent:acct-a:user:zhangsan?upstream_corp=corp-up",
35
+ ),
36
+ ).toEqual({
37
+ accountId: "acct-a",
38
+ upstreamCorpId: "corp-up",
39
+ userId: "zhangsan",
40
+ });
41
+ });
42
+
43
+ it("resolves upstream-scoped targets to the real touser without leaking corp metadata", () => {
44
+ expect(
45
+ resolveScopedWecomTarget("wecom-agent-upstream:acct-a:corp-up:zhangsan", "default"),
46
+ ).toEqual({
47
+ accountId: "acct-a",
48
+ target: { touser: "zhangsan" },
49
+ rawTarget: "zhangsan",
50
+ });
51
+
52
+ expect(
53
+ resolveScopedWecomTarget(
54
+ "wecom-agent:acct-a:user:zhangsan?upstream_corp=corp-up",
55
+ "default",
56
+ ),
57
+ ).toEqual({
58
+ accountId: "acct-a",
59
+ target: { touser: "zhangsan" },
60
+ rawTarget: "zhangsan",
61
+ });
62
+ });
63
+
64
+ it("keeps normal users and upstream users distinguishable", () => {
65
+ // 普通用户目标:不带 upstream 标识,应按普通 agent target 解析
66
+ expect(resolveScopedWecomTarget("wecom-agent:acct-a:user:zhangsan", "default")).toEqual({
67
+ accountId: "acct-a",
68
+ target: { touser: "zhangsan" },
69
+ rawTarget: "user:zhangsan",
70
+ });
71
+
72
+ // 上下游用户目标:带 upstream_corp,应走 upstream 解析
73
+ expect(
74
+ resolveScopedWecomTarget(
75
+ "wecom-agent:acct-a:user:zhangsan?upstream_corp=corp-up",
76
+ "default",
77
+ ),
78
+ ).toEqual({
79
+ accountId: "acct-a",
80
+ target: { touser: "zhangsan" },
81
+ rawTarget: "zhangsan",
82
+ });
83
+ });
84
+ });
package/vitest.config.ts CHANGED
@@ -1,15 +1,26 @@
1
- import { defineConfig } from "vitest/config";
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
 
3
- import baseConfig from "../../vitest.config.ts";
5
+ import { defineConfig } from "vitest/config";
4
6
 
5
- const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
7
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
8
+ const sharedConfigPath = path.resolve(currentDir, "../../vitest.config.ts");
9
+ const sharedConfigModule = existsSync(sharedConfigPath)
10
+ ? await import(sharedConfigPath)
11
+ : undefined;
12
+ const baseConfig = (sharedConfigModule?.default ?? {}) as { test?: { exclude?: string[] } };
13
+ const baseTest = baseConfig.test ?? {};
6
14
  const exclude = baseTest.exclude ?? [];
15
+ const include = sharedConfigModule
16
+ ? ["extensions/wecom/src/**/*.test.ts"]
17
+ : ["src/**/*.test.ts", "index.test.ts"];
7
18
 
8
19
  export default defineConfig({
9
20
  ...baseConfig,
10
21
  test: {
11
22
  ...baseTest,
12
- include: ["extensions/wecom/src/**/*.test.ts"],
23
+ include,
13
24
  exclude,
14
25
  },
15
26
  });