@huo15/dingtalk-connector-pro 1.0.5 → 1.0.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.
Files changed (141) hide show
  1. package/README.en.md +106 -384
  2. package/README.md +14 -18
  3. package/dist/index.js +17 -0
  4. package/dist/openclaw.plugin.json +498 -0
  5. package/dist/package.json +91 -0
  6. package/dist/src/channel.js +415 -0
  7. package/dist/src/config/accounts.js +182 -0
  8. package/dist/src/config/schema.js +135 -0
  9. package/dist/src/core/connection.js +561 -0
  10. package/dist/src/core/message-handler.js +1422 -0
  11. package/dist/src/core/provider.js +59 -0
  12. package/dist/src/core/state.js +49 -0
  13. package/dist/src/directory.js +53 -0
  14. package/dist/src/docs.js +209 -0
  15. package/dist/src/gateway-methods.js +360 -0
  16. package/dist/src/onboarding.js +337 -0
  17. package/dist/src/policy.js +15 -0
  18. package/dist/src/probe.js +144 -0
  19. package/dist/src/reply-dispatcher.js +435 -0
  20. package/dist/src/runtime.js +26 -0
  21. package/dist/src/sdk/helpers.js +237 -0
  22. package/dist/src/sdk/types.js +13 -0
  23. package/dist/src/secret-input.js +13 -0
  24. package/dist/src/services/media/audio.js +40 -0
  25. package/dist/src/services/media/chunk-upload.js +211 -0
  26. package/dist/src/services/media/common.js +120 -0
  27. package/dist/src/services/media/file.js +54 -0
  28. package/dist/src/services/media/image.js +59 -0
  29. package/dist/src/services/media/index.js +9 -0
  30. package/dist/src/services/media/video.js +133 -0
  31. package/dist/src/services/media.js +889 -0
  32. package/dist/src/services/messaging/card.js +234 -0
  33. package/dist/src/services/messaging/index.js +8 -0
  34. package/dist/src/services/messaging/send.js +85 -0
  35. package/dist/src/services/messaging.js +680 -0
  36. package/dist/src/targets.js +38 -0
  37. package/dist/src/types/index.js +1 -0
  38. package/dist/src/utils/agent.js +55 -0
  39. package/dist/src/utils/async.js +40 -0
  40. package/dist/src/utils/constants.js +24 -0
  41. package/dist/src/utils/http-client.js +33 -0
  42. package/dist/src/utils/index.js +7 -0
  43. package/dist/src/utils/logger.js +76 -0
  44. package/dist/src/utils/session.js +95 -0
  45. package/dist/src/utils/token.js +71 -0
  46. package/dist/src/utils/utils-legacy.js +393 -0
  47. package/index.ts +3 -3
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +16 -5
  50. package/src/channel.js +415 -0
  51. package/src/channel.ts +12 -12
  52. package/src/config/accounts.js +182 -0
  53. package/src/config/accounts.ts +2 -2
  54. package/src/config/schema.js +135 -0
  55. package/src/config/schema.ts +2 -2
  56. package/src/core/connection.js +561 -0
  57. package/src/core/connection.ts +2 -2
  58. package/src/core/message-handler.js +1422 -0
  59. package/src/core/message-handler.ts +12 -12
  60. package/src/core/provider.js +59 -0
  61. package/src/core/provider.ts +4 -4
  62. package/src/core/state.js +49 -0
  63. package/src/directory.js +53 -0
  64. package/src/directory.ts +2 -2
  65. package/src/docs.js +209 -0
  66. package/src/docs.ts +3 -3
  67. package/src/gateway-methods.js +360 -0
  68. package/src/gateway-methods.ts +5 -5
  69. package/src/onboarding.js +337 -0
  70. package/src/onboarding.ts +4 -4
  71. package/src/policy.js +15 -0
  72. package/src/policy.ts +1 -1
  73. package/src/probe.js +144 -0
  74. package/src/probe.ts +2 -2
  75. package/src/reply-dispatcher.js +435 -0
  76. package/src/reply-dispatcher.ts +9 -9
  77. package/src/runtime.js +26 -0
  78. package/src/sdk/helpers.js +237 -0
  79. package/src/sdk/helpers.ts +1 -1
  80. package/src/sdk/types.js +13 -0
  81. package/src/secret-input.js +13 -0
  82. package/src/secret-input.ts +1 -1
  83. package/src/services/media/audio.js +40 -0
  84. package/src/services/media/audio.ts +2 -2
  85. package/src/services/media/chunk-upload.js +211 -0
  86. package/src/services/media/chunk-upload.ts +2 -2
  87. package/src/services/media/common.js +120 -0
  88. package/src/services/media/common.ts +3 -3
  89. package/src/services/media/file.js +54 -0
  90. package/src/services/media/file.ts +2 -2
  91. package/src/services/media/image.js +59 -0
  92. package/src/services/media/image.ts +2 -2
  93. package/src/services/media/index.js +9 -0
  94. package/src/services/media/index.ts +6 -6
  95. package/src/services/media/video.js +133 -0
  96. package/src/services/media/video.ts +2 -2
  97. package/src/services/media.js +889 -0
  98. package/src/services/media.ts +12 -12
  99. package/src/services/messaging/card.js +234 -0
  100. package/src/services/messaging/card.ts +3 -3
  101. package/src/services/messaging/index.js +8 -0
  102. package/src/services/messaging/index.ts +3 -3
  103. package/src/services/messaging/send.js +85 -0
  104. package/src/services/messaging/send.ts +3 -3
  105. package/src/services/messaging.js +680 -0
  106. package/src/services/messaging.ts +8 -8
  107. package/src/targets.js +38 -0
  108. package/src/targets.ts +1 -1
  109. package/src/types/index.js +1 -0
  110. package/src/types/index.ts +1 -1
  111. package/src/utils/agent.js +55 -0
  112. package/src/utils/async.js +40 -0
  113. package/src/utils/constants.js +24 -0
  114. package/src/utils/http-client.js +33 -0
  115. package/src/utils/http-client.ts +1 -1
  116. package/src/utils/index.js +7 -0
  117. package/src/utils/index.ts +4 -4
  118. package/src/utils/logger.js +76 -0
  119. package/src/utils/session.js +95 -0
  120. package/src/utils/session.ts +1 -1
  121. package/src/utils/token.js +71 -0
  122. package/src/utils/token.ts +2 -2
  123. package/src/utils/utils-legacy.js +393 -0
  124. package/src/utils/utils-legacy.ts +8 -8
  125. package/CHANGELOG.md +0 -485
  126. package/SKILL.md +0 -40
  127. package/_meta.json +0 -4
  128. package/docs/AGENT_ROUTING.md +0 -335
  129. package/docs/DEAP_AGENT_GUIDE.en.md +0 -115
  130. package/docs/DEAP_AGENT_GUIDE.md +0 -115
  131. package/docs/images/dingtalk.svg +0 -1
  132. package/docs/images/image-1.png +0 -0
  133. package/docs/images/image-2.png +0 -0
  134. package/docs/images/image-3.png +0 -0
  135. package/docs/images/image-4.png +0 -0
  136. package/docs/images/image-5.png +0 -0
  137. package/docs/images/image-6.png +0 -0
  138. package/docs/images/image-7.png +0 -0
  139. package/install-beta.sh +0 -438
  140. package/install-npm.sh +0 -167
  141. package/tsconfig.json +0 -20
