├── test.js ├── vite-node.mjs ├── src ├── index.ts ├── constants.ts └── client.ts ├── example ├── config.json └── index.ts ├── tsconfig.json ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── README.md ├── rollup.config.js ├── package.json └── .gitignore /test.js: -------------------------------------------------------------------------------- 1 | console.log('你好,工具人') -------------------------------------------------------------------------------- /vite-node.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('./dist/cli.mjs'); 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants.js'; 2 | export * from './client.js'; -------------------------------------------------------------------------------- /example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "clientId": "ding2kdwyyilaknq8zj5", 3 | "clientSecret": "vlDWox885jMZQLm5cQ6lBTtdjqQXEfK-PK5dIL29tPECYdAE0i1A_7wum76BLxzO" 4 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["esnext", "dom"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "resolveJsonModule": true, 11 | "skipDefaultLibCheck": true, 12 | "skipLibCheck": true, 13 | "outDir": "./dist", 14 | "declaration": true, 15 | "inlineSourceMap": true 16 | }, 17 | "include": ["./src/**/*.ts"], 18 | "exclude": ["./dist"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: pnpm/action-setup@v2 11 | with: 12 | version: 8 13 | # Setup .npmrc file to publish to npm 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: '16.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm install 19 | - run: npm run build 20 | - run: npm publish 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 钉钉开放平台团队 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const GATEWAY_URL = 'https://api.dingtalk.com/v1.0/gateway/connections/open'; 2 | export const GET_TOKEN_URL = 'https://oapi.dingtalk.com/gettoken'; 3 | 4 | 5 | /** 机器人消息回调 */ 6 | export const TOPIC_ROBOT = '/v1.0/im/bot/messages/get'; 7 | 8 | /** 卡片回调 */ 9 | export const TOPIC_CARD = '/v1.0/card/instances/callback'; 10 | 11 | /** AI Graph API 插件消息回调 */ 12 | export const TOPIC_AI_GRAPH_API = '/v1.0/graph/api/invoke'; 13 | 14 | interface RobotMessageBase { 15 | conversationId: string; 16 | chatbotCorpId: string; 17 | chatbotUserId: string; 18 | msgId: string; 19 | senderNick: string; 20 | isAdmin: boolean; 21 | senderStaffId: string; 22 | sessionWebhookExpiredTime: number; 23 | createAt: number; 24 | senderCorpId: string; 25 | conversationType: string; 26 | senderId: string; 27 | sessionWebhook: string; 28 | robotCode: string; 29 | msgtype: string; 30 | } 31 | 32 | export interface RobotTextMessage extends RobotMessageBase { 33 | msgtype: 'text'; 34 | text: { 35 | content: string; 36 | }; 37 | } 38 | 39 | export interface GraphAPIResponse { 40 | response: { 41 | statusLine: { 42 | code?: number; 43 | reasonPhrase?: string; 44 | }; 45 | headers: { 46 | [key: string]: string; 47 | }; 48 | body: string; 49 | }; 50 | } 51 | 52 | export type RobotMessage = RobotTextMessage; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | 7 | NPM Version 8 | 9 | 10 |

