├── 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 |
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 |
--------------------------------------------------------------------------------