@@ -0,0 +1,237 @@
1
+ /**
2
+ * DingTalk Connector SDK Helpers
3
+ *
4
+ * 完全独立的辅助函数,不依赖任何外部 SDK。
5
+ */
6
+ // ============================================================================
7
+ // 账号 ID 处理
8
+ // ============================================================================
9
+ /**
10
+ * 默认账号 ID
11
+ */
12
+ export const DEFAULT_ACCOUNT_ID = "__default__";
13
+ /**
14
+ * 规范化账号 ID
15
+ *
16
+ * 注意:账号 ID 保留原始大小写,仅做 trim 处理。
17
+ * 不做 toLowerCase,因为配置文件中的 accounts key 是大小写敏感的,
18
+ * 如 "zhizaoDashuIP" 与 "zhizaodashuip" 是不同的账号。
19
+ * 特殊值 "default"(不区分大小写)和空字符串映射到 DEFAULT_ACCOUNT_ID。
20
+ */
21
+ export function normalizeAccountId(accountId) {
22
+ const trimmed = accountId.trim();
23
+ if (trimmed.toLowerCase() === "default" || trimmed === "") {
24
+ return DEFAULT_ACCOUNT_ID;
25
+ }
26
+ return trimmed;
27
+ }
28
+ // ============================================================================
29
+ // SecretInput 处理
30
+ // ============================================================================
31
+ /**
32
+ * 判断是否为 SecretInput 引用
33
+ */
34
+ export function isSecretInputRef(value) {
35
+ if (!value || typeof value !== "object") {
36
+ return false;
37
+ }
38
+ const ref = value;
39
+ return (typeof ref.source === "string" &&
40
+ ["env", "file", "exec"].includes(ref.source) &&
41
+ typeof ref.provider === "string" &&
42
+ ref.provider.length > 0 &&
43
+ typeof ref.id === "string" &&
44
+ ref.id.length > 0);
45
+ }
46
+ /**
47
+ * 规范化 SecretInput 字符串
48
+ * 用于显示和日志,会隐藏敏感信息
49
+ */
50
+ export function normalizeSecretInputString(value) {
51
+ if (typeof value === "string") {
52
+ const trimmed = value.trim();
53
+ return trimmed || undefined;
54
+ }
55
+ if (isSecretInputRef(value)) {
56
+ const ref = value;
57
+ return `<${ref.source}:${ref.provider}:${ref.id}>`;
58
+ }
59
+ return undefined;
60
+ }
61
+ /**
62
+ * 解析 SecretInput 为实际值
63
+ * 用于运行时获取实际的敏感信息
64
+ */
65
+ export function resolveSecretInputValue(value, options) {
66
+ // 直接字符串
67
+ if (typeof value === "string") {
68
+ const trimmed = value.trim();
69
+ return trimmed || undefined;
70
+ }
71
+ // SecretInput 引用
72
+ if (isSecretInputRef(value)) {
73
+ const ref = value;
74
+ // 环境变量
75
+ if (ref.source === "env" && options?.allowEnvRead) {
76
+ const envValue = process.env[ref.id];
77
+ if (typeof envValue === "string") {
78
+ return envValue.trim() || undefined;
79
+ }
80
+ }
81
+ // 文件或执行 - 返回引用字符串
82
+ return `<${ref.source}:${ref.provider}:${ref.id}>`;
83
+ }
84
+ return undefined;
85
+ }
86
+ /**
87
+ * 检查 SecretInput 是否已配置
88
+ */
89
+ export function hasConfiguredSecretInput(value) {
90
+ if (typeof value === "string") {
91
+ return value.trim().length > 0;
92
+ }
93
+ if (isSecretInputRef(value)) {
94
+ const ref = value;
95
+ if (ref.source === "env") {
96
+ return typeof process.env[ref.id] === "string" && process.env[ref.id].trim().length > 0;
97
+ }
98
+ // file 和 exec 总是认为已配置(运行时会验证)
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ /**
104
+ * 规范化已解析的 SecretInput 字符串
105
+ * 用于配置验证和错误提示
106
+ */
107
+ export function normalizeResolvedSecretInputString(params) {
108
+ const { value, path } = params;
109
+ // 直接字符串
110
+ if (typeof value === "string") {
111
+ const trimmed = value.trim();
112
+ if (trimmed) {
113
+ return trimmed;
114
+ }
115
+ throw new Error(`${path} must be a non-empty string`);
116
+ }
117
+ // SecretInput 引用
118
+ if (isSecretInputRef(value)) {
119
+ const ref = value;
120
+ // 验证引用格式
121
+ if (!["env", "file", "exec"].includes(ref.source)) {
122
+ throw new Error(`${path}.source must be one of: env, file, exec`);
123
+ }
124
+ if (typeof ref.provider !== "string" || !ref.provider.trim()) {
125
+ throw new Error(`${path}.provider must be a non-empty string`);
126
+ }
127
+ if (typeof ref.id !== "string" || !ref.id.trim()) {
128
+ throw new Error(`${path}.id must be a non-empty string`);
129
+ }
130
+ // 环境变量特殊处理
131
+ if (ref.source === "env") {
132
+ const envValue = process.env[ref.id];
133
+ if (!envValue || !envValue.trim()) {
134
+ throw new Error(`${path}: environment variable ${ref.id} is not set`);
135
+ }
136
+ return envValue.trim();
137
+ }
138
+ // file 和 exec 返回引用字符串
139
+ return `<${ref.source}:${ref.provider}:${ref.id}>`;
140
+ }
141
+ throw new Error(`${path} must be a string or SecretInput object`);
142
+ }
143
+ // ============================================================================
144
+ // 群组策略处理
145
+ // ============================================================================
146
+ /**
147
+ * 解析默认群组策略
148
+ */
149
+ export function resolveDefaultGroupPolicy(cfg) {
150
+ const dingtalkCfg = cfg.channels?.["dingtalk-connector"];
151
+ return dingtalkCfg?.groupPolicy ?? "open";
152
+ }
153
+ /**
154
+ * 解析允许列表提供者运行时群组策略
155
+ */
156
+ export function resolveAllowlistProviderRuntimeGroupPolicy(params) {
157
+ const { providerConfigPresent, groupPolicy, defaultGroupPolicy } = params;
158
+ if (groupPolicy) {
159
+ return { groupPolicy };
160
+ }
161
+ if (providerConfigPresent) {
162
+ return { groupPolicy: defaultGroupPolicy };
163
+ }
164
+ return { groupPolicy: "disabled" };
165
+ }
166
+ // ============================================================================
167
+ // 通道状态处理
168
+ // ============================================================================
169
+ /**
170
+ * 创建默认通道运行时状态
171
+ */
172
+ export function createDefaultChannelRuntimeState(accountId, extras) {
173
+ return {
174
+ running: false,
175
+ lastStartAt: null,
176
+ lastStopAt: null,
177
+ lastError: null,
178
+ port: null,
179
+ accountId,
180
+ ...extras,
181
+ };
182
+ }
183
+ /**
184
+ * 构建基础通道状态摘要
185
+ */
186
+ export function buildBaseChannelStatusSummary(snapshot) {
187
+ return {
188
+ accountId: snapshot.accountId,
189
+ enabled: snapshot.enabled,
190
+ configured: snapshot.configured,
191
+ name: snapshot.name,
192
+ running: snapshot.running ?? false,
193
+ lastStartAt: snapshot.lastStartAt ?? null,
194
+ lastStopAt: snapshot.lastStopAt ?? null,
195
+ lastError: snapshot.lastError ?? null,
196
+ };
197
+ }
198
+ // ============================================================================
199
+ // 其他辅助函数
200
+ // ============================================================================
201
+ /**
202
+ * 添加通配符到 allowFrom
203
+ */
204
+ export function addWildcardAllowFrom(existing) {
205
+ if (!existing || existing.length === 0) {
206
+ return ["*"];
207
+ }
208
+ if (existing.includes("*")) {
209
+ return existing;
210
+ }
211
+ return [...existing, "*"];
212
+ }
213
+ /**
214
+ * 格式化文档链接
215
+ */
216
+ export function formatDocsLink(path, label) {
217
+ return `https://docs.openclaw.ai${path}`;
218
+ }
219
+ /**
220
+ * 规范化字符串
221
+ */
222
+ export function normalizeString(value) {
223
+ if (typeof value !== "string") {
224
+ return undefined;
225
+ }
226
+ const trimmed = value.trim();
227
+ return trimmed || undefined;
228
+ }
229
+ /**
230
+ * 解析 allowFrom 输入
231
+ */
232
+ export function parseAllowFromInput(raw) {
233
+ return raw
234
+ .split(/[\n,;]+/g)
235
+ .map((entry) => entry.trim())
236
+ .filter(Boolean);
237
+ }
@@ -4,7 +4,7 @@
4
4
  * 完全独立的辅助函数,不依赖任何外部 SDK。
5
5
  */
6
6
 
7
- import type { SecretInput, SecretInputRef } from "./types.ts";
7
+ import type { SecretInput, SecretInputRef } from "./types.js";
8
8
 
9
9
  // ============================================================================
10
10
  // 账号 ID 处理
@@ -0,0 +1,13 @@
1
+ /**
2
+ * DingTalk Connector SDK Types
3
+ *
4
+ * 完全独立的类型定义,不依赖任何外部 SDK。
5
+ * 这是钉钉连接器插件的核心类型系统。
6
+ */
7
+ // ============================================================================
8
+ // 常量
9
+ // ============================================================================
10
+ /**
11
+ * 默认账号 ID
12
+ */
13
+ export const DEFAULT_ACCOUNT_ID = "default";
@@ -0,0 +1,13 @@
1
+ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "./sdk/helpers.js";
2
+ import { z } from "zod";
3
+ export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
4
+ export function buildSecretInputSchema() {
5
+ return z.union([
6
+ z.string(),
7
+ z.object({
8
+ source: z.enum(["env", "file", "exec"]),
9
+ provider: z.string().min(1),
10
+ id: z.string().min(1),
11
+ }),
12
+ ]);
13
+ }
@@ -2,7 +2,7 @@ import {
2
2
  hasConfiguredSecretInput,
3
3
  normalizeResolvedSecretInputString,
4
4
  normalizeSecretInputString,
5
- } from "./sdk/helpers.ts";
5
+ } from "./sdk/helpers.js";
6
6
  import { z } from "zod";
7
7
 
8
8
  export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 音频处理模块
3
+ * 支持音频消息发送
4
+ */
5
+ import { AUDIO_MARKER_PATTERN, toLocalPath, uploadMediaToDingTalk } from './common.js';
6
+ import * as fs from 'fs';
7
+ /**
8
+ * 提取音频标记并发送音频消息
9
+ */
10
+ export async function processAudioMarkers(content, sessionWebhook, config, oapiToken, log, useProactiveApi = false, target) {
11
+ const logPrefix = useProactiveApi ? '[DingTalk][Audio][Proactive]' : '[DingTalk][Audio]';
12
+ if (!oapiToken) {
13
+ log?.warn?.(`${logPrefix} 无 oapiToken,跳过音频处理`);
14
+ return content;
15
+ }
16
+ const matches = [...content.matchAll(AUDIO_MARKER_PATTERN)];
17
+ if (matches.length === 0)
18
+ return content;
19
+ log?.info?.(`${logPrefix} 检测到 ${matches.length} 个音频,开始上传...`);
20
+ let result = content;
21
+ for (const match of matches) {
22
+ const full = match[0];
23
+ try {
24
+ const audioData = JSON.parse(match[1]);
25
+ const absPath = toLocalPath(audioData.path);
26
+ if (!fs.existsSync(absPath)) {
27
+ log?.warn?.(`${logPrefix} 音频文件不存在:${absPath}`);
28
+ result = result.replace(full, '⚠️ 音频文件不存在');
29
+ continue;
30
+ }
31
+ const uploadResult = await uploadMediaToDingTalk(absPath, 'voice', oapiToken, 20 * 1024 * 1024, log);
32
+ result = result.replace(full, uploadResult ? `[音频已上传:${uploadResult}]` : '⚠️ 音频上传失败');
33
+ }
34
+ catch {
35
+ log?.warn?.(`${logPrefix} 解析音频标记失败:${match[1]}`);
36
+ result = result.replace(full, '');
37
+ }
38
+ }
39
+ return result.trim();
40
+ }
@@ -3,8 +3,8 @@
3
3
  * 支持音频消息发送
4
4
  */
5
5
 
6
- import type { DingtalkConfig } from '../../types/index.ts';
7
- import { AUDIO_MARKER_PATTERN, toLocalPath, uploadMediaToDingTalk } from './common.ts';
6
+ import type { DingtalkConfig } from '../../types/index.js';
7
+ import { AUDIO_MARKER_PATTERN, toLocalPath, uploadMediaToDingTalk } from './common.js';
8
8
  import * as fs from 'fs';
9
9
 
10
10
  /**
@@ -0,0 +1,211 @@
1
+ /**
2
+ * 钉钉文件分块上传模块
3
+ * 支持大文件(>20MB)的分块上传
4
+ *
5
+ * API 文档:
6
+ * - 开启事务:https://open.dingtalk.com/document/development/enable-upload-transaction
7
+ * - 上传块:https://open.dingtalk.com/document/development/upload-file-blocks
8
+ * - 提交事务:https://open.dingtalk.com/document/development/submit-a-file-upload-transaction
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { createLogger } from '../../utils/logger.js';
13
+ import { dingtalkOapiHttp } from '../../utils/http-client.js';
14
+ // form-data 是 CJS 模块,静态 import 可确保 jiti/ESM 环境下 CJS 互操作行为稳定,
15
+ // 避免动态 import 时 .default 偶发为 undefined 导致 "Cannot read properties of undefined (reading 'registry')"
16
+ import FormData from 'form-data';
17
+ const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
18
+ /** 分块上传配置 */
19
+ export const CHUNK_CONFIG = {
20
+ MIN_CHUNK_SIZE: 100 * 1024, // 最小分块 100KB
21
+ MAX_CHUNK_SIZE: 8 * 1024 * 1024, // 最大分块 8MB
22
+ DEFAULT_CHUNK_SIZE: 5 * 1024 * 1024, // 默认分块 5MB
23
+ SIZE_THRESHOLD: 20 * 1024 * 1024, // 超过 20MB 使用分块上传
24
+ };
25
+ /**
26
+ * 步骤一:开启分块上传事务
27
+ * @param oapiToken 钉钉 access_token
28
+ * @param fileName 文件名
29
+ * @param fileSize 文件大小(字节)
30
+ * @param log 日志对象
31
+ */
32
+ export async function enableUploadTransaction(oapiToken, fileName, fileSize, debug = false) {
33
+ const log = createLogger(debug, 'DingTalk][ChunkUpload');
34
+ try {
35
+ log.info(`开启上传事务:${fileName}, 大小:${(fileSize / 1024 / 1024).toFixed(2)}MB`);
36
+ const form = new FormData();
37
+ form.append('file_name', fileName);
38
+ form.append('file_size', fileSize.toString());
39
+ const resp = await dingtalkOapiHttp.post(`${DINGTALK_OAPI}/file/upload/transaction/enable`, form, {
40
+ params: { access_token: oapiToken },
41
+ headers: form.getHeaders(),
42
+ timeout: 60_000,
43
+ });
44
+ if (resp.data.errcode === 0) {
45
+ log.info(`事务开启成功,upload_id: ${resp.data.upload_id}`);
46
+ return resp.data.upload_id;
47
+ }
48
+ else {
49
+ log.error(`开启事务失败:${resp.data.errmsg}`);
50
+ return null;
51
+ }
52
+ }
53
+ catch (err) {
54
+ log.error(`开启事务异常:${err.message}`);
55
+ console.error(`开启事务异常详情:`, err.response?.data || err);
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * 步骤二:上传文件块
61
+ * @param oapiToken 钉钉 access_token
62
+ * @param uploadId 上传事务 ID
63
+ * @param chunkData 文件块数据
64
+ * @param chunkNumber 块编号(从 1 开始)
65
+ * @param totalChunks 总块数
66
+ * @param log 日志对象
67
+ */
68
+ export async function uploadFileBlock(oapiToken, uploadId, chunkData, chunkNumber, totalChunks, debug = false) {
69
+ const log = createLogger(debug, 'DingTalk][ChunkUpload');
70
+ try {
71
+ log.info(`上传块 ${chunkNumber}/${totalChunks}, 大小:${(chunkData.length / 1024).toFixed(2)}KB`);
72
+ const form = new FormData();
73
+ form.append('upload_id', uploadId);
74
+ form.append('chunk_number', chunkNumber.toString());
75
+ form.append('total_chunks', totalChunks.toString());
76
+ form.append('file', chunkData, {
77
+ filename: `chunk_${chunkNumber}`,
78
+ contentType: 'application/octet-stream',
79
+ });
80
+ const resp = await dingtalkOapiHttp.post(`${DINGTALK_OAPI}/file/upload/chunk`, form, {
81
+ params: { access_token: oapiToken },
82
+ headers: form.getHeaders(),
83
+ timeout: 60_000,
84
+ });
85
+ if (resp.data.errcode === 0) {
86
+ log.info(`块 ${chunkNumber} 上传成功`);
87
+ return true;
88
+ }
89
+ else {
90
+ log.error(`块 ${chunkNumber} 上传失败:${resp.data.errmsg}`);
91
+ return false;
92
+ }
93
+ }
94
+ catch (err) {
95
+ log.error(`块 ${chunkNumber} 上传异常:${err.message}`);
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * 步骤三:提交分块上传事务
101
+ * @param oapiToken 钉钉 access_token
102
+ * @param uploadId 上传事务 ID
103
+ * @param fileName 文件名
104
+ * @param log 日志对象
105
+ */
106
+ export async function submitUploadTransaction(oapiToken, uploadId, fileName, debug = false) {
107
+ const log = createLogger(debug, 'DingTalk][ChunkUpload');
108
+ try {
109
+ log.info(`提交上传事务:${uploadId}`);
110
+ const resp = await dingtalkOapiHttp.get(`${DINGTALK_OAPI}/file/upload/transaction/submit`, {
111
+ params: {
112
+ access_token: oapiToken,
113
+ upload_id: uploadId,
114
+ file_name: fileName,
115
+ },
116
+ timeout: 60_000,
117
+ });
118
+ if (resp.data.errcode === 0) {
119
+ log.info(`事务提交成功,file_id: ${resp.data.file_id}, download_code: ${resp.data.download_code}`);
120
+ return {
121
+ fileId: resp.data.file_id,
122
+ downloadCode: resp.data.download_code,
123
+ };
124
+ }
125
+ else {
126
+ log.error(`事务提交失败:${resp.data.errmsg}`);
127
+ return null;
128
+ }
129
+ }
130
+ catch (err) {
131
+ log.error(`事务提交异常:${err.message}`);
132
+ return null;
133
+ }
134
+ }
135
+ /**
136
+ * 计算分块参数
137
+ */
138
+ function calculateChunkParams(fileSize) {
139
+ // 根据文件大小动态调整分块大小
140
+ let chunkSize = CHUNK_CONFIG.DEFAULT_CHUNK_SIZE;
141
+ if (fileSize > 100 * 1024 * 1024) {
142
+ // >100MB,使用最大分块 8MB
143
+ chunkSize = CHUNK_CONFIG.MAX_CHUNK_SIZE;
144
+ }
145
+ else if (fileSize > 50 * 1024 * 1024) {
146
+ // >50MB,使用 6MB 分块
147
+ chunkSize = 6 * 1024 * 1024;
148
+ }
149
+ const totalChunks = Math.ceil(fileSize / chunkSize);
150
+ return { chunkSize, totalChunks };
151
+ }
152
+ /**
153
+ * 分块上传大文件(>20MB)
154
+ * @param filePath 文件路径
155
+ * @param mediaType 媒体类型:video, file
156
+ * @param oapiToken 钉钉 access_token
157
+ * @param log 日志对象
158
+ * @returns download_code 或 null
159
+ */
160
+ export async function uploadLargeFileByChunks(filePath, mediaType, oapiToken, debug = false) {
161
+ const log = createLogger(debug, 'DingTalk][ChunkUpload');
162
+ try {
163
+ const absPath = path.resolve(filePath);
164
+ if (!fs.existsSync(absPath)) {
165
+ log.warn(`文件不存在:${absPath}`);
166
+ return null;
167
+ }
168
+ const stats = fs.statSync(absPath);
169
+ const fileSize = stats.size;
170
+ const fileName = path.basename(absPath);
171
+ const fileSizeMB = (fileSize / 1024 / 1024).toFixed(2);
172
+ log.info(`开始分块上传:${fileName}, 大小:${fileSizeMB}MB, 类型:${mediaType}`);
173
+ // 步骤一:开启上传事务
174
+ const uploadId = await enableUploadTransaction(oapiToken, fileName, fileSize, debug);
175
+ if (!uploadId) {
176
+ log.error(`开启事务失败,终止上传`);
177
+ return null;
178
+ }
179
+ // 计算分块参数
180
+ const { chunkSize, totalChunks } = calculateChunkParams(fileSize);
181
+ log.info(`分块参数:chunkSize=${(chunkSize / 1024 / 1024).toFixed(2)}MB, totalChunks=${totalChunks}`);
182
+ // 步骤二:分块上传
183
+ const fileBuffer = fs.readFileSync(absPath);
184
+ let successCount = 0;
185
+ for (let i = 0; i < totalChunks; i++) {
186
+ const start = i * chunkSize;
187
+ const end = Math.min(start + chunkSize, fileSize);
188
+ const chunkData = fileBuffer.slice(start, end);
189
+ const success = await uploadFileBlock(oapiToken, uploadId, chunkData, i + 1, // chunkNumber 从 1 开始
190
+ totalChunks, debug);
191
+ if (!success) {
192
+ log.error(`块 ${i + 1} 上传失败,终止上传`);
193
+ return null;
194
+ }
195
+ successCount++;
196
+ log.info(`进度:${successCount}/${totalChunks} (${((successCount / totalChunks) * 100).toFixed(1)}%)`);
197
+ }
198
+ // 步骤三:提交上传事务
199
+ const result = await submitUploadTransaction(oapiToken, uploadId, fileName, debug);
200
+ if (!result || !result.downloadCode) {
201
+ log.error(`提交事务失败`);
202
+ return null;
203
+ }
204
+ log.info(`分块上传完成:${fileName}, download_code: ${result.downloadCode}`);
205
+ return result.downloadCode;
206
+ }
207
+ catch (err) {
208
+ log.error(`分块上传异常:${err.message}`);
209
+ return null;
210
+ }
211
+ }
@@ -10,8 +10,8 @@
10
10
 
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
- import { createLogger } from '../../utils/logger.ts';
14
- import { dingtalkOapiHttp, dingtalkUploadHttp } from '../../utils/http-client.ts';
13
+ import { createLogger } from '../../utils/logger.js';
14
+ import { dingtalkOapiHttp, dingtalkUploadHttp } from '../../utils/http-client.js';
15
15
  // form-data 是 CJS 模块,静态 import 可确保 jiti/ESM 环境下 CJS 互操作行为稳定,
16
16
  // 避免动态 import 时 .default 偶发为 undefined 导致 "Cannot read properties of undefined (reading 'registry')"
17
17
  import FormData from 'form-data';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 媒体处理公共工具和常量
3
+ */
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ // form-data 是 CJS 模块,静态 import 可确保 jiti/ESM 环境下 CJS 互操作行为稳定,
7
+ // 避免动态 import 时 .default 偶发为 undefined 导致 "Cannot read properties of undefined (reading 'registry')"
8
+ import FormData from 'form-data';
9
+ import { createLogger } from '../../utils/logger.js';
10
+ import { CHUNK_CONFIG } from './chunk-upload.js';
11
+ import { dingtalkUploadHttp } from '../../utils/http-client.js';
12
+ // ============ 常量 ============
13
+ /** 文本文件扩展名 */
14
+ export const TEXT_FILE_EXTENSIONS = new Set([
15
+ '.txt', '.md', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
16
+ '.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.sh', '.bat', '.csv',
17
+ ]);
18
+ /** 图片文件扩展名 */
19
+ export const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|gif|bmp|webp|tiff|svg)$/i;
20
+ /** 本地图片路径正则表达式(跨平台) */
21
+ export const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\(((?:file:\/\/|MEDIA:|attachment:\/\/)[^)]+|\/(?:tmp|var|private|Users|home|root)[^)]+|[A-Za-z]:[\\/][^)]+)\)/g;
22
+ /** 纯文本图片路径正则表达式 */
23
+ export const BARE_IMAGE_PATH_RE = /`?((?:\/(?:tmp|var|private|Users|home|root)\/[^\s`'",)]+|[A-Za-z]:[\\/][^\s`'",)]+)\.(?:png|jpg|jpeg|gif|bmp|webp))`?/gi;
24
+ /** 视频标记正则表达式 */
25
+ export const VIDEO_MARKER_PATTERN = /\[DINGTALK_VIDEO\](.*?)\[\/DINGTALK_VIDEO\]/gs;
26
+ /** 音频标记正则表达式 */
27
+ export const AUDIO_MARKER_PATTERN = /\[DINGTALK_AUDIO\](.*?)\[\/DINGTALK_AUDIO\]/gs;
28
+ /** 文件标记正则表达式 */
29
+ export const FILE_MARKER_PATTERN = /\[DINGTALK_FILE\](.*?)\[\/DINGTALK_FILE\]/gs;
30
+ // ============ 工具函数 ============
31
+ /**
32
+ * 去掉 file:// / MEDIA: / attachment:// 前缀,得到实际的绝对路径
33
+ */
34
+ export function toLocalPath(raw) {
35
+ let filePath = raw;
36
+ if (filePath.startsWith('file://'))
37
+ filePath = filePath.replace('file://', '');
38
+ else if (filePath.startsWith('MEDIA:'))
39
+ filePath = filePath.replace('MEDIA:', '');
40
+ else if (filePath.startsWith('attachment://'))
41
+ filePath = filePath.replace('attachment://', '');
42
+ try {
43
+ filePath = decodeURIComponent(filePath);
44
+ }
45
+ catch {
46
+ // 解码失败则保持原样
47
+ }
48
+ return filePath;
49
+ }
50
+ /**
51
+ * 谨慎使用,返回的是cleanMediaId
52
+ * 后续逐步删除,可用 media.ts
53
+ */
54
+ export async function uploadMediaToDingTalk(filePath, mediaType, oapiToken, maxSize = 20 * 1024 * 1024, logOrDebug, debug) {
55
+ const debugEnabled = typeof logOrDebug === 'boolean' ? logOrDebug === true : debug === true;
56
+ const externalLog = typeof logOrDebug === 'boolean' ? undefined : logOrDebug;
57
+ const log = externalLog ?? createLogger(debugEnabled, `DingTalk][${mediaType}`);
58
+ log?.info?.(`[uploadMediaToDingTalk] 开始上传,filePath: ${filePath}, mediaType: ${mediaType}, debug: ${debugEnabled}`);
59
+ try {
60
+ const absPath = toLocalPath(filePath);
61
+ log?.info?.(`检查文件是否存在:${absPath}`);
62
+ if (!fs.existsSync(absPath)) {
63
+ log?.warn?.(`文件不存在:${absPath}`);
64
+ return null;
65
+ }
66
+ const stats = fs.statSync(absPath);
67
+ const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
68
+ const fileSize = stats.size;
69
+ // ✅ 对于视频和文件类型,如果超过 20MB,使用分块上传
70
+ if ((mediaType === 'video' || mediaType === 'file') && fileSize > CHUNK_CONFIG.SIZE_THRESHOLD) {
71
+ log?.info?.(`文件超过 20MB,使用分块上传:${absPath} (${fileSizeMB}MB)`);
72
+ try {
73
+ const { uploadLargeFileByChunks } = await import('./chunk-upload');
74
+ const downloadCode = await uploadLargeFileByChunks(absPath, mediaType, oapiToken, debugEnabled);
75
+ if (downloadCode) {
76
+ log?.info?.(`分块上传成功:${absPath}, download_code: ${downloadCode}`);
77
+ return downloadCode;
78
+ }
79
+ log?.error?.(`分块上传失败:${absPath}`);
80
+ }
81
+ catch (chunkErr) {
82
+ log?.error?.(`分块上传异常:${chunkErr.message}`);
83
+ }
84
+ return null;
85
+ }
86
+ // 检查文件大小(对于小于 20MB 的文件)
87
+ if (stats.size > maxSize) {
88
+ const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(0);
89
+ log?.warn?.(`文件过大:${absPath}, 大小:${fileSizeMB}MB, 超过限制 ${maxSizeMB}MB`);
90
+ return null;
91
+ }
92
+ const form = new FormData();
93
+ form.append('media', fs.createReadStream(absPath), {
94
+ filename: path.basename(absPath),
95
+ contentType: mediaType === 'image' ? 'image/jpeg' : 'application/octet-stream',
96
+ });
97
+ const uploadType = mediaType;
98
+ log?.info?.(`上传文件:${absPath} (${fileSizeMB}MB), uploadType=${uploadType}`);
99
+ const resp = await dingtalkUploadHttp.post(`${DINGTALK_OAPI}/media/upload`, form, {
100
+ params: { access_token: oapiToken, type: mediaType },
101
+ headers: form.getHeaders(),
102
+ timeout: 60_000,
103
+ maxBodyLength: Infinity,
104
+ });
105
+ const mediaId = resp.data?.media_id;
106
+ if (mediaId) {
107
+ const cleanMediaId = mediaId.startsWith('@') ? mediaId.substring(1) : mediaId;
108
+ log?.info?.(`上传成功:mediaId=${cleanMediaId}`);
109
+ return cleanMediaId;
110
+ }
111
+ log?.warn?.(`上传返回无 media_id`);
112
+ return null;
113
+ }
114
+ catch (err) {
115
+ log?.error?.(`上传失败:${err.message}`);
116
+ return null;
117
+ }
118
+ }
119
+ /** 钉钉 OAPI 常量 */
120
+ export const DINGTALK_OAPI = 'https://oapi.dingtalk.com';