11 | 12 | 钉钉支持 Stream 模式接入事件推送、机器人收消息以及卡片回调,该 SDK 实现了 Stream 模式。相比 Webhook 模式,Stream 模式可以更简单的接入各类事件和回调。 13 | 14 | ## 开发教程 15 | 16 | 在 [教程文档](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/overview) 中,你可以找到钉钉 Stream 模式的教程文档和示例代码。 17 | 18 | ### 参考资料 19 | 20 | * [Stream 模式说明](https://opensource.dingtalk.com/developerpedia/docs/learn/stream/overview) 21 | * [教程文档](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/overview) 22 | * [常见问题](https://opensource.dingtalk.com/developerpedia/docs/learn/stream/faq) 23 | * [Stream 模式共创群](https://opensource.dingtalk.com/developerpedia/docs/explore/support/?via=moon-group) 24 | 25 | ### 调试方法 26 | 27 | 1、创建企业内部应用 28 | 29 | 进入钉钉开发者后台,创建企业内部应用,获取ClientID(即 AppKey)和ClientSecret( 即AppSecret)。 30 | 31 | 2、开通Stream 模式的机器人 32 | 33 | 进入开发者后台新建的应用,点击应用能力 - 添加应用能力 - 机器人,完善机器人信息,选择stream模式并发布。 34 | 35 | 3、使用demo项目测试,启动服务: 36 | 37 | a、获取demo项目 38 | 39 | git clone git@github.com:open-dingtalk/dingtalk-stream-sdk-nodejs.git 40 | b、在example/config.json里配置应用信息。 41 | 42 | c、启动测试case 43 | 44 | cd dingtalk-stream-sdk-nodejs 45 | yarn 46 | npm run build 47 | npm start 48 | 49 | 50 | 注意:ts-node-esm启动ts文件调试时,ts文件内import引用的文件后缀必须是js,ts会报找不到模块异常。 51 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { builtinModules } from 'node:module'; 2 | import esbuild from 'rollup-plugin-esbuild'; 3 | import dts from 'rollup-plugin-dts'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import commonjs from '@rollup/plugin-commonjs'; 6 | import json from '@rollup/plugin-json'; 7 | import alias from '@rollup/plugin-alias'; 8 | import { defineConfig } from 'rollup'; 9 | import pkg from './package.json' assert { type: 'json' }; 10 | 11 | const entries = { 12 | index: 'src/index.ts', 13 | client: 'src/client.ts', 14 | constants: 'src/constants.ts', 15 | }; 16 | 17 | const external = [ 18 | ...builtinModules, 19 | ...Object.keys(pkg.dependencies || {}), 20 | ...Object.keys(pkg.peerDependencies || {}), 21 | 'pathe', 22 | 'birpc', 23 | 'vite', 24 | 'vite/types/hot', 25 | 'node:url', 26 | 'node:events', 27 | 'node:vm', 28 | ]; 29 | 30 | const plugins = [ 31 | resolve({ 32 | preferBuiltins: true, 33 | }), 34 | json(), 35 | commonjs(), 36 | esbuild({ 37 | target: 'node14', 38 | }), 39 | ]; 40 | 41 | export default defineConfig([ 42 | { 43 | input: entries, 44 | output: { 45 | dir: 'dist', 46 | format: 'esm', 47 | entryFileNames: '[name].mjs', 48 | chunkFileNames: 'chunk-[name].mjs', 49 | }, 50 | external, 51 | plugins, 52 | onwarn, 53 | }, 54 | { 55 | input: entries, 56 | output: { 57 | dir: 'dist', 58 | format: 'cjs', 59 | entryFileNames: '[name].cjs', 60 | chunkFileNames: 'chunk-[name].cjs', 61 | }, 62 | external, 63 | plugins: [ 64 | alias({ 65 | entries: [ 66 | // cjs in Node 14 doesn't support node: prefix 67 | // can be dropped, when we drop support for Node 14 68 | { find: /^node:(.+)$/, replacement: '$1' }, 69 | ], 70 | }), 71 | ...plugins, 72 | ], 73 | onwarn, 74 | }, 75 | { 76 | input: entries, 77 | output: { 78 | dir: 'dist', 79 | entryFileNames: '[name].d.ts', 80 | format: 'esm', 81 | }, 82 | external, 83 | plugins: [dts({ respectExternal: true })], 84 | onwarn, 85 | }, 86 | ]); 87 | 88 | function onwarn(message) { 89 | if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) return; 90 | console.error(message); 91 | } 92 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DWClient, 3 | DWClientDownStream, 4 | EventAck, 5 | RobotMessage, 6 | TOPIC_ROBOT, 7 | TOPIC_AI_GRAPH_API, 8 | } from "../src/index.js"; 9 | import axios from "axios"; 10 | import config from "./config.json" assert { type: "json" }; 11 | 12 | console.log("开始启动"); 13 | const client = new DWClient({ 14 | clientId: config.clientId, 15 | clientSecret: config.clientSecret, 16 | debug: true, 17 | }); 18 | client.registerCallbackListener(TOPIC_ROBOT, async (res) => { 19 | // 注册机器人回调事件 20 | console.log("收到消息"); 21 | debugger; 22 | // const {messageId} = res.headers; 23 | const { text, senderStaffId, sessionWebhook } = JSON.parse( 24 | res.data 25 | ) as RobotMessage; 26 | const body = { 27 | at: { 28 | atUserIds: [senderStaffId], 29 | isAtAll: false, 30 | }, 31 | text: { 32 | content: 33 | "nodejs-getting-started say : 收到," + text?.content || 34 | "钉钉,让进步发生", 35 | }, 36 | msgtype: "text", 37 | }; 38 | 39 | const accessToken = await client.getAccessToken(); 40 | const result = await axios({ 41 | url: sessionWebhook, 42 | method: "POST", 43 | responseType: "json", 44 | data: body, 45 | headers: { 46 | "x-acs-dingtalk-access-token": accessToken, 47 | }, 48 | }); 49 | 50 | // stream模式下,服务端推送消息到client后,会监听client响应,如果消息长时间未响应会在一定时间内(60s)重试推消息,可以通过此方法返回消息响应,避免多次接收服务端消息。 51 | // 机器人topic,可以通过socketCallBackResponse方法返回消息响应 52 | if(result?.data){ 53 | client.socketCallBackResponse(res.headers.messageId, result.data); 54 | } 55 | }); 56 | client 57 | .registerCallbackListener( 58 | TOPIC_AI_GRAPH_API, 59 | async (res: DWClientDownStream) => { 60 | // 注册AI插件回调事件 61 | console.log("收到ai消息"); 62 | const { messageId } = res.headers; 63 | 64 | // 添加业务逻辑 65 | console.log(res); 66 | console.log(JSON.parse(res.data)); 67 | 68 | // 通过Stream返回数据 69 | client.sendGraphAPIResponse(messageId, { 70 | response: { 71 | statusLine: { 72 | code: 200, 73 | reasonPhrase: "OK", 74 | }, 75 | headers: {}, 76 | body: JSON.stringify({ 77 | text: "你好", 78 | }), 79 | }, 80 | }); 81 | } 82 | ) 83 | .registerAllEventListener((message: DWClientDownStream) => { 84 | return { status: EventAck.SUCCESS }; 85 | }) 86 | .connect(); 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dingtalk-stream", 3 | "version": "2.1.4", 4 | "description": "Nodejs SDK for DingTalk Stream Mode API, Compared with the webhook mode, it is easier to access the DingTalk", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "type": "module", 9 | "typesVersions": { 10 | "*": { 11 | "*": [ 12 | "./dist/*", 13 | "./dist/index.d.ts" 14 | ] 15 | } 16 | }, 17 | "exports": { 18 | ".": { 19 | "types": "./dist/index.d.ts", 20 | "require": "./dist/index.cjs", 21 | "import": "./dist/index.mjs" 22 | }, 23 | "./client": { 24 | "types": "./dist/client.d.ts", 25 | "require": "./dist/client.cjs", 26 | "import": "./dist/client.mjs" 27 | }, 28 | "./constants": { 29 | "types": "./dist/constants.d.ts", 30 | "require": "./dist/constants.cjs", 31 | "import": "./dist/constants.mjs" 32 | }, 33 | "./*": "./*" 34 | }, 35 | "scripts": { 36 | "start": "ts-node-esm example/index.ts", 37 | "build": "rimraf dist && rollup -c", 38 | "dev": "rollup -c --watch --watch.include 'src/**' -m inline", 39 | "prepublishOnly": "pnpm build", 40 | "typecheck": "tsc --noEmit" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs.git" 45 | }, 46 | "keywords": [ 47 | "DingTalk Stream Mode", 48 | "Nodejs", 49 | "SDK" 50 | ], 51 | "author": "junlong.hjl@alibaba-inc.com", 52 | "license": "MIT", 53 | "bugs": { 54 | "url": "https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs/issues" 55 | }, 56 | "files": [ 57 | "dist", 58 | "*.d.ts", 59 | "*.mjs" 60 | ], 61 | "homepage": "https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs#readme", 62 | "dependencies": { 63 | "axios": "^1.4.0", 64 | "debug": "^4.3.4", 65 | "ws": "^8.13.0" 66 | }, 67 | "devDependencies": { 68 | "@rollup/plugin-alias": "^5.0.0", 69 | "@rollup/plugin-commonjs": "^25.0.4", 70 | "@rollup/plugin-json": "^6.0.0", 71 | "@rollup/plugin-node-resolve": "^15.2.0", 72 | "@types/debug": "^4.1.8", 73 | "@types/node": ">=16", 74 | "@types/ws": "^8.5.5", 75 | "rimraf": "^5.0.1", 76 | "rollup": "^3.28.0", 77 | "rollup-plugin-dts": "^6.0.0", 78 | "rollup-plugin-esbuild": "^5.0.0", 79 | "ts-node": "^10.9.1", 80 | "typescript": "^5.1.6", 81 | "vite": "^4.4.9" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # WebStorm projects 133 | .idea -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import axios from 'axios'; 3 | import EventEmitter from 'events'; 4 | import { TOPIC_ROBOT,GET_TOKEN_URL, GATEWAY_URL,GraphAPIResponse } from './constants.js'; 5 | 6 | export enum EventAck { 7 | SUCCESS = "SUCCESS", 8 | LATER = "LATER", 9 | } 10 | 11 | export interface EventAckData { 12 | status: EventAck; 13 | message?: string; 14 | } 15 | 16 | const defaultConfig = { 17 | autoReconnect: true, 18 | keepAlive: false, 19 | ua: '', 20 | subscriptions: [ 21 | { 22 | type: 'EVENT', 23 | topic: '*', 24 | }, 25 | ], 26 | }; 27 | 28 | export interface DWClientConfig { 29 | clientId: string; 30 | clientSecret: string; 31 | keepAlive?: boolean; 32 | debug?: boolean; 33 | ua?: string; 34 | endpoint?: string; 35 | access_token?: string; 36 | autoReconnect?: boolean; 37 | subscriptions: Array<{ 38 | type: string; 39 | topic: string; 40 | }>; 41 | } 42 | 43 | export interface DWClientDownStream { 44 | specVersion: string; 45 | type: string; 46 | headers: { 47 | appId: string; 48 | connectionId: string; 49 | contentType: string; 50 | messageId: string; 51 | time: string; 52 | topic: string; 53 | eventType?: string; 54 | eventBornTime?: string; 55 | eventId?: string; 56 | eventCorpId?: string; 57 | eventUnifiedAppId?: string; 58 | }; 59 | data: string; 60 | } 61 | 62 | export interface OnEventReceived { 63 | (msg: DWClientDownStream): EventAckData 64 | } 65 | 66 | export class DWClient extends EventEmitter { 67 | debug = false; 68 | connected = false; 69 | registered = false; 70 | reconnecting = false; 71 | private userDisconnect = false; 72 | private reconnectInterval = 1000; 73 | private heartbeat_interval = 8000; 74 | private heartbeatIntervallId?: NodeJS.Timeout; 75 | 76 | private sslopts = { rejectUnauthorized: true }; 77 | readonly config: DWClientConfig; 78 | private socket?: WebSocket; 79 | private dw_url?: string; 80 | private isAlive = false; 81 | private onEventReceived: OnEventReceived = (msg: DWClientDownStream) => {return {status: EventAck.SUCCESS}}; 82 | 83 | constructor(opts: { 84 | clientId: string; 85 | clientSecret: string; 86 | ua?: string; 87 | keepAlive?: boolean; 88 | debug?: boolean; 89 | }) { 90 | super(); 91 | this.config = { 92 | ...defaultConfig, 93 | ...opts, 94 | }; 95 | 96 | if (!this.config.clientId || !this.config.clientSecret) { 97 | console.error('clientId or clientSecret is null'); 98 | throw new Error('clientId or clientSecret is null'); 99 | } 100 | if (this.config.debug !== undefined) { 101 | this.debug = this.config.debug; 102 | } 103 | } 104 | 105 | getConfig() { 106 | return { ...this.config }; 107 | } 108 | 109 | printDebug(msg: object | string) { 110 | if (this.debug) { 111 | const date = '[' + new Date().toISOString() + ']'; 112 | console.info(date, msg); 113 | } 114 | } 115 | 116 | registerAllEventListener( 117 | onEventReceived: (v: DWClientDownStream) => EventAckData 118 | ) { 119 | this.onEventReceived = onEventReceived; 120 | return this; 121 | } 122 | 123 | registerCallbackListener( 124 | eventId: string, 125 | callback: (v: DWClientDownStream) => void 126 | ) { 127 | if (!eventId || !callback) { 128 | console.error( 129 | 'registerCallbackListener: eventId and callback must be defined' 130 | ); 131 | throw new Error( 132 | 'registerCallbackListener: eventId and callback must be defined' 133 | ); 134 | } 135 | 136 | if ( 137 | !this.config.subscriptions.find( 138 | (x) => x.topic === eventId && x.type === 'CALLBACK' 139 | ) 140 | ) { 141 | this.config.subscriptions.push({ 142 | type: 'CALLBACK', 143 | topic: eventId, 144 | }); 145 | } 146 | 147 | this.on(eventId, callback); 148 | 149 | return this; 150 | } 151 | 152 | async getAccessToken() { 153 | const result = await axios.get( 154 | `${GET_TOKEN_URL}?appkey=${this.config.clientId}&appsecret=${this.config.clientSecret}` 155 | ); 156 | if (result.status === 200 && result.data.access_token) { 157 | this.config.access_token = result.data.access_token; 158 | return result.data.access_token; 159 | } else { 160 | throw new Error('getAccessToken: get access_token failed'); 161 | } 162 | } 163 | 164 | async getEndpoint() { 165 | this.printDebug('get connect endpoint by config'); 166 | this.printDebug(this.config); 167 | const res = await axios({ 168 | url: GATEWAY_URL, 169 | method: 'POST', 170 | responseType: 'json', 171 | data: { 172 | clientId: this.config.clientId, 173 | clientSecret: this.config.clientSecret, 174 | ua: this.config.ua, 175 | subscriptions: this.config.subscriptions, 176 | }, 177 | headers: { 178 | // 这个接口得加个,否则默认返回的会是xml 179 | Accept: 'application/json' 180 | }, 181 | }); 182 | 183 | this.printDebug('res.data ' + JSON.stringify(res.data)); 184 | if (res.data) { 185 | this.config.endpoint = res.data; 186 | const { endpoint, ticket } = res.data; 187 | if (!endpoint || !ticket) { 188 | this.printDebug('endpoint or ticket is null'); 189 | throw new Error('endpoint or ticket is null'); 190 | } 191 | this.dw_url = `${endpoint}?ticket=${ticket}`; 192 | return this; 193 | } else { 194 | throw new Error('build: get endpoint failed'); 195 | } 196 | } 197 | 198 | _connect() { 199 | return new Promise((resolve, reject) => { 200 | this.userDisconnect = false; 201 | 202 | this.printDebug('Connecting to dingtalk websocket @ ' + this.dw_url); 203 | this.socket = new WebSocket(this.dw_url!, this.sslopts); 204 | 205 | // config dw connection when socket is open 206 | this.socket.on('open', () => { 207 | this.connected = true; 208 | console.info('[' + new Date().toISOString() + '] connect success'); 209 | 210 | // check if keepalive (client-side heartbeat) is enabled 211 | // if enabled, start heartbeat for ping-pong 212 | if (this.config.keepAlive) { 213 | this.isAlive = true; 214 | this.heartbeatIntervallId = setInterval(() => { 215 | // if ping-pong need to much time, longer than heartbeat, terminate socket connection 216 | if (this.isAlive === false) { 217 | console.error( 218 | 'TERMINATE SOCKET: Ping Pong does not transfer heartbeat within heartbeat intervall' 219 | ); 220 | return this.socket?.terminate(); 221 | } 222 | // if ping-pong ok, prepare next one 223 | this.isAlive = false; 224 | this.socket?.ping('', true); 225 | }, this.heartbeat_interval); 226 | } 227 | }); 228 | 229 | // wait for ping-pong with server 230 | this.socket.on('pong', () => { 231 | this.heartbeat(); 232 | }); 233 | 234 | // on receiving messages from dingtalk websocket server 235 | this.socket.on('message', (data: string) => { 236 | this.onDownStream(data); 237 | }); 238 | 239 | this.socket.on('close', (err) => { 240 | this.printDebug('Socket closed'); 241 | this.connected = false; 242 | this.registered = false; 243 | // perorm reconnection (if not canceled by user) 244 | if (this.config.autoReconnect && !this.userDisconnect) { 245 | this.reconnecting = true; 246 | this.printDebug( 247 | 'Reconnecting in ' + this.reconnectInterval / 1000 + ' seconds...' 248 | ); 249 | setTimeout(this.connect.bind(this), this.reconnectInterval); 250 | } 251 | }); 252 | 253 | // on socket errors 254 | this.socket.on('error', (err) => { 255 | this.printDebug('SOCKET ERROR'); 256 | console.warn('ERROR', err); 257 | }); 258 | 259 | resolve(); 260 | }); 261 | } 262 | 263 | async connect() { 264 | await this.getEndpoint(); 265 | await this._connect(); 266 | } 267 | 268 | disconnect() { 269 | console.info('Disconnecting.'); 270 | this.userDisconnect = true; 271 | // if client-side heartbeat is active, cancel the heartbeat intervall 272 | if (this.config.keepAlive && this.heartbeatIntervallId !== undefined) { 273 | clearInterval(this.heartbeatIntervallId!); 274 | } 275 | this.socket?.close(); 276 | } 277 | 278 | heartbeat() { 279 | this.isAlive = true; 280 | this.printDebug('CLIENT-SIDE HEARTBEAT'); 281 | } 282 | 283 | onDownStream(data: string) { 284 | this.printDebug('Received message from dingtalk websocket server'); 285 | 286 | const msg = JSON.parse(data) as DWClientDownStream; 287 | this.printDebug(msg); 288 | switch (msg.type) { 289 | case 'SYSTEM': 290 | this.onSystem(msg); 291 | break; 292 | case 'EVENT': 293 | this.onEvent(msg); 294 | break; 295 | case 'CALLBACK': 296 | // 处理回调消息 297 | this.onCallback(msg); 298 | break; 299 | } 300 | } 301 | 302 | onSystem(downstream: DWClientDownStream) { 303 | switch (downstream.headers.topic) { 304 | case 'CONNECTED': { 305 | this.printDebug('CONNECTED'); 306 | break; 307 | } 308 | case 'REGISTERED': { 309 | // this.printDebug('REGISTERED'); 310 | this.registered = true; 311 | this.reconnecting = false; 312 | break; 313 | } 314 | case 'disconnect': { 315 | this.connected = false; 316 | this.registered = false; 317 | break; 318 | } 319 | case 'KEEPALIVE': { 320 | this.heartbeat(); 321 | break; 322 | } 323 | case 'ping': { 324 | this.printDebug('PING'); 325 | this.socket?.send( 326 | JSON.stringify({ 327 | code: 200, 328 | headers: downstream.headers, 329 | message: 'OK', 330 | data: downstream.data, 331 | }) 332 | ); 333 | break; 334 | } 335 | } 336 | } 337 | 338 | onEvent(message: DWClientDownStream) { 339 | this.printDebug("received event, message=" + JSON.stringify(message)) 340 | const ackData = this.onEventReceived(message) 341 | this.socket?.send(JSON.stringify({ 342 | code: 200, 343 | headers: { 344 | contentType: "application/json", 345 | messageId: message.headers.messageId, 346 | }, 347 | message: 'OK', 348 | data: JSON.stringify(ackData) 349 | })); 350 | } 351 | 352 | onCallback(message: DWClientDownStream) { 353 | this.emit(message.headers.topic, message); 354 | } 355 | 356 | send(messageId: string, value: any) { 357 | if (!messageId) { 358 | console.error('send: messageId must be defined'); 359 | throw new Error('send: messageId must be defined'); 360 | } 361 | 362 | const msg = { 363 | code: 200, 364 | headers: { 365 | contentType: 'application/json', 366 | messageId: messageId, 367 | }, 368 | message: 'OK', 369 | data: JSON.stringify(value), 370 | }; 371 | this.socket?.send(JSON.stringify(msg)); 372 | } 373 | 374 | /** 375 | * 消息响应,避免服务端重试. 376 | * stream模式下,服务端推送消息到client后,会监听client响应,如果消息长时间未响应会在一定时间内(60s)重试推消息,可以通过此方法返回消息响应,避免多次接收服务端消息。 377 | * @param messageId 378 | * @param result 379 | * @returns 380 | * @memberof DWClient 381 | * @example 382 | * ```javascript 383 | * client.socketResponse(res.headers.messageId, result.data); 384 | * ``` 385 | */ 386 | socketCallBackResponse(messageId: string, result: any) { 387 | this.send(messageId, {response : result}); 388 | } 389 | 390 | sendGraphAPIResponse(messageId: string, value: GraphAPIResponse) { 391 | if (!messageId) { 392 | console.error('send: messageId must be defined'); 393 | throw new Error('send: messageId must be defined'); 394 | } 395 | 396 | const msg = { 397 | code: 200, 398 | headers: { 399 | contentType: 'application/json', 400 | messageId: messageId, 401 | }, 402 | message: 'OK', 403 | data: JSON.stringify(value), 404 | }; 405 | this.socket?.send(JSON.stringify(msg)); 406 | } 407 | } 408 | --------------------------------------------------------------------------------