@huo15/dingtalk-connector-pro 1.0.0 → 1.0.2

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 (60) hide show
  1. package/dist/hooks/init.js +16 -0
  2. package/hooks/init.js +18 -0
  3. package/package.json +25 -11
  4. package/CHANGELOG.md +0 -485
  5. package/docs/AGENT_ROUTING.md +0 -335
  6. package/docs/DEAP_AGENT_GUIDE.en.md +0 -115
  7. package/docs/DEAP_AGENT_GUIDE.md +0 -115
  8. package/docs/images/dingtalk.svg +0 -1
  9. package/docs/images/image-1.png +0 -0
  10. package/docs/images/image-2.png +0 -0
  11. package/docs/images/image-3.png +0 -0
  12. package/docs/images/image-4.png +0 -0
  13. package/docs/images/image-5.png +0 -0
  14. package/docs/images/image-6.png +0 -0
  15. package/docs/images/image-7.png +0 -0
  16. package/install-beta.sh +0 -438
  17. package/install-npm.sh +0 -167
  18. package/openclaw.plugin.json +0 -498
  19. package/src/channel.ts +0 -463
  20. package/src/config/accounts.ts +0 -242
  21. package/src/config/schema.ts +0 -148
  22. package/src/core/connection.ts +0 -722
  23. package/src/core/message-handler.ts +0 -1700
  24. package/src/core/provider.ts +0 -111
  25. package/src/core/state.ts +0 -54
  26. package/src/directory.ts +0 -95
  27. package/src/docs.ts +0 -293
  28. package/src/gateway-methods.ts +0 -404
  29. package/src/onboarding.ts +0 -413
  30. package/src/policy.ts +0 -32
  31. package/src/probe.ts +0 -212
  32. package/src/reply-dispatcher.ts +0 -630
  33. package/src/runtime.ts +0 -32
  34. package/src/sdk/helpers.ts +0 -322
  35. package/src/sdk/types.ts +0 -513
  36. package/src/secret-input.ts +0 -19
  37. package/src/services/media/audio.ts +0 -54
  38. package/src/services/media/chunk-upload.ts +0 -296
  39. package/src/services/media/common.ts +0 -155
  40. package/src/services/media/file.ts +0 -70
  41. package/src/services/media/image.ts +0 -81
  42. package/src/services/media/index.ts +0 -10
  43. package/src/services/media/video.ts +0 -162
  44. package/src/services/media.ts +0 -1136
  45. package/src/services/messaging/card.ts +0 -342
  46. package/src/services/messaging/index.ts +0 -17
  47. package/src/services/messaging/send.ts +0 -141
  48. package/src/services/messaging.ts +0 -1013
  49. package/src/targets.ts +0 -45
  50. package/src/types/index.ts +0 -59
  51. package/src/utils/agent.ts +0 -63
  52. package/src/utils/async.ts +0 -51
  53. package/src/utils/constants.ts +0 -27
  54. package/src/utils/http-client.ts +0 -37
  55. package/src/utils/index.ts +0 -8
  56. package/src/utils/logger.ts +0 -78
  57. package/src/utils/session.ts +0 -147
  58. package/src/utils/token.ts +0 -93
  59. package/src/utils/utils-legacy.ts +0 -454
  60. package/tsconfig.json +0 -20
