cli-claw-kit 0.0.1
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/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
package/dist/dingtalk.js
ADDED
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DingTalk Bot Stream Connection Factory
|
|
3
|
+
*
|
|
4
|
+
* Implements DingTalk bot connection using official Stream mode SDK:
|
|
5
|
+
* - WebSocket connection for receiving events
|
|
6
|
+
* - Message deduplication (LRU 1000 / 30min TTL)
|
|
7
|
+
* - Group mention filtering
|
|
8
|
+
* - REST API for sending messages
|
|
9
|
+
*
|
|
10
|
+
* Reference: https://open.dingtalk.com/document/orgapp/the-streaming-mode-is-connected-to-the-robot-receiving-message
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import { DWClient, TOPIC_ROBOT, } from 'dingtalk-stream';
|
|
17
|
+
import { storeChatMetadata, storeMessageDirect, updateChatName } from './db.js';
|
|
18
|
+
import { notifyNewImMessage } from './message-notifier.js';
|
|
19
|
+
import { broadcastNewMessage } from './web.js';
|
|
20
|
+
import { logger } from './logger.js';
|
|
21
|
+
import { saveDownloadedFile, MAX_FILE_SIZE } from './im-downloader.js';
|
|
22
|
+
import { detectImageMimeType } from './image-detector.js';
|
|
23
|
+
import { markdownToPlainText, splitTextChunks } from './im-utils.js';
|
|
24
|
+
// ─── Constants ──────────────────────────────────────────────────
|
|
25
|
+
const DINGTALK_API_BASE = 'https://api.dingtalk.com';
|
|
26
|
+
const MSG_DEDUP_MAX = 1000;
|
|
27
|
+
const MSG_DEDUP_TTL = 30 * 60 * 1000; // 30min
|
|
28
|
+
const MSG_SPLIT_LIMIT = 4000; // DingTalk markdown card limit
|
|
29
|
+
// Same 5MB threshold as WeChat — only inline base64 for small images
|
|
30
|
+
const IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024;
|
|
31
|
+
// Minimum valid image size (bytes) — discard responses that are too small to be real images
|
|
32
|
+
const MIN_IMAGE_SIZE = 500;
|
|
33
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
34
|
+
// markdownToPlainText imported from ./im-utils.js
|
|
35
|
+
/**
|
|
36
|
+
* Convert standard Markdown to DingTalk markdown format.
|
|
37
|
+
* DingTalk supports: headers (#/#/###), bold (**text**), italic (*text*),
|
|
38
|
+
* unordered lists (- item), links [text](url), blockquotes (> text), inline code (`code`).
|
|
39
|
+
* Strips: code blocks, strikethrough, images.
|
|
40
|
+
*/
|
|
41
|
+
function convertToDingTalkMarkdown(md) {
|
|
42
|
+
let text = md;
|
|
43
|
+
// Code blocks → code block marker (DingTalk supports ``` fence)
|
|
44
|
+
// Keep them as-is since DingTalk markdown supports fenced code
|
|
45
|
+
// Images:  → alt (DingTalk doesn't render inline images in markdown)
|
|
46
|
+
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1');
|
|
47
|
+
// Links: keep as [text](url) since DingTalk markdown supports them
|
|
48
|
+
// Strikethrough: ~~text~~ → text (not supported)
|
|
49
|
+
text = text.replace(/~~(.+?)~~/g, '$1');
|
|
50
|
+
// Headings: keep as-is (# to ######)
|
|
51
|
+
// Bold: keep as-is **text**
|
|
52
|
+
// Italic: keep as-is *text*
|
|
53
|
+
// Unordered lists: keep as-is - item
|
|
54
|
+
// Blockquotes: keep as-is > text
|
|
55
|
+
// Inline code: keep as-is `code`
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
// splitTextChunks imported from ./im-utils.js
|
|
59
|
+
/**
|
|
60
|
+
* Parse JID to determine chat type and extract conversation ID / staff ID.
|
|
61
|
+
* dingtalk:c2c:{senderStaffId} → { type: 'c2c', conversationId: senderStaffId }
|
|
62
|
+
* dingtalk:group:{openConversationId} → { type: 'group', conversationId: openConversationId }
|
|
63
|
+
* c2c:{senderStaffId} → { type: 'c2c', conversationId: senderStaffId } (legacy without prefix)
|
|
64
|
+
*/
|
|
65
|
+
function parseDingTalkChatId(chatId) {
|
|
66
|
+
if (chatId.startsWith('dingtalk:c2c:')) {
|
|
67
|
+
// Format: dingtalk:c2c:{senderStaffId}, extract senderStaffId
|
|
68
|
+
return { type: 'c2c', conversationId: chatId.slice(13) };
|
|
69
|
+
}
|
|
70
|
+
if (chatId.startsWith('dingtalk:group:')) {
|
|
71
|
+
return { type: 'group', conversationId: chatId.slice(15) };
|
|
72
|
+
}
|
|
73
|
+
// Legacy format without prefix
|
|
74
|
+
if (chatId.startsWith('c2c:')) {
|
|
75
|
+
return { type: 'c2c', conversationId: chatId.slice(4) };
|
|
76
|
+
}
|
|
77
|
+
if (chatId.startsWith('group:')) {
|
|
78
|
+
return { type: 'group', conversationId: chatId.slice(6) };
|
|
79
|
+
}
|
|
80
|
+
// Legacy format: direct conversationId (assume group)
|
|
81
|
+
if (chatId.startsWith('cid')) {
|
|
82
|
+
return { type: 'group', conversationId: chatId };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// ─── Factory Function ───────────────────────────────────────────
|
|
87
|
+
export function createDingTalkConnection(config) {
|
|
88
|
+
// SDK client state
|
|
89
|
+
let client = null;
|
|
90
|
+
let stopping = false;
|
|
91
|
+
let readyFired = false;
|
|
92
|
+
let reconnectCheckInterval = null;
|
|
93
|
+
// Token state for REST API
|
|
94
|
+
let tokenInfo = null;
|
|
95
|
+
// Message deduplication
|
|
96
|
+
const msgCache = new Map();
|
|
97
|
+
// Last message ID per chat (for reply context)
|
|
98
|
+
const lastMessageIds = new Map();
|
|
99
|
+
// Session webhook per chat (for sending replies)
|
|
100
|
+
const lastSessionWebhooks = new Map();
|
|
101
|
+
// Session webhook expiry per chat
|
|
102
|
+
const sessionWebhookExpiry = new Map();
|
|
103
|
+
const SESSION_WEBHOOK_TTL = 5 * 60 * 1000; // 5 minutes
|
|
104
|
+
// Sender ID per chat (for sending files back to user)
|
|
105
|
+
const lastSenderIds = new Map();
|
|
106
|
+
// Sender staff ID per chat (enterprise staff ID for batchSend API)
|
|
107
|
+
const lastSenderStaffIds = new Map();
|
|
108
|
+
function isDuplicate(msgId) {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
// Map preserves insertion order; stop at first non-expired entry
|
|
111
|
+
for (const [id, ts] of msgCache.entries()) {
|
|
112
|
+
if (now - ts > MSG_DEDUP_TTL) {
|
|
113
|
+
msgCache.delete(id);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (msgCache.size >= MSG_DEDUP_MAX) {
|
|
120
|
+
const firstKey = msgCache.keys().next().value;
|
|
121
|
+
if (firstKey)
|
|
122
|
+
msgCache.delete(firstKey);
|
|
123
|
+
}
|
|
124
|
+
return msgCache.has(msgId);
|
|
125
|
+
}
|
|
126
|
+
function markSeen(msgId) {
|
|
127
|
+
// delete + set to refresh insertion order (move to end)
|
|
128
|
+
msgCache.delete(msgId);
|
|
129
|
+
msgCache.set(msgId, Date.now());
|
|
130
|
+
}
|
|
131
|
+
// ─── Token Management ──────────────────────────────────────
|
|
132
|
+
async function getAccessToken() {
|
|
133
|
+
// Check cached token
|
|
134
|
+
if (tokenInfo && Date.now() < tokenInfo.expiresAt - 300000) {
|
|
135
|
+
return tokenInfo.token;
|
|
136
|
+
}
|
|
137
|
+
// Fetch new token using GET method (钉钉 API 支持 GET 和 POST)
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const url = new URL('https://oapi.dingtalk.com/gettoken');
|
|
140
|
+
url.searchParams.set('appkey', config.clientId);
|
|
141
|
+
url.searchParams.set('appsecret', config.clientSecret);
|
|
142
|
+
const req = https.request({
|
|
143
|
+
hostname: url.hostname,
|
|
144
|
+
path: url.pathname + url.search,
|
|
145
|
+
method: 'GET',
|
|
146
|
+
}, (res) => {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
149
|
+
res.on('end', () => {
|
|
150
|
+
try {
|
|
151
|
+
const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
152
|
+
if (data.errcode !== 0) {
|
|
153
|
+
reject(new Error(`DingTalk token error: ${data.errmsg}`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const expiresIn = Number(data.expires_in) || 7200;
|
|
157
|
+
tokenInfo = {
|
|
158
|
+
token: data.access_token,
|
|
159
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
160
|
+
};
|
|
161
|
+
logger.info({ expiresIn }, 'DingTalk access token refreshed');
|
|
162
|
+
resolve(data.access_token);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
reject(err);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
res.on('error', reject);
|
|
169
|
+
});
|
|
170
|
+
req.on('error', reject);
|
|
171
|
+
req.end();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// ─── REST API ──────────────────────────────────────────────
|
|
175
|
+
async function apiRequest(method, path, body) {
|
|
176
|
+
const token = await getAccessToken();
|
|
177
|
+
const url = new URL(path, DINGTALK_API_BASE);
|
|
178
|
+
const bodyStr = body ? JSON.stringify(body) : undefined;
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const req = https.request({
|
|
181
|
+
hostname: url.hostname,
|
|
182
|
+
path: url.pathname + url.search,
|
|
183
|
+
method,
|
|
184
|
+
headers: {
|
|
185
|
+
'x-acs-dingtalk-access-token': token,
|
|
186
|
+
'Content-Type': 'application/json',
|
|
187
|
+
...(bodyStr
|
|
188
|
+
? { 'Content-Length': String(Buffer.byteLength(bodyStr)) }
|
|
189
|
+
: {}),
|
|
190
|
+
},
|
|
191
|
+
}, (res) => {
|
|
192
|
+
const chunks = [];
|
|
193
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
194
|
+
res.on('end', () => {
|
|
195
|
+
const text = Buffer.concat(chunks).toString('utf-8');
|
|
196
|
+
try {
|
|
197
|
+
const data = JSON.parse(text);
|
|
198
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
199
|
+
const errMsg = data.message || data.msg || text;
|
|
200
|
+
reject(new Error(`DingTalk API ${method} ${path} failed (${res.statusCode}): ${errMsg}`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
resolve(data);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
207
|
+
reject(new Error(`DingTalk API ${method} ${path} failed (${res.statusCode}): ${text}`));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
resolve({});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
res.on('error', reject);
|
|
215
|
+
});
|
|
216
|
+
req.on('error', reject);
|
|
217
|
+
if (bodyStr)
|
|
218
|
+
req.write(bodyStr);
|
|
219
|
+
req.end();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// ─── Message Sending ──────────────────────────────────────
|
|
223
|
+
/**
|
|
224
|
+
* Send message via sessionWebhook (from incoming message)
|
|
225
|
+
* This is the standard DingTalk robot reply mechanism
|
|
226
|
+
*/
|
|
227
|
+
async function sendViaSessionWebhook(sessionWebhook, content, useMarkdown = false) {
|
|
228
|
+
const token = await getAccessToken();
|
|
229
|
+
const body = useMarkdown
|
|
230
|
+
? {
|
|
231
|
+
msgtype: 'markdown',
|
|
232
|
+
markdown: {
|
|
233
|
+
title: content.slice(0, 50),
|
|
234
|
+
text: content,
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
: {
|
|
238
|
+
msgtype: 'text',
|
|
239
|
+
text: {
|
|
240
|
+
content,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
const url = new URL(sessionWebhook);
|
|
245
|
+
const req = https.request({
|
|
246
|
+
hostname: url.hostname,
|
|
247
|
+
path: url.pathname + url.search,
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
'x-acs-dingtalk-access-token': token,
|
|
252
|
+
},
|
|
253
|
+
}, (res) => {
|
|
254
|
+
const chunks = [];
|
|
255
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
256
|
+
res.on('end', () => {
|
|
257
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
258
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
259
|
+
reject(new Error(`DingTalk HTTP failed (${res.statusCode}): ${body}`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// Also check DingTalk API-level errcode
|
|
263
|
+
try {
|
|
264
|
+
const data = JSON.parse(body);
|
|
265
|
+
logger.info({
|
|
266
|
+
statusCode: res.statusCode,
|
|
267
|
+
errcode: data.errcode,
|
|
268
|
+
errmsg: data.errmsg,
|
|
269
|
+
}, 'DingTalk sendViaSessionWebhook response');
|
|
270
|
+
if (data.errcode && data.errcode !== 0) {
|
|
271
|
+
reject(new Error(`DingTalk API error: ${data.errcode} ${data.errmsg}`));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Not JSON, ignore
|
|
277
|
+
}
|
|
278
|
+
resolve();
|
|
279
|
+
});
|
|
280
|
+
res.on('error', reject);
|
|
281
|
+
});
|
|
282
|
+
req.on('error', reject);
|
|
283
|
+
req.write(JSON.stringify(body));
|
|
284
|
+
req.end();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Common helper: POST /v1.0/robot/oToMessages/batchSend
|
|
289
|
+
* Used by C2C text, file, and image message senders.
|
|
290
|
+
*/
|
|
291
|
+
async function batchSendToUser(userIds, robotCode, token, msgKey, msgParam) {
|
|
292
|
+
const body = JSON.stringify({ robotCode, userIds, msgKey, msgParam });
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
const req = https.request({
|
|
295
|
+
hostname: 'api.dingtalk.com',
|
|
296
|
+
path: '/v1.0/robot/oToMessages/batchSend',
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: {
|
|
299
|
+
'Content-Type': 'application/json',
|
|
300
|
+
'x-acs-dingtalk-access-token': token,
|
|
301
|
+
},
|
|
302
|
+
}, (res) => {
|
|
303
|
+
const chunks = [];
|
|
304
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
305
|
+
res.on('end', () => {
|
|
306
|
+
const respBody = Buffer.concat(chunks).toString('utf8');
|
|
307
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
308
|
+
reject(new Error(`DingTalk batchSend HTTP failed (${res.statusCode}): ${respBody}`));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const data = JSON.parse(respBody);
|
|
313
|
+
if (data.errcode && data.errcode !== 0) {
|
|
314
|
+
reject(new Error(`DingTalk batchSend API error: ${data.errcode} ${data.errmsg}`));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Not JSON, ignore
|
|
320
|
+
}
|
|
321
|
+
resolve();
|
|
322
|
+
});
|
|
323
|
+
res.on('error', reject);
|
|
324
|
+
});
|
|
325
|
+
req.on('error', reject);
|
|
326
|
+
req.write(body);
|
|
327
|
+
req.end();
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Send a C2C text message via the persistent chatbot API (oToMessages/batchSend).
|
|
332
|
+
* This is the correct API for proactive C2C messages — sessionWebhook is only
|
|
333
|
+
* for reply scenarios within the stream connection.
|
|
334
|
+
* Uses senderStaffId (enterprise user ID) which was stored when the user messaged us.
|
|
335
|
+
*/
|
|
336
|
+
async function sendViaPersistentAPI(senderStaffId, content) {
|
|
337
|
+
const token = await getAccessToken();
|
|
338
|
+
const robotCode = config.clientId;
|
|
339
|
+
const msgParam = JSON.stringify({ content });
|
|
340
|
+
await batchSendToUser([senderStaffId], robotCode, token, 'sampleText', msgParam);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Send a group message via the persistent robot/groupMessages API.
|
|
344
|
+
* Uses openConversationId (stable group ID) instead of ephemeral sessionWebhook.
|
|
345
|
+
* Ref: https://open.dingtalk.com/document/group/the-robot-sends-a-group-message
|
|
346
|
+
*/
|
|
347
|
+
async function sendViaGroupMessagesAPI(openConversationId, msgKey, msgParam) {
|
|
348
|
+
const token = await getAccessToken();
|
|
349
|
+
const robotCode = config.clientId;
|
|
350
|
+
const body = JSON.stringify({
|
|
351
|
+
openConversationId,
|
|
352
|
+
robotCode,
|
|
353
|
+
msgKey,
|
|
354
|
+
msgParam,
|
|
355
|
+
});
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const req = https.request({
|
|
358
|
+
hostname: 'api.dingtalk.com',
|
|
359
|
+
path: '/v1.0/robot/groupMessages/send',
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: {
|
|
362
|
+
'Content-Type': 'application/json',
|
|
363
|
+
'x-acs-dingtalk-access-token': token,
|
|
364
|
+
},
|
|
365
|
+
}, (res) => {
|
|
366
|
+
const chunks = [];
|
|
367
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
368
|
+
res.on('end', () => {
|
|
369
|
+
const respBody = Buffer.concat(chunks).toString('utf8');
|
|
370
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
371
|
+
reject(new Error(`DingTalk groupMessages API HTTP failed (${res.statusCode}): ${respBody}`));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const data = JSON.parse(respBody);
|
|
376
|
+
logger.info({
|
|
377
|
+
statusCode: res.statusCode,
|
|
378
|
+
errcode: data.errcode,
|
|
379
|
+
errmsg: data.errmsg,
|
|
380
|
+
processQueryKey: data.processQueryKey,
|
|
381
|
+
}, 'DingTalk sendViaGroupMessagesAPI response');
|
|
382
|
+
if (data.errcode && data.errcode !== 0) {
|
|
383
|
+
reject(new Error(`DingTalk groupMessages API error: ${data.errcode} ${data.errmsg}`));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// Not JSON, ignore
|
|
389
|
+
}
|
|
390
|
+
resolve();
|
|
391
|
+
});
|
|
392
|
+
res.on('error', reject);
|
|
393
|
+
});
|
|
394
|
+
req.on('error', reject);
|
|
395
|
+
req.write(body);
|
|
396
|
+
req.end();
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// ─── File Download ─────────────────────────────────────────
|
|
400
|
+
async function downloadDingTalkImageAsBase64(url) {
|
|
401
|
+
try {
|
|
402
|
+
const buffer = await new Promise((resolve, reject) => {
|
|
403
|
+
const doRequest = (reqUrl, redirectCount = 0) => {
|
|
404
|
+
if (redirectCount > 5) {
|
|
405
|
+
reject(new Error('Too many redirects'));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const parsedUrl = new URL(reqUrl);
|
|
409
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
410
|
+
protocol
|
|
411
|
+
.get(reqUrl, (res) => {
|
|
412
|
+
if (res.statusCode &&
|
|
413
|
+
res.statusCode >= 300 &&
|
|
414
|
+
res.statusCode < 400 &&
|
|
415
|
+
res.headers.location) {
|
|
416
|
+
doRequest(res.headers.location, redirectCount + 1);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const chunks = [];
|
|
420
|
+
let total = 0;
|
|
421
|
+
res.on('data', (chunk) => {
|
|
422
|
+
total += chunk.length;
|
|
423
|
+
if (total > MAX_FILE_SIZE) {
|
|
424
|
+
res.destroy(new Error('Image exceeds MAX_FILE_SIZE'));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
chunks.push(chunk);
|
|
428
|
+
});
|
|
429
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
430
|
+
res.on('error', reject);
|
|
431
|
+
})
|
|
432
|
+
.on('error', reject);
|
|
433
|
+
};
|
|
434
|
+
doRequest(url);
|
|
435
|
+
});
|
|
436
|
+
if (buffer.length === 0)
|
|
437
|
+
return null;
|
|
438
|
+
const mimeType = detectImageMimeType(buffer);
|
|
439
|
+
return { base64: buffer.toString('base64'), mimeType };
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
logger.warn({ err }, 'Failed to download DingTalk image as base64');
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Fetch a temporary download URL for a robot message file/image.
|
|
448
|
+
* POST /v1.0/robot/messageFiles/download → { downloadUrl }
|
|
449
|
+
*/
|
|
450
|
+
async function fetchDingTalkDownloadUrl(downloadCode, robotCode, token) {
|
|
451
|
+
const downloadUrlResp = await new Promise((resolve, reject) => {
|
|
452
|
+
const body = JSON.stringify({ downloadCode, robotCode });
|
|
453
|
+
const req = https.request({
|
|
454
|
+
hostname: 'api.dingtalk.com',
|
|
455
|
+
path: '/v1.0/robot/messageFiles/download',
|
|
456
|
+
method: 'POST',
|
|
457
|
+
headers: {
|
|
458
|
+
'Content-Type': 'application/json',
|
|
459
|
+
'x-acs-dingtalk-access-token': token,
|
|
460
|
+
},
|
|
461
|
+
}, (res) => {
|
|
462
|
+
const statusCode = res.statusCode ?? 0;
|
|
463
|
+
const chunks = [];
|
|
464
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
465
|
+
res.on('end', () => {
|
|
466
|
+
const buf = Buffer.concat(chunks);
|
|
467
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
468
|
+
logger.warn({
|
|
469
|
+
statusCode,
|
|
470
|
+
bodyUtf8: buf.toString('utf8').slice(0, 300),
|
|
471
|
+
}, 'DingTalk download URL API non-2xx response');
|
|
472
|
+
reject(new Error(`DingTalk download URL API HTTP failed (${statusCode}): ${buf.toString('utf8').slice(0, 200)}`));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
resolve(JSON.parse(buf.toString('utf8')));
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
reject(new Error(`Invalid JSON from download URL API: ${buf.toString('utf8').slice(0, 200)}`));
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
res.on('error', reject);
|
|
483
|
+
});
|
|
484
|
+
req.on('error', reject);
|
|
485
|
+
req.write(body);
|
|
486
|
+
req.end();
|
|
487
|
+
});
|
|
488
|
+
const downloadUrl = downloadUrlResp?.downloadUrl;
|
|
489
|
+
if (!downloadUrl) {
|
|
490
|
+
throw new Error('DingTalk download URL API returned no downloadUrl');
|
|
491
|
+
}
|
|
492
|
+
return downloadUrl;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Download a DingTalk picture message using the downloadCode from the robot callback.
|
|
496
|
+
* Step 1: POST /v1.0/robot/messageFiles/download → get downloadUrl
|
|
497
|
+
* Step 2: GET downloadUrl → get actual image bytes
|
|
498
|
+
* Ref: https://open.dingtalk.com/document/orgapp/download-the-file-content-of-the-robot-receiving-message
|
|
499
|
+
*/
|
|
500
|
+
async function downloadDingTalkImageByDownloadCode(downloadCode, robotCode) {
|
|
501
|
+
try {
|
|
502
|
+
const token = await getAccessToken();
|
|
503
|
+
// Step 1: Get temporary download URL
|
|
504
|
+
const downloadUrl = await fetchDingTalkDownloadUrl(downloadCode, robotCode, token);
|
|
505
|
+
// Step 2: Download the actual image from the temporary URL
|
|
506
|
+
const buffer = await new Promise((resolve, reject) => {
|
|
507
|
+
const isHttps = downloadUrl.startsWith('https://');
|
|
508
|
+
const urlObj = new URL(downloadUrl);
|
|
509
|
+
const protocol = isHttps ? https : http;
|
|
510
|
+
const req = protocol.request({
|
|
511
|
+
hostname: urlObj.hostname,
|
|
512
|
+
path: urlObj.pathname + urlObj.search,
|
|
513
|
+
method: 'GET',
|
|
514
|
+
}, (res) => {
|
|
515
|
+
if (!res.statusCode ||
|
|
516
|
+
res.statusCode < 200 ||
|
|
517
|
+
res.statusCode >= 300) {
|
|
518
|
+
reject(new Error(`DingTalk image GET HTTP failed (${res.statusCode})`));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const chunks = [];
|
|
522
|
+
let total = 0;
|
|
523
|
+
res.on('data', (chunk) => {
|
|
524
|
+
total += chunk.length;
|
|
525
|
+
if (total > MAX_FILE_SIZE) {
|
|
526
|
+
res.destroy(new Error('Downloaded image exceeds MAX_FILE_SIZE'));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
chunks.push(chunk);
|
|
530
|
+
});
|
|
531
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
532
|
+
res.on('error', reject);
|
|
533
|
+
});
|
|
534
|
+
req.on('error', reject);
|
|
535
|
+
req.end();
|
|
536
|
+
});
|
|
537
|
+
if (buffer.length === 0)
|
|
538
|
+
return null;
|
|
539
|
+
// Validate buffer looks like a real image (has JPEG/PNG/GIF/WebP magic bytes)
|
|
540
|
+
const mimeType = detectImageMimeType(buffer);
|
|
541
|
+
if (!mimeType) {
|
|
542
|
+
logger.warn({
|
|
543
|
+
bufferLength: buffer.length,
|
|
544
|
+
firstBytes: buffer.slice(0, 20).toString('hex'),
|
|
545
|
+
}, 'DingTalk image download returned non-image data, skipping');
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
// Discard tiny responses that can't be real images (e.g. 54-byte fake JPEG headers)
|
|
549
|
+
if (buffer.length < MIN_IMAGE_SIZE) {
|
|
550
|
+
logger.warn({ bufferLength: buffer.length, minSize: MIN_IMAGE_SIZE }, 'DingTalk image download returned too-small data, skipping');
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return { base64: buffer.toString('base64'), mimeType };
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
logger.warn({ err }, 'Failed to download DingTalk image by downloadCode');
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Download a file (any type) via DingTalk robot callback downloadCode.
|
|
562
|
+
* Step 1: POST /v1.0/robot/messageFiles/download → get downloadUrl
|
|
563
|
+
* Step 2: GET downloadUrl → get raw file bytes (no MIME magic-byte check)
|
|
564
|
+
* Ref: https://open.dingtalk.com/document/orgapp/download-the-file-content-of-the-robot-receiving-message
|
|
565
|
+
*/
|
|
566
|
+
async function downloadDingTalkFileByDownloadCode(downloadCode, robotCode) {
|
|
567
|
+
try {
|
|
568
|
+
const token = await getAccessToken();
|
|
569
|
+
// Step 1: Get temporary download URL
|
|
570
|
+
const downloadUrl = await fetchDingTalkDownloadUrl(downloadCode, robotCode, token);
|
|
571
|
+
// Step 2: Download raw file bytes (no MIME check — any file type allowed)
|
|
572
|
+
const buffer = await new Promise((resolve, reject) => {
|
|
573
|
+
const isHttps = downloadUrl.startsWith('https://');
|
|
574
|
+
const urlObj = new URL(downloadUrl);
|
|
575
|
+
const protocol = isHttps ? https : http;
|
|
576
|
+
const req = protocol.request({
|
|
577
|
+
hostname: urlObj.hostname,
|
|
578
|
+
path: urlObj.pathname + urlObj.search,
|
|
579
|
+
method: 'GET',
|
|
580
|
+
}, (res) => {
|
|
581
|
+
if (!res.statusCode ||
|
|
582
|
+
res.statusCode < 200 ||
|
|
583
|
+
res.statusCode >= 300) {
|
|
584
|
+
reject(new Error(`DingTalk file GET HTTP failed (${res.statusCode})`));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const chunks = [];
|
|
588
|
+
let total = 0;
|
|
589
|
+
res.on('data', (chunk) => {
|
|
590
|
+
total += chunk.length;
|
|
591
|
+
if (total > MAX_FILE_SIZE) {
|
|
592
|
+
res.destroy(new Error('Downloaded file exceeds MAX_FILE_SIZE'));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
chunks.push(chunk);
|
|
596
|
+
});
|
|
597
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
598
|
+
res.on('error', reject);
|
|
599
|
+
});
|
|
600
|
+
req.on('error', reject);
|
|
601
|
+
req.end();
|
|
602
|
+
});
|
|
603
|
+
if (buffer.length === 0)
|
|
604
|
+
return null;
|
|
605
|
+
return buffer;
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
logger.warn({ err }, 'Failed to download DingTalk file by downloadCode');
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// ─── File Upload & Send (for outgoing files) ─────────────
|
|
613
|
+
/**
|
|
614
|
+
* Upload a file buffer to DingTalk media API and return the media_id.
|
|
615
|
+
* @param fileBuffer Raw file bytes
|
|
616
|
+
* @param fileName Original file name (used as filename in multipart)
|
|
617
|
+
* @param type Media type: "image", "voice", "video", "file"
|
|
618
|
+
*/
|
|
619
|
+
async function uploadDingTalkMedia(fileBuffer, fileName, type) {
|
|
620
|
+
try {
|
|
621
|
+
const token = await getAccessToken();
|
|
622
|
+
const boundary = `----FormBoundary${Date.now()}`;
|
|
623
|
+
const CRLF = '\r\n';
|
|
624
|
+
// Build multipart form body manually
|
|
625
|
+
const parts = [];
|
|
626
|
+
// type field
|
|
627
|
+
parts.push(Buffer.from(`--${boundary}${CRLF}` +
|
|
628
|
+
`Content-Disposition: form-data; name="type"${CRLF}${CRLF}` +
|
|
629
|
+
`${type}${CRLF}`, 'utf8'));
|
|
630
|
+
// media field with filename
|
|
631
|
+
const header = `--${boundary}${CRLF}` +
|
|
632
|
+
`Content-Disposition: form-data; name="media"; filename="${fileName}"${CRLF}` +
|
|
633
|
+
`Content-Type: application/octet-stream${CRLF}${CRLF}`;
|
|
634
|
+
parts.push(Buffer.from(header, 'utf8'));
|
|
635
|
+
parts.push(fileBuffer);
|
|
636
|
+
parts.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8'));
|
|
637
|
+
const body = Buffer.concat(parts);
|
|
638
|
+
const result = await new Promise((resolve, reject) => {
|
|
639
|
+
const req = https.request({
|
|
640
|
+
hostname: 'oapi.dingtalk.com',
|
|
641
|
+
path: `/media/upload?access_token=${token}&type=${type}`,
|
|
642
|
+
method: 'POST',
|
|
643
|
+
headers: {
|
|
644
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
645
|
+
'Content-Length': body.length,
|
|
646
|
+
},
|
|
647
|
+
}, (res) => {
|
|
648
|
+
const chunks = [];
|
|
649
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
650
|
+
res.on('end', () => {
|
|
651
|
+
try {
|
|
652
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
reject(new Error('Invalid JSON from DingTalk media upload'));
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
res.on('error', reject);
|
|
659
|
+
});
|
|
660
|
+
req.on('error', reject);
|
|
661
|
+
req.write(body);
|
|
662
|
+
req.end();
|
|
663
|
+
});
|
|
664
|
+
if (result.errcode && result.errcode !== 0) {
|
|
665
|
+
logger.warn({ errcode: result.errcode, errmsg: result.errmsg }, 'DingTalk media upload failed');
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
if (!result.media_id) {
|
|
669
|
+
logger.warn('DingTalk media upload: no media_id in response');
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
logger.info({ mediaId: result.media_id, fileName, type }, 'DingTalk media uploaded');
|
|
673
|
+
return result.media_id;
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
logger.warn({ err }, 'Failed to upload DingTalk media');
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Send a file message to a DingTalk user using batchSend API.
|
|
682
|
+
* @param userId The target user's senderId (from incoming messages)
|
|
683
|
+
* @param robotCode The robot code (from config or incoming message)
|
|
684
|
+
* @param mediaId The media_id from upload
|
|
685
|
+
* @param fileName Display name for the file
|
|
686
|
+
*/
|
|
687
|
+
async function sendDingTalkFileMessage(userId, robotCode, mediaId, fileName, fileType) {
|
|
688
|
+
try {
|
|
689
|
+
const token = await getAccessToken();
|
|
690
|
+
const msgParam = JSON.stringify({ mediaId, fileName, fileType });
|
|
691
|
+
await batchSendToUser([userId], robotCode, token, 'sampleFile', msgParam);
|
|
692
|
+
logger.info({ userId, mediaId, fileName }, 'DingTalk file message sent');
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
logger.error({ err, userId, mediaId, fileName }, 'Failed to send DingTalk file message');
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Send an image message to a DingTalk user using batchSend API.
|
|
701
|
+
* Uses sampleImageMsg with photoURL pointing to the uploaded mediaId.
|
|
702
|
+
*/
|
|
703
|
+
async function sendDingTalkImageMessage(userId, robotCode, mediaId, fileName) {
|
|
704
|
+
try {
|
|
705
|
+
const token = await getAccessToken();
|
|
706
|
+
// sampleImageMsg uses photoURL field (not mediaId) - DingTalk API quirk
|
|
707
|
+
const msgParam = JSON.stringify({ photoURL: mediaId });
|
|
708
|
+
await batchSendToUser([userId], robotCode, token, 'sampleImageMsg', msgParam);
|
|
709
|
+
logger.info({ userId, mediaId, fileName }, 'DingTalk image message sent');
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
logger.error({ err, userId, mediaId, fileName }, 'Failed to send DingTalk image message');
|
|
713
|
+
throw err;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Download a DingTalk image, optionally inline as base64, and save to disk.
|
|
718
|
+
* Unifies the handling of msgtype="picture" (downloadCode API) and
|
|
719
|
+
* msgtype="image" (contentUrl direct download).
|
|
720
|
+
*/
|
|
721
|
+
async function normalizeDingTalkImage(jid, opts, downloader) {
|
|
722
|
+
const imageData = await downloader();
|
|
723
|
+
if (!imageData)
|
|
724
|
+
return null;
|
|
725
|
+
const imgBuffer = Buffer.from(imageData.base64, 'base64');
|
|
726
|
+
const imgSize = imgBuffer.length;
|
|
727
|
+
// Small images are inlined as base64 for Vision API
|
|
728
|
+
const attachments = imgSize <= IMAGE_MAX_BASE64_SIZE
|
|
729
|
+
? [
|
|
730
|
+
{
|
|
731
|
+
type: 'image',
|
|
732
|
+
data: imageData.base64,
|
|
733
|
+
mimeType: imageData.mimeType,
|
|
734
|
+
},
|
|
735
|
+
]
|
|
736
|
+
: [];
|
|
737
|
+
const groupFolder = opts.resolveGroupFolder?.(jid);
|
|
738
|
+
if (groupFolder) {
|
|
739
|
+
try {
|
|
740
|
+
const ext = imageData.mimeType.split('/')[1] || 'jpg';
|
|
741
|
+
const filename = `img_${Date.now()}.${ext}`;
|
|
742
|
+
const savedPath = await saveDownloadedFile(groupFolder, 'dingtalk', filename, imgBuffer);
|
|
743
|
+
return {
|
|
744
|
+
content: `[图片: ${savedPath}]`,
|
|
745
|
+
attachmentsJson: attachments.length > 0 ? JSON.stringify(attachments) : undefined,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return { content: '[图片]', attachmentsJson: undefined };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
content: '[图片]',
|
|
754
|
+
attachmentsJson: attachments.length > 0 ? JSON.stringify(attachments) : undefined,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
// ─── Event Handlers ───────────────────────────────────────
|
|
758
|
+
async function handleRobotMessage(downstream, opts) {
|
|
759
|
+
try {
|
|
760
|
+
const data = JSON.parse(downstream.data);
|
|
761
|
+
const msgId = data.msgId;
|
|
762
|
+
logger.info({
|
|
763
|
+
msgId,
|
|
764
|
+
conversationType: data.conversationType,
|
|
765
|
+
msgtype: data.msgtype,
|
|
766
|
+
}, 'DingTalk handleRobotMessage start');
|
|
767
|
+
if (!msgId || isDuplicate(msgId)) {
|
|
768
|
+
logger.info({ msgId }, 'DingTalk dropped: duplicate or no msgId');
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
markSeen(msgId);
|
|
772
|
+
// Skip stale messages from before connection (hot-reload scenario)
|
|
773
|
+
if (opts.ignoreMessagesBefore && data.createAt) {
|
|
774
|
+
const msgTime = data.createAt;
|
|
775
|
+
if (msgTime < opts.ignoreMessagesBefore) {
|
|
776
|
+
logger.info({ msgId, msgTime, ignoreBefore: opts.ignoreMessagesBefore }, 'DingTalk dropped: stale message');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const conversationId = data.conversationId;
|
|
781
|
+
const conversationType = data.conversationType;
|
|
782
|
+
const isGroup = conversationType === '2'; // 1=C2C, 2=Group
|
|
783
|
+
const jid = isGroup
|
|
784
|
+
? `dingtalk:group:${conversationId}`
|
|
785
|
+
: `dingtalk:c2c:${data.senderId}`;
|
|
786
|
+
const senderName = data.senderNick || '钉钉用户';
|
|
787
|
+
const chatName = isGroup
|
|
788
|
+
? `钉钉群 ${conversationId.slice(0, 8)}`
|
|
789
|
+
: senderName;
|
|
790
|
+
// Store last message ID for reply context
|
|
791
|
+
lastMessageIds.set(jid, msgId);
|
|
792
|
+
// Store session webhook for sending replies
|
|
793
|
+
logger.debug({
|
|
794
|
+
jid,
|
|
795
|
+
hasSessionWebhook: !!data.sessionWebhook,
|
|
796
|
+
}, 'DingTalk message sessionWebhook');
|
|
797
|
+
if (data.sessionWebhook) {
|
|
798
|
+
lastSessionWebhooks.set(jid, data.sessionWebhook);
|
|
799
|
+
if (data.sessionWebhookExpiredTime) {
|
|
800
|
+
sessionWebhookExpiry.set(jid, data.sessionWebhookExpiredTime);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Store sender ID for file sending
|
|
804
|
+
if (data.senderId) {
|
|
805
|
+
lastSenderIds.set(jid, data.senderId);
|
|
806
|
+
}
|
|
807
|
+
// Store sender staff ID (enterprise user ID) for batchSend API
|
|
808
|
+
if (data.senderStaffId) {
|
|
809
|
+
lastSenderStaffIds.set(jid, data.senderStaffId);
|
|
810
|
+
}
|
|
811
|
+
// Get message content and attachments
|
|
812
|
+
let content = '';
|
|
813
|
+
let attachmentsJson;
|
|
814
|
+
if (data.msgtype === 'text' && 'text' in data) {
|
|
815
|
+
content = data.text?.content?.trim() || '';
|
|
816
|
+
}
|
|
817
|
+
else if (data.msgtype === 'richText' && data.content) {
|
|
818
|
+
// richText: mixed content array with text segments and picture objects
|
|
819
|
+
// e.g. [{text:"hi"},{type:"picture",downloadCode:"...",pictureDownloadCode:"..."}]
|
|
820
|
+
const richText = data.content.richText ?? [];
|
|
821
|
+
const textParts = [];
|
|
822
|
+
const imageEntries = [];
|
|
823
|
+
for (const entry of richText) {
|
|
824
|
+
if (entry.text) {
|
|
825
|
+
textParts.push(entry.text);
|
|
826
|
+
}
|
|
827
|
+
else if (entry.type === 'picture' &&
|
|
828
|
+
(entry.downloadCode || entry.pictureDownloadCode)) {
|
|
829
|
+
imageEntries.push({
|
|
830
|
+
downloadCode: entry.downloadCode || entry.pictureDownloadCode || '',
|
|
831
|
+
pictureDownloadCode: entry.pictureDownloadCode || '',
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
logger.info({ msgId, textParts, imageEntriesCount: imageEntries.length }, 'DingTalk richText parsed');
|
|
836
|
+
content = textParts.join('').trim();
|
|
837
|
+
if (imageEntries.length > 0) {
|
|
838
|
+
// Download each image; first one's base64 goes to Vision, all saved to disk
|
|
839
|
+
const allAttachments = [];
|
|
840
|
+
for (let i = 0; i < imageEntries.length; i++) {
|
|
841
|
+
const entry = imageEntries[i];
|
|
842
|
+
logger.info({ msgId, downloadCode: entry.downloadCode, index: i }, 'DingTalk richText downloading image');
|
|
843
|
+
const normalized = await normalizeDingTalkImage(jid, opts, () => downloadDingTalkImageByDownloadCode(entry.downloadCode || entry.pictureDownloadCode || '', data.robotCode ?? ''));
|
|
844
|
+
logger.info({ msgId, index: i, hasResult: !!normalized }, 'DingTalk richText image download complete');
|
|
845
|
+
if (normalized?.attachmentsJson) {
|
|
846
|
+
const parsed = JSON.parse(normalized.attachmentsJson);
|
|
847
|
+
allAttachments.push(...parsed);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (allAttachments.length > 0) {
|
|
851
|
+
attachmentsJson = JSON.stringify(allAttachments);
|
|
852
|
+
// Prepend first image content if available
|
|
853
|
+
const firstImgContent = allAttachments[0] ? `[图片: base64]` : '';
|
|
854
|
+
content = (firstImgContent + (content ? ' ' + content : '')).trim();
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
logger.info({
|
|
858
|
+
msgId,
|
|
859
|
+
contentLen: content?.length,
|
|
860
|
+
hasAttachments: !!attachmentsJson,
|
|
861
|
+
}, 'DingTalk richText processing complete');
|
|
862
|
+
if (!content && !attachmentsJson) {
|
|
863
|
+
// All richText entries were pictures with no text
|
|
864
|
+
content = attachmentsJson ? '[图片]' : '';
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
else if (data.msgtype === 'picture' && 'content' in data) {
|
|
868
|
+
const pictureContent = data.content;
|
|
869
|
+
const downloadCode = pictureContent?.downloadCode || pictureContent?.pictureDownloadCode;
|
|
870
|
+
if (!downloadCode) {
|
|
871
|
+
logger.warn({ msgId }, 'DingTalk picture message missing both downloadCode and pictureDownloadCode');
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const normalized = await normalizeDingTalkImage(jid, opts, () => downloadDingTalkImageByDownloadCode(downloadCode, data.robotCode ?? ''));
|
|
875
|
+
if (!normalized) {
|
|
876
|
+
logger.warn({ msgId }, 'DingTalk picture download failed, skipping');
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
content = normalized.content;
|
|
880
|
+
attachmentsJson = normalized.attachmentsJson;
|
|
881
|
+
}
|
|
882
|
+
else if (data.msgtype === 'file' && 'content' in data) {
|
|
883
|
+
const fileContent = data.content;
|
|
884
|
+
const downloadCode = fileContent?.downloadCode;
|
|
885
|
+
const fileName = fileContent?.fileName || 'file';
|
|
886
|
+
if (!downloadCode) {
|
|
887
|
+
logger.warn({ msgId }, 'DingTalk file message missing downloadCode');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const fileBuffer = await downloadDingTalkFileByDownloadCode(downloadCode, data.robotCode ?? '');
|
|
891
|
+
if (fileBuffer) {
|
|
892
|
+
const groupFolder = opts.resolveGroupFolder?.(jid);
|
|
893
|
+
if (groupFolder) {
|
|
894
|
+
try {
|
|
895
|
+
// Preserve original extension from filename
|
|
896
|
+
const ext = fileName.includes('.')
|
|
897
|
+
? fileName.split('.').pop()
|
|
898
|
+
: '';
|
|
899
|
+
const savedFilename = ext
|
|
900
|
+
? `file_${Date.now()}.${ext}`
|
|
901
|
+
: `file_${Date.now()}`;
|
|
902
|
+
const savedPath = await saveDownloadedFile(groupFolder, 'dingtalk', savedFilename, fileBuffer);
|
|
903
|
+
content = `[文件: ${savedPath}]`;
|
|
904
|
+
}
|
|
905
|
+
catch (err) {
|
|
906
|
+
logger.warn({ err }, 'Failed to save DingTalk file to disk');
|
|
907
|
+
content = `[文件: ${fileName}]`;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
content = `[文件: ${fileName}]`;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
logger.warn({ msgId }, 'DingTalk file download failed, skipping');
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
else if (data.msgtype === 'image' && 'image' in data) {
|
|
920
|
+
// Image message via contentUrl (legacy/native format)
|
|
921
|
+
const contentUrl = data.image?.contentUrl;
|
|
922
|
+
if (!contentUrl) {
|
|
923
|
+
logger.warn({ msgId }, 'DingTalk image message missing contentUrl');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const normalized = await normalizeDingTalkImage(jid, opts, () => downloadDingTalkImageAsBase64(contentUrl));
|
|
927
|
+
if (!normalized) {
|
|
928
|
+
logger.warn({ msgId }, 'DingTalk image download failed, skipping');
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
content = normalized.content;
|
|
932
|
+
attachmentsJson = normalized.attachmentsJson;
|
|
933
|
+
}
|
|
934
|
+
// Skip empty messages (text without content, or failed image)
|
|
935
|
+
if (!content && !attachmentsJson) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// ── /pair <code> command ──
|
|
939
|
+
const pairMatch = content.match(/^\/pair\s+(\S+)/i);
|
|
940
|
+
if (pairMatch && opts.onPairAttempt) {
|
|
941
|
+
const code = pairMatch[1];
|
|
942
|
+
try {
|
|
943
|
+
const success = await opts.onPairAttempt(jid, chatName, code);
|
|
944
|
+
const reply = success
|
|
945
|
+
? '配对成功!此聊天已连接到你的账号。'
|
|
946
|
+
: '配对码无效或已过期,请在 Web 设置页重新生成。';
|
|
947
|
+
if (data.sessionWebhook) {
|
|
948
|
+
await sendViaSessionWebhook(data.sessionWebhook, reply, isGroup);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
logger.error({ err, jid }, 'DingTalk pair attempt error');
|
|
953
|
+
}
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
// ── Authorization check ──
|
|
957
|
+
if (opts.isChatAuthorized && !opts.isChatAuthorized(jid)) {
|
|
958
|
+
logger.debug({ jid }, 'DingTalk chat not authorized');
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
// ── Group mention check ──
|
|
962
|
+
if (isGroup &&
|
|
963
|
+
opts.shouldProcessGroupMessage &&
|
|
964
|
+
!opts.shouldProcessGroupMessage(jid)) {
|
|
965
|
+
logger.debug({ jid }, 'DingTalk group message dropped (mention required)');
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
// ── Authorized: process message ──
|
|
969
|
+
storeChatMetadata(jid, new Date().toISOString());
|
|
970
|
+
updateChatName(jid, chatName);
|
|
971
|
+
opts.onNewChat(jid, chatName);
|
|
972
|
+
// Handle slash commands
|
|
973
|
+
const slashMatch = content.match(/^\/(\S+)(?:\s+(.*))?$/i);
|
|
974
|
+
if (slashMatch && opts.onCommand) {
|
|
975
|
+
const cmdBody = (slashMatch[1] + (slashMatch[2] ? ' ' + slashMatch[2] : '')).trim();
|
|
976
|
+
try {
|
|
977
|
+
const reply = await opts.onCommand(jid, cmdBody);
|
|
978
|
+
if (reply) {
|
|
979
|
+
const plainText = markdownToPlainText(reply);
|
|
980
|
+
if (data.sessionWebhook) {
|
|
981
|
+
await sendViaSessionWebhook(data.sessionWebhook, plainText, isGroup);
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
logger.error({ jid, err }, 'DingTalk slash command failed');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// Route and store message
|
|
992
|
+
const agentRouting = opts.resolveEffectiveChatJid?.(jid);
|
|
993
|
+
const targetJid = agentRouting?.effectiveJid ?? jid;
|
|
994
|
+
const id = crypto.randomUUID();
|
|
995
|
+
const timestamp = data.createAt
|
|
996
|
+
? new Date(data.createAt).toISOString()
|
|
997
|
+
: new Date().toISOString();
|
|
998
|
+
const senderId = `dingtalk:${data.senderId}`;
|
|
999
|
+
storeChatMetadata(targetJid, timestamp);
|
|
1000
|
+
storeMessageDirect(id, targetJid, senderId, senderName, content, timestamp, false, { attachments: attachmentsJson, sourceJid: jid });
|
|
1001
|
+
broadcastNewMessage(targetJid, {
|
|
1002
|
+
id,
|
|
1003
|
+
chat_jid: targetJid,
|
|
1004
|
+
source_jid: jid,
|
|
1005
|
+
sender: senderId,
|
|
1006
|
+
sender_name: senderName,
|
|
1007
|
+
content,
|
|
1008
|
+
timestamp,
|
|
1009
|
+
attachments: attachmentsJson,
|
|
1010
|
+
is_from_me: false,
|
|
1011
|
+
}, agentRouting?.agentId ?? undefined);
|
|
1012
|
+
notifyNewImMessage();
|
|
1013
|
+
if (agentRouting?.agentId) {
|
|
1014
|
+
opts.onAgentMessage?.(jid, agentRouting.agentId);
|
|
1015
|
+
logger.info({ jid, effectiveJid: targetJid, agentId: agentRouting.agentId }, 'DingTalk message routed to agent');
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
logger.info({ jid, sender: senderName, msgId }, 'DingTalk message stored');
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
logger.error({ err }, 'Error handling DingTalk robot message');
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
// ─── Connection Interface ─────────────────────────────────
|
|
1026
|
+
const connection = {
|
|
1027
|
+
async connect(opts) {
|
|
1028
|
+
if (!config.clientId || !config.clientSecret) {
|
|
1029
|
+
logger.info('DingTalk clientId/clientSecret not configured, skipping');
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
stopping = false;
|
|
1033
|
+
readyFired = false;
|
|
1034
|
+
try {
|
|
1035
|
+
// 🔧 Fix proxy issue: dingtalk-stream SDK uses axios internally, which can be
|
|
1036
|
+
// affected by system PAC files. We temporarily disable the global proxy default
|
|
1037
|
+
// around DWClient creation, then restore the original value to avoid affecting
|
|
1038
|
+
// other modules (e.g., @larksuiteoapi/node-sdk) that also use axios.
|
|
1039
|
+
const axios = (await import('axios')).default;
|
|
1040
|
+
const originalProxy = axios.defaults?.proxy;
|
|
1041
|
+
if (axios.defaults) {
|
|
1042
|
+
axios.defaults.proxy = false;
|
|
1043
|
+
logger.debug('Temporarily disabled axios global proxy for dingtalk-stream SDK');
|
|
1044
|
+
}
|
|
1045
|
+
// Create DWClient
|
|
1046
|
+
client = new DWClient({
|
|
1047
|
+
clientId: config.clientId,
|
|
1048
|
+
clientSecret: config.clientSecret,
|
|
1049
|
+
debug: false,
|
|
1050
|
+
});
|
|
1051
|
+
// Restore original axios proxy setting after DWClient creation
|
|
1052
|
+
if (axios.defaults && originalProxy !== undefined) {
|
|
1053
|
+
axios.defaults.proxy = originalProxy;
|
|
1054
|
+
}
|
|
1055
|
+
// Register robot message callback using registerCallbackListener (not registerAllEventListener)
|
|
1056
|
+
client.registerCallbackListener(TOPIC_ROBOT, async (downstream) => {
|
|
1057
|
+
logger.info({ dataLen: downstream.data?.length }, 'DingTalk robot message received');
|
|
1058
|
+
// Ack immediately
|
|
1059
|
+
const messageId = downstream.headers?.messageId;
|
|
1060
|
+
if (messageId && client) {
|
|
1061
|
+
client.socketCallBackResponse(messageId, { success: true });
|
|
1062
|
+
logger.debug({ messageId }, 'DingTalk callback acknowledged');
|
|
1063
|
+
}
|
|
1064
|
+
// Process in background
|
|
1065
|
+
handleRobotMessage(downstream, opts).catch((err) => {
|
|
1066
|
+
logger.error({ err }, 'Error in DingTalk message handler');
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
// Connect
|
|
1070
|
+
await client.connect();
|
|
1071
|
+
logger.info({ clientId: config.clientId.slice(0, 8) }, 'DingTalk Stream connected');
|
|
1072
|
+
// Monitor for subscription recovery: the SDK reconnects automatically after
|
|
1073
|
+
// network interruptions, but the server may drop our subscription registration.
|
|
1074
|
+
// Detect "connected but not subscribed" state and force a full re-register.
|
|
1075
|
+
let reconnectGuard = false;
|
|
1076
|
+
const startReconnectMonitor = () => {
|
|
1077
|
+
const check = async () => {
|
|
1078
|
+
if (stopping || reconnectGuard)
|
|
1079
|
+
return;
|
|
1080
|
+
const sdk = client;
|
|
1081
|
+
if (sdk?.connected && !sdk?.registered) {
|
|
1082
|
+
reconnectGuard = true;
|
|
1083
|
+
logger.warn('DingTalk reconnected but not registered, forcing re-register');
|
|
1084
|
+
try {
|
|
1085
|
+
const cur = client;
|
|
1086
|
+
if (cur) {
|
|
1087
|
+
cur.disconnect();
|
|
1088
|
+
await cur.connect();
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
// ignore — SDK will retry on next check
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
reconnectGuard = false;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
reconnectCheckInterval = setInterval(check, 15_000);
|
|
1100
|
+
void check(); // immediate first check
|
|
1101
|
+
};
|
|
1102
|
+
startReconnectMonitor();
|
|
1103
|
+
readyFired = true;
|
|
1104
|
+
opts.onReady?.();
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
catch (err) {
|
|
1108
|
+
logger.error({ err }, 'DingTalk initial connection failed');
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
async disconnect() {
|
|
1113
|
+
stopping = true;
|
|
1114
|
+
if (reconnectCheckInterval) {
|
|
1115
|
+
clearInterval(reconnectCheckInterval);
|
|
1116
|
+
reconnectCheckInterval = null;
|
|
1117
|
+
}
|
|
1118
|
+
if (client) {
|
|
1119
|
+
try {
|
|
1120
|
+
client.disconnect();
|
|
1121
|
+
}
|
|
1122
|
+
catch (err) {
|
|
1123
|
+
logger.debug({ err }, 'Error disconnecting DingTalk client');
|
|
1124
|
+
}
|
|
1125
|
+
client = null;
|
|
1126
|
+
}
|
|
1127
|
+
tokenInfo = null;
|
|
1128
|
+
msgCache.clear();
|
|
1129
|
+
lastMessageIds.clear();
|
|
1130
|
+
lastSessionWebhooks.clear();
|
|
1131
|
+
sessionWebhookExpiry.clear();
|
|
1132
|
+
lastSenderIds.clear();
|
|
1133
|
+
lastSenderStaffIds.clear();
|
|
1134
|
+
logger.info('DingTalk bot disconnected');
|
|
1135
|
+
},
|
|
1136
|
+
async sendMessage(chatId, text, _localImagePaths) {
|
|
1137
|
+
const parsed = parseDingTalkChatId(chatId);
|
|
1138
|
+
if (!parsed) {
|
|
1139
|
+
logger.error({ chatId }, 'Invalid DingTalk chat ID format');
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
// Reconstruct the full jid to match how sessionWebhook/senderStaffId was stored
|
|
1143
|
+
const jidKey = parsed.type === 'c2c'
|
|
1144
|
+
? `dingtalk:c2c:${parsed.conversationId}`
|
|
1145
|
+
: `dingtalk:group:${parsed.conversationId}`;
|
|
1146
|
+
logger.info({ chatId, textLen: text.length, text: text.slice(0, 200), jidKey }, 'DingTalk sendMessage called');
|
|
1147
|
+
// C2C messages require the persistent API with senderStaffId.
|
|
1148
|
+
// sessionWebhook is DingTalk's reply callback URL — only valid within the
|
|
1149
|
+
// stream connection and cannot be used for proactive C2C messages.
|
|
1150
|
+
if (parsed.type === 'c2c') {
|
|
1151
|
+
const senderStaffId = lastSenderStaffIds.get(jidKey);
|
|
1152
|
+
if (!senderStaffId) {
|
|
1153
|
+
logger.error({ chatId, jidKey }, 'DingTalk sendMessage: no senderStaffId found for C2C chat');
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const plainText = markdownToPlainText(text);
|
|
1157
|
+
const chunks = splitTextChunks(plainText, MSG_SPLIT_LIMIT);
|
|
1158
|
+
logger.info({ chatId, jidKey, chunks: chunks.length }, 'DingTalk sendMessage: sending C2C via persistent API');
|
|
1159
|
+
for (const chunk of chunks) {
|
|
1160
|
+
await sendViaPersistentAPI(senderStaffId, chunk);
|
|
1161
|
+
}
|
|
1162
|
+
logger.info({ chatId }, 'DingTalk C2C message sent via persistent API');
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
// Group messages — use the persistent groupMessages API (openConversationId is
|
|
1166
|
+
// stable and does not expire like sessionWebhook). This also avoids the reconnect
|
|
1167
|
+
// invalidation issue that plagued sendViaSessionWebhook for group chats.
|
|
1168
|
+
const openConversationId = parsed.conversationId;
|
|
1169
|
+
// Group chats support markdown. Split first to stay within message size limits.
|
|
1170
|
+
const contentToSend = convertToDingTalkMarkdown(text);
|
|
1171
|
+
const chunks = splitTextChunks(contentToSend, MSG_SPLIT_LIMIT);
|
|
1172
|
+
// Try markdown first, fall back to plain text on error.
|
|
1173
|
+
for (const chunk of chunks) {
|
|
1174
|
+
const msgParam = JSON.stringify({
|
|
1175
|
+
title: chunk.slice(0, 50),
|
|
1176
|
+
text: chunk,
|
|
1177
|
+
});
|
|
1178
|
+
try {
|
|
1179
|
+
await sendViaGroupMessagesAPI(openConversationId, 'sampleMarkdown', msgParam);
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
// Fall back to plain text
|
|
1183
|
+
const plainContent = markdownToPlainText(chunk);
|
|
1184
|
+
const plainMsgParam = JSON.stringify({ content: plainContent });
|
|
1185
|
+
await sendViaGroupMessagesAPI(openConversationId, 'sampleText', plainMsgParam);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
logger.info({ chatId }, 'DingTalk group message sent via persistent API');
|
|
1189
|
+
},
|
|
1190
|
+
async sendImage(chatId, imageBuffer, mimeType, caption, fileName) {
|
|
1191
|
+
// Look up sender info from the chat jid
|
|
1192
|
+
const parsed = parseDingTalkChatId(chatId);
|
|
1193
|
+
const jidKey = parsed
|
|
1194
|
+
? parsed.type === 'c2c'
|
|
1195
|
+
? `dingtalk:c2c:${parsed.conversationId}`
|
|
1196
|
+
: `dingtalk:group:${parsed.conversationId}`
|
|
1197
|
+
: chatId;
|
|
1198
|
+
const senderId = lastSenderIds.get(jidKey);
|
|
1199
|
+
const senderStaffId = lastSenderStaffIds.get(jidKey);
|
|
1200
|
+
if (!senderId) {
|
|
1201
|
+
logger.error({ chatId, jidKey }, 'DingTalk sendImage: no senderId found');
|
|
1202
|
+
throw new Error(`DingTalk sendImage: unknown chat ${chatId}`);
|
|
1203
|
+
}
|
|
1204
|
+
const fname = fileName || `image.${mimeType.split('/')[1] || 'png'}`;
|
|
1205
|
+
// Upload image to DingTalk media API
|
|
1206
|
+
const mediaId = await uploadDingTalkMedia(imageBuffer, fname, 'image');
|
|
1207
|
+
if (!mediaId) {
|
|
1208
|
+
throw new Error('DingTalk sendImage: media upload failed');
|
|
1209
|
+
}
|
|
1210
|
+
// For group chats: use persistent groupMessages API.
|
|
1211
|
+
// For C2C: use batchSend API.
|
|
1212
|
+
const isGroup = parsed?.type === 'group';
|
|
1213
|
+
const openConversationId = parsed?.conversationId;
|
|
1214
|
+
if (isGroup && openConversationId) {
|
|
1215
|
+
const msgParam = JSON.stringify({ photoURL: mediaId });
|
|
1216
|
+
try {
|
|
1217
|
+
await sendViaGroupMessagesAPI(openConversationId, 'sampleImageMsg', msgParam);
|
|
1218
|
+
logger.info({ chatId, mediaId, fileName: fname }, 'DingTalk group image sent via persistent API');
|
|
1219
|
+
}
|
|
1220
|
+
catch (err) {
|
|
1221
|
+
logger.error({ err, chatId }, 'DingTalk sendImage: group API failed');
|
|
1222
|
+
throw err;
|
|
1223
|
+
}
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
// C2C: use batchSend API
|
|
1227
|
+
const targetUserId = senderStaffId || senderId;
|
|
1228
|
+
const robotCode = config.clientId;
|
|
1229
|
+
try {
|
|
1230
|
+
await sendDingTalkImageMessage(targetUserId, robotCode, mediaId, fname);
|
|
1231
|
+
logger.info({ chatId, mediaId, fileName: fname }, 'DingTalk C2C image sent');
|
|
1232
|
+
}
|
|
1233
|
+
catch (err) {
|
|
1234
|
+
logger.error({ err, chatId }, 'DingTalk sendImage: failed');
|
|
1235
|
+
throw err;
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
async sendFile(chatId, filePath, fileName) {
|
|
1239
|
+
logger.info({ chatId, filePath, fileName }, 'DingTalk sendFile called');
|
|
1240
|
+
// Look up senderId and senderStaffId stored from incoming message.
|
|
1241
|
+
// NOTE: lastSenderIds and lastSenderStaffIds are keyed by the full jid
|
|
1242
|
+
// (dingtalk:c2c:{id} or dingtalk:group:{id}), so we must reconstruct
|
|
1243
|
+
// the jid from chatId to match the storage key.
|
|
1244
|
+
// extractChatId gives bare ID, then we re-add the prefix for Map lookup.
|
|
1245
|
+
const parsed = parseDingTalkChatId(chatId);
|
|
1246
|
+
const jidKey = parsed
|
|
1247
|
+
? parsed.type === 'c2c'
|
|
1248
|
+
? `dingtalk:c2c:${parsed.conversationId}`
|
|
1249
|
+
: `dingtalk:group:${parsed.conversationId}`
|
|
1250
|
+
: chatId; // fallback for legacy format
|
|
1251
|
+
const senderId = lastSenderIds.get(jidKey);
|
|
1252
|
+
if (!senderId) {
|
|
1253
|
+
logger.error({ chatId, jidKey }, 'DingTalk sendFile: no senderId found for chat');
|
|
1254
|
+
throw new Error(`DingTalk sendFile: unknown chat ${chatId}`);
|
|
1255
|
+
}
|
|
1256
|
+
const senderStaffId = lastSenderStaffIds.get(jidKey);
|
|
1257
|
+
// Read file from disk
|
|
1258
|
+
let fileBuffer;
|
|
1259
|
+
try {
|
|
1260
|
+
fileBuffer = await fs.readFile(filePath);
|
|
1261
|
+
}
|
|
1262
|
+
catch (err) {
|
|
1263
|
+
logger.error({ err, filePath }, 'DingTalk sendFile: failed to read file');
|
|
1264
|
+
throw new Error(`DingTalk sendFile: failed to read file ${filePath}`);
|
|
1265
|
+
}
|
|
1266
|
+
if (fileBuffer.length === 0) {
|
|
1267
|
+
throw new Error('DingTalk sendFile: empty file');
|
|
1268
|
+
}
|
|
1269
|
+
if (fileBuffer.length > 20 * 1024 * 1024) {
|
|
1270
|
+
throw new Error('DingTalk sendFile: file exceeds 20MB limit');
|
|
1271
|
+
}
|
|
1272
|
+
// Determine media type
|
|
1273
|
+
const ext = fileName.includes('.')
|
|
1274
|
+
? fileName.split('.').pop().toLowerCase()
|
|
1275
|
+
: '';
|
|
1276
|
+
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
|
1277
|
+
const voiceExts = ['amr', 'mp3', 'wav'];
|
|
1278
|
+
const videoExts = ['mp4'];
|
|
1279
|
+
let mediaType = 'file';
|
|
1280
|
+
if (imageExts.includes(ext))
|
|
1281
|
+
mediaType = 'image';
|
|
1282
|
+
else if (voiceExts.includes(ext))
|
|
1283
|
+
mediaType = 'voice';
|
|
1284
|
+
else if (videoExts.includes(ext))
|
|
1285
|
+
mediaType = 'video';
|
|
1286
|
+
// Upload to DingTalk media API
|
|
1287
|
+
const mediaId = await uploadDingTalkMedia(fileBuffer, fileName, mediaType);
|
|
1288
|
+
if (!mediaId) {
|
|
1289
|
+
throw new Error('DingTalk sendFile: media upload failed');
|
|
1290
|
+
}
|
|
1291
|
+
// For group chats: use the persistent groupMessages API (openConversationId
|
|
1292
|
+
// is stable, unlike sessionWebhook which gets invalidated on reconnects).
|
|
1293
|
+
// For C2C chats: use the batchSend API with senderStaffId/senderId.
|
|
1294
|
+
const isGroup = parsed?.type === 'group';
|
|
1295
|
+
const openConversationId = parsed?.conversationId;
|
|
1296
|
+
if (isGroup && openConversationId) {
|
|
1297
|
+
// Send via persistent groupMessages API
|
|
1298
|
+
try {
|
|
1299
|
+
if (mediaType === 'image') {
|
|
1300
|
+
const msgParam = JSON.stringify({ photoURL: mediaId });
|
|
1301
|
+
await sendViaGroupMessagesAPI(openConversationId, 'sampleImageMsg', msgParam);
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
const msgParam = JSON.stringify({
|
|
1305
|
+
mediaId,
|
|
1306
|
+
fileName,
|
|
1307
|
+
fileType: ext,
|
|
1308
|
+
});
|
|
1309
|
+
await sendViaGroupMessagesAPI(openConversationId, 'sampleFile', msgParam);
|
|
1310
|
+
}
|
|
1311
|
+
logger.info({ chatId, fileName, mediaId }, 'DingTalk group file sent via persistent API');
|
|
1312
|
+
}
|
|
1313
|
+
catch (err) {
|
|
1314
|
+
logger.error({ err, chatId, fileName }, 'DingTalk sendFile: groupMessages API failed');
|
|
1315
|
+
throw err;
|
|
1316
|
+
}
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// C2C: use batchSend API
|
|
1320
|
+
const targetUserId = senderStaffId || senderId;
|
|
1321
|
+
const robotCode = config.clientId;
|
|
1322
|
+
try {
|
|
1323
|
+
if (mediaType === 'image') {
|
|
1324
|
+
await sendDingTalkImageMessage(targetUserId, robotCode, mediaId, fileName);
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
await sendDingTalkFileMessage(targetUserId, robotCode, mediaId, fileName, ext);
|
|
1328
|
+
}
|
|
1329
|
+
logger.info({ chatId, fileName, mediaId, senderStaffId: !!senderStaffId }, 'DingTalk C2C file sent successfully');
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
logger.error({ err, chatId, fileName }, 'DingTalk sendFile: batchSend failed');
|
|
1333
|
+
throw err;
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
async sendReaction(_chatId, _isTyping) {
|
|
1337
|
+
// DingTalk doesn't support typing indicators via Stream
|
|
1338
|
+
},
|
|
1339
|
+
isConnected() {
|
|
1340
|
+
return client !== null && !stopping;
|
|
1341
|
+
},
|
|
1342
|
+
getLastMessageId(chatId) {
|
|
1343
|
+
return lastMessageIds.get(chatId);
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
return connection;
|
|
1347
|
+
}
|