package/src/targets.ts DELETED
@@ -1,45 +0,0 @@
1
- import type { DingtalkMessageContext } from "./types/index.ts";
2
-
3
- function stripProviderPrefix(raw: string): string {
4
- return raw.replace(/^(dingtalk|dd|ding):/i, "").trim();
5
- }
6
-
7
- export function normalizeDingtalkTarget(raw: string): string | null {
8
- const trimmed = raw.trim();
9
- if (!trimmed) {
10
- return null;
11
- }
12
-
13
- const withoutProvider = stripProviderPrefix(trimmed);
14
- const lowered = withoutProvider.toLowerCase();
15
- if (lowered.startsWith("user:")) {
16
- return withoutProvider.slice("user:".length).trim() || null;
17
- }
18
- if (lowered.startsWith("group:")) {
19
- return withoutProvider.slice("group:".length).trim() || null;
20
- }
21
-
22
- return withoutProvider;
23
- }
24
-
25
- export function formatDingtalkTarget(id: string, type?: "user" | "group"): string {
26
- const trimmed = id.trim();
27
- if (type === "group") {
28
- return `group:${trimmed}`;
29
- }
30
- if (type === "user") {
31
- return `user:${trimmed}`;
32
- }
33
- return trimmed;
34
- }
35
-
36
- export function looksLikeDingtalkId(raw: string): boolean {
37
- const trimmed = stripProviderPrefix(raw.trim());
38
- if (!trimmed) {
39
- return false;
40
- }
41
- if (/^(user|group):/i.test(trimmed)) {
42
- return true;
43
- }
44
- return true;
45
- }
@@ -1,59 +0,0 @@
1
- // 本地类型定义
2
- interface BaseProbeResult<T = any> {
3
- ok: boolean;
4
- error?: string;
5
- data?: T;
6
- [key: string]: any;
7
- }
8
-
9
- import type {
10
- DingtalkConfigSchema,
11
- DingtalkGroupSchema,
12
- DingtalkAccountConfigSchema,
13
- z,
14
- } from "../config/schema.ts";
15
-
16
- export type DingtalkConfig = z.infer<typeof DingtalkConfigSchema>;
17
- export type DingtalkGroupConfig = z.infer<typeof DingtalkGroupSchema>;
18
- export type DingtalkAccountConfig = z.infer<typeof DingtalkAccountConfigSchema>;
19
-
20
- export type DingtalkConnectionMode = "stream";
21
-
22
- export type DingtalkDefaultAccountSelectionSource =
23
- | "explicit-default"
24
- | "mapped-default"
25
- | "fallback";
26
- export type DingtalkAccountSelectionSource = "explicit" | DingtalkDefaultAccountSelectionSource;
27
-
28
- export type ResolvedDingtalkAccount = {
29
- accountId: string;
30
- selectionSource: DingtalkAccountSelectionSource;
31
- enabled: boolean;
32
- configured: boolean;
33
- name?: string;
34
- clientId?: string;
35
- clientSecret?: string;
36
- /** Merged config (top-level defaults + account-specific overrides) */
37
- config: DingtalkConfig;
38
- };
39
-
40
- export type DingtalkMessageContext = {
41
- conversationId: string;
42
- messageId: string;
43
- senderId: string;
44
- senderName?: string;
45
- conversationType: "1" | "2"; // 1=单聊, 2=群聊
46
- content: string;
47
- contentType: string;
48
- groupSubject?: string;
49
- };
50
-
51
- export type DingtalkSendResult = {
52
- messageId: string;
53
- conversationId: string;
54
- };
55
-
56
- export type DingtalkProbeResult = BaseProbeResult<string> & {
57
- clientId?: string;
58
- botName?: string;
59
- };
@@ -1,63 +0,0 @@
1
- /**
2
- * Agent 相关工具函数
3
- *
4
- * 提供 Agent 配置解析、工作空间路径解析等功能
5
- */
6
- import * as os from "node:os";
7
- import * as path from "node:path";
8
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
9
-
10
- /**
11
- * 解析 Agent 工作空间路径
12
- *
13
- * 参考 OpenClaw SDK 的 resolveAgentWorkspaceDir 实现逻辑:
14
- * 1. 优先从 agents.list 中查找用户配置的 workspace
15
- * 2. 如果没有配置,使用默认路径规则:
16
- * - 默认 Agent (main): ~/.openclaw/workspace
17
- * - 其他 Agent: ~/.openclaw/workspace-{agentId}
18
- *
19
- * @param cfg - OpenClaw 配置对象
20
- * @param agentId - Agent ID
21
- * @returns Agent 工作空间的绝对路径
22
- *
23
- * @example
24
- * ```typescript
25
- * // 用户自定义工作空间
26
- * const cfg = {
27
- * agents: {
28
- * list: [{ id: 'bot1', workspace: '~/my-workspace' }]
29
- * }
30
- * };
31
- * resolveAgentWorkspaceDir(cfg, 'bot1'); // => '/Users/xxx/my-workspace'
32
- *
33
- * // 默认 Agent
34
- * resolveAgentWorkspaceDir(cfg, 'main'); // => '/Users/xxx/.openclaw/workspace'
35
- *
36
- * // 其他 Agent
37
- * resolveAgentWorkspaceDir(cfg, 'bot2'); // => '/Users/xxx/.openclaw/workspace-bot2'
38
- * ```
39
- */
40
- export function resolveAgentWorkspaceDir(
41
- cfg: ClawdbotConfig,
42
- agentId: string,
43
- ): string {
44
- // 1. 先从 agents.list 中查找配置的 workspace
45
- const agentConfig = cfg.agents?.list?.find((a: any) => a.id === agentId);
46
-
47
- if (agentConfig?.workspace) {
48
- // 用户配置了自定义工作空间路径
49
- // 支持 ~ 路径展开
50
- return agentConfig.workspace.startsWith('~')
51
- ? path.join(os.homedir(), agentConfig.workspace.slice(1))
52
- : agentConfig.workspace;
53
- }
54
-
55
- // 2. 使用默认路径规则
56
- if (agentId === 'main' || agentId === cfg.defaultAgent) {
57
- // 默认 Agent 使用 ~/.openclaw/workspace
58
- return path.join(os.homedir(), '.openclaw', 'workspace');
59
- }
60
-
61
- // 其他 Agent 使用 ~/.openclaw/workspace-{agentId}
62
- return path.join(os.homedir(), '.openclaw', `workspace-${agentId}`);
63
- }
@@ -1,51 +0,0 @@
1
- export type RaceResult<T> =
2
- | { status: "success"; value: T }
3
- | { status: "timeout" }
4
- | { status: "aborted" };
5
-
6
- export async function raceWithTimeoutAndAbort<T>(
7
- promise: Promise<T>,
8
- opts: { timeoutMs: number; abortSignal?: AbortSignal },
9
- ): Promise<RaceResult<T>> {
10
- const { timeoutMs, abortSignal } = opts;
11
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
12
- let abortHandler: (() => void) | undefined;
13
-
14
- const timeoutOutcome = new Promise<{ kind: "timeout" }>((resolve) => {
15
- timeoutId = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
16
- });
17
-
18
- const abortOutcome: Promise<{ kind: "aborted" }> | Promise<never> = abortSignal
19
- ? new Promise<{ kind: "aborted" }>((resolve) => {
20
- if (abortSignal.aborted) {
21
- resolve({ kind: "aborted" });
22
- return;
23
- }
24
- abortHandler = () => resolve({ kind: "aborted" });
25
- abortSignal.addEventListener("abort", abortHandler, { once: true });
26
- })
27
- : new Promise<never>(() => {});
28
-
29
- try {
30
- const winner = await Promise.race([
31
- promise.then((value) => ({ kind: "success" as const, value })),
32
- timeoutOutcome,
33
- abortOutcome,
34
- ]);
35
-
36
- if (winner.kind === "success") {
37
- return { status: "success", value: winner.value };
38
- }
39
- if (winner.kind === "timeout") {
40
- return { status: "timeout" };
41
- }
42
- return { status: "aborted" };
43
- } finally {
44
- if (timeoutId) {
45
- clearTimeout(timeoutId);
46
- }
47
- if (abortSignal && abortHandler) {
48
- abortSignal.removeEventListener("abort", abortHandler);
49
- }
50
- }
51
- }
@@ -1,27 +0,0 @@
1
- /**
2
- * 常量定义模块
3
- */
4
-
5
- /** 新会话触发命令 */
6
- export const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话'];
7
-
8
- /**
9
- * 媒体类消息类型集合。
10
- *
11
- * 这些消息类型需要通过钉钉原生消息 API 发送,不支持 AI Card 形式,
12
- * 在 sendProactiveInternal 中会强制跳过 AI Card 路径。
13
- */
14
- export const MEDIA_MSG_TYPES = new Set(['image', 'voice', 'file', 'video'] as const);
15
-
16
- /**
17
- * 队列繁忙时的即时 ACK 回复短语。
18
- *
19
- * 当消息入队时检测到上一条消息仍在处理中,立即从此列表中随机选取一条回复,
20
- * 告知用户消息已收到并排队,避免用户以为 Bot 没有响应。
21
- * 参考 delivery.rs 中 DINGTALK_ACK_PHRASES_BUSY_ZH_CN 的设计。
22
- */
23
- export const QUEUE_BUSY_ACK_PHRASES = [
24
- '上一条还没结束,这条我已经记下,稍后按顺序继续处理。',
25
- '当前还在忙,你的新消息已经排队,上一条完成后我马上继续。',
26
- '我这边还在处理上一条,这条已加入队列,完成后继续处理。',
27
- ] as const;
@@ -1,37 +0,0 @@
1
- /**
2
- * HTTP 客户端配置模块
3
- *
4
- * 提供统一的 axios 实例,用于钉钉 API 请求。
5
- *
6
- * 使用方式:
7
- * ```typescript
8
- * import { dingtalkHttp } from './utils/http-client.ts';
9
- *
10
- * const response = await dingtalkHttp.post('/api/endpoint', data);
11
- * ```
12
- */
13
-
14
- import axios, { type AxiosInstance } from 'axios';
15
-
16
- /** 钉钉专用 HTTP 客户端(30 秒超时) */
17
- export const dingtalkHttp: AxiosInstance = axios.create({
18
- timeout: 30000,
19
- headers: {
20
- 'Content-Type': 'application/json',
21
- },
22
- });
23
-
24
- /** 钉钉 OAPI 专用 HTTP 客户端(60 秒超时,用于媒体上传等) */
25
- export const dingtalkOapiHttp: AxiosInstance = axios.create({
26
- timeout: 60000,
27
- headers: {
28
- 'Content-Type': 'application/json',
29
- },
30
- });
31
-
32
- /** 文件上传专用 HTTP 客户端(120 秒超时,无 body 大小限制) */
33
- export const dingtalkUploadHttp: AxiosInstance = axios.create({
34
- timeout: 120000,
35
- maxContentLength: Infinity,
36
- maxBodyLength: Infinity,
37
- });
@@ -1,8 +0,0 @@
1
- /**
2
- * 工具函数模块统一导出
3
- */
4
-
5
- export * from './constants.ts';
6
- export * from './token.ts';
7
- export * from './session.ts';
8
- export * from './logger.ts';
@@ -1,78 +0,0 @@
1
- /**
2
- * 日志工具模块
3
- * 根据 debug 配置控制日志输出
4
- */
5
-
6
- /**
7
- * 创建日志记录器
8
- * @param debug - 是否启用 debug 模式
9
- * @param prefix - 日志前缀
10
- * @returns 日志记录器对象
11
- */
12
- export function createLogger(debug: boolean = false, prefix: string = '') {
13
- const logger = {
14
- /**
15
- * 打印 info 级别日志
16
- * 仅在 debug 模式下输出
17
- */
18
- info(...args: any[]) {
19
- if (debug) {
20
- if (prefix) {
21
- console.log(`[${prefix}]`, ...args);
22
- } else {
23
- console.log(...args);
24
- }
25
- }
26
- },
27
-
28
- /**
29
- * 打印 warn 级别日志
30
- * 始终输出
31
- */
32
- warn(...args: any[]) {
33
- if (prefix) {
34
- console.warn(`[${prefix}]`, ...args);
35
- } else {
36
- console.warn(...args);
37
- }
38
- },
39
-
40
- /**
41
- * 打印 error 级别日志
42
- * 始终输出
43
- */
44
- error(...args: any[]) {
45
- if (prefix) {
46
- console.error(`[${prefix}]`, ...args);
47
- } else {
48
- console.error(...args);
49
- }
50
- },
51
-
52
- /**
53
- * 打印 debug 级别日志
54
- * 仅在 debug 模式下输出
55
- */
56
- debug(...args: any[]) {
57
- if (debug) {
58
- if (prefix) {
59
- console.log(`[DEBUG][${prefix}]`, ...args);
60
- } else {
61
- console.log('[DEBUG]', ...args);
62
- }
63
- }
64
- },
65
- };
66
-
67
- return logger;
68
- }
69
-
70
- /**
71
- * 从配置中创建日志记录器
72
- * @param config - 包含 debug 配置的对象(可选)
73
- * @param prefix - 日志前缀
74
- * @returns 日志记录器对象
75
- */
76
- export function createLoggerFromConfig(config: { debug?: boolean } | undefined | null, prefix: string = '') {
77
- return createLogger(!!config?.debug, prefix);
78
- }
@@ -1,147 +0,0 @@
1
- /**
2
- * 会话管理模块
3
- * 构建 OpenClaw 标准会话上下文
4
- */
5
-
6
- import { NEW_SESSION_COMMANDS } from './constants.ts';
7
-
8
- /** OpenClaw 标准会话上下文 */
9
- export interface SessionContext {
10
- channel: 'dingtalk-connector';
11
- accountId: string;
12
- chatType: 'direct' | 'group';
13
- /**
14
- * 真实的 peer 标识,不受任何会话隔离配置影响。
15
- * 群聊为 conversationId,单聊为 senderId。
16
- * 与配置中 match.peer.id 语义一致,专用于 bindings 路由匹配。
17
- */
18
- peerId: string;
19
- /**
20
- * 用于 session/memory 隔离的 peer 标识(session 键的一部分)。
21
- * 受 sharedMemoryAcrossConversations、separateSessionByConversation、groupSessionScope 等配置影响,
22
- * 可能与 peerId 不同(如 sharedMemoryAcrossConversations=true 时被设为 accountId)。
23
- * 注意:不要用此字段做 binding 路由匹配,应使用 peerId。
24
- */
25
- sessionPeerId: string;
26
- conversationId?: string;
27
- senderName?: string;
28
- groupSubject?: string;
29
- }
30
-
31
- /**
32
- * 构建 OpenClaw 标准会话上下文
33
- * 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离
34
- *
35
- * @param sharedMemoryAcrossConversations - 是否在不同会话间共享记忆(默认 false)
36
- * - true: 所有会话共享记忆,使用 accountId 作为记忆标识
37
- * - false: 不同会话独立记忆,使用完整的 sessionContext 作为记忆标识
38
- */
39
- export function buildSessionContext(params: {
40
- accountId: string;
41
- senderId: string;
42
- senderName?: string
43
- conversationType: string;
44
- conversationId?: string;
45
- groupSubject?: string;
46
- separateSessionByConversation?: boolean;
47
- groupSessionScope?: 'group' | 'group_sender';
48
- sharedMemoryAcrossConversations?: boolean;
49
- }): SessionContext {
50
- const {
51
- accountId,
52
- senderId,
53
- senderName,
54
- conversationType,
55
- conversationId,
56
- groupSubject,
57
- separateSessionByConversation,
58
- groupSessionScope,
59
- sharedMemoryAcrossConversations,
60
- } = params;
61
- const isDirect = conversationType === '1';
62
-
63
- // peerId:真实的 peer 标识,不受任何会话隔离配置影响,专用于 bindings 路由匹配
64
- // 群聊为 conversationId,单聊为 senderId,与配置中 match.peer.id 语义一致
65
- const peerId = isDirect ? senderId : (conversationId || senderId);
66
-
67
- // sharedMemoryAcrossConversations=true 时,所有会话共享记忆
68
- // sessionPeerId 被设为 accountId 以合并记忆,peerId 仍保留真实 peer,供路由匹配使用
69
- if (sharedMemoryAcrossConversations === true) {
70
- return {
71
- channel: 'dingtalk-connector',
72
- accountId,
73
- chatType: isDirect ? 'direct' : 'group',
74
- peerId,
75
- sessionPeerId: accountId, // 使用 accountId 作为 sessionPeerId,实现跨会话记忆共享
76
- conversationId: isDirect ? undefined : conversationId,
77
- senderName,
78
- groupSubject: isDirect ? undefined : groupSubject,
79
- };
80
- }
81
-
82
- // separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session
83
- if (separateSessionByConversation === false) {
84
- return {
85
- channel: 'dingtalk-connector',
86
- accountId,
87
- chatType: isDirect ? 'direct' : 'group',
88
- peerId,
89
- sessionPeerId: senderId, // 只用 senderId,不区分会话
90
- conversationId: isDirect ? undefined : conversationId,
91
- senderName,
92
- groupSubject: isDirect ? undefined : groupSubject,
93
- };
94
- }
95
-
96
- // 以下是 separateSessionByConversation=true(默认)的逻辑
97
- if (isDirect) {
98
- // 单聊:sessionPeerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
99
- return {
100
- channel: 'dingtalk-connector',
101
- accountId,
102
- chatType: 'direct',
103
- peerId,
104
- sessionPeerId: senderId,
105
- senderName,
106
- };
107
- }
108
-
109
- // 群聊:根据 groupSessionScope 配置决定会话隔离策略
110
- if (groupSessionScope === 'group_sender') {
111
- // 群内每个用户独立会话
112
- return {
113
- channel: 'dingtalk-connector',
114
- accountId,
115
- chatType: 'group',
116
- peerId,
117
- sessionPeerId: `${conversationId}:${senderId}`,
118
- conversationId,
119
- senderName,
120
- groupSubject,
121
- };
122
- }
123
-
124
- // 默认:整个群共享一个会话
125
- return {
126
- channel: 'dingtalk-connector',
127
- accountId,
128
- chatType: 'group',
129
- peerId,
130
- sessionPeerId: conversationId || senderId,
131
- conversationId,
132
- senderName,
133
- groupSubject,
134
- };
135
- }
136
-
137
- /**
138
- * 检查消息是否是新会话命令
139
- */
140
- export function normalizeSlashCommand(text: string): string {
141
- const trimmed = text.trim();
142
- const lower = trimmed.toLowerCase();
143
- if (NEW_SESSION_COMMANDS.some((cmd) => lower === cmd.toLowerCase())) {
144
- return '/new';
145
- }
146
- return text;
147
- }
@@ -1,93 +0,0 @@
1
- /**
2
- * Access Token 管理模块
3
- * 支持钉钉 API 和 OAPI 的 Token 获取和缓存
4
- */
5
-
6
- import type { DingtalkConfig } from '../types/index.ts';
7
- import { dingtalkHttp, dingtalkOapiHttp } from './http-client.ts';
8
-
9
- // ============ 常量 ============
10
-
11
- export const DINGTALK_API = 'https://api.dingtalk.com';
12
- export const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
13
-
14
- // ============ Access Token 缓存 ============
15
-
16
- type CachedToken = {
17
- token: string;
18
- expiryMs: number;
19
- };
20
-
21
- /**
22
- * 按 clientId 分桶缓存,避免多账号串 token。
23
- */
24
- const apiTokenCache = new Map<string, CachedToken>();
25
- const oapiTokenCache = new Map<string, CachedToken>();
26
-
27
- function cacheKey(config: DingtalkConfig): string {
28
- const clientId = String((config as any)?.clientId ?? '').trim();
29
-
30
- // 添加校验
31
- if (!clientId) {
32
- throw new Error(
33
- 'Invalid DingtalkConfig: clientId is required for token caching. ' +
34
- 'Please ensure your configuration includes a valid clientId.'
35
- );
36
- }
37
-
38
- return clientId;
39
- }
40
-
41
- /**
42
- * 获取钉钉 Access Token(新版 API)
43
- */
44
- export async function getAccessToken(config: DingtalkConfig): Promise<string> {
45
- const now = Date.now();
46
- const key = cacheKey(config);
47
- const cached = apiTokenCache.get(key);
48
- if (cached && cached.expiryMs > now + 60_000) {
49
- return cached.token;
50
- }
51
-
52
- const response = await dingtalkHttp.post(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
53
- appKey: config.clientId,
54
- appSecret: config.clientSecret,
55
- });
56
-
57
- const token = response.data.accessToken as string;
58
- const expireInSec = Number(response.data.expireIn ?? 0);
59
- apiTokenCache.set(key, {
60
- token,
61
- expiryMs: now + expireInSec * 1000,
62
- });
63
- return token;
64
- }
65
-
66
- /**
67
- * 获取钉钉 OAPI Access Token(旧版 API,用于媒体上传等)
68
- */
69
- export async function getOapiAccessToken(config: DingtalkConfig): Promise<string | null> {
70
- try {
71
- const now = Date.now();
72
- const key = cacheKey(config);
73
- const cached = oapiTokenCache.get(key);
74
- if (cached && cached.expiryMs > now + 60_000) {
75
- return cached.token;
76
- }
77
-
78
- const resp = await dingtalkOapiHttp.get(`${DINGTALK_OAPI}/gettoken`, {
79
- params: { appkey: config.clientId, appsecret: config.clientSecret },
80
- });
81
-
82
- if (resp.data?.errcode === 0 && resp.data?.access_token) {
83
- const token = String(resp.data.access_token);
84
- // 钉钉返回 expires_in(秒),拿不到就按 2 小时兜底
85
- const expiresInSec = Number(resp.data.expires_in ?? 7200);
86
- oapiTokenCache.set(key, { token, expiryMs: now + expiresInSec * 1000 });
87
- return token;
88
- }
89
- return null;
90
- } catch {
91
- return null;
92
- }
93
- }