├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── .releaserc.json ├── .travis.yml ├── LICENSE ├── README.md ├── cq-websocket.d.ts ├── demo ├── echobot.js └── webpack │ ├── app.js │ ├── webpack.config.js │ └── www │ ├── bundle.js │ └── index.html ├── dist ├── cq-websocket.kaomojified.js └── cq-websocket.min.js ├── docs ├── CHANGELOG-v1.md ├── CHANGELOG.md ├── CNAME ├── README.md ├── _config.yml ├── api │ ├── ArrayMessage.md │ ├── CQEvent.md │ ├── CQHTTPMessage.md │ ├── CQWebSocket.md │ ├── EventListener.md │ ├── README.md │ ├── WebSocketState.md │ ├── WebSocketType.md │ ├── errors.md │ ├── events.md │ └── messages.md └── get-started │ ├── README.md │ ├── connection.md │ ├── errors.md │ ├── example.md │ └── features.md ├── package-lock.json ├── package.json ├── performance ├── leak.test.js ├── leakage │ ├── on-off.test.js │ └── once.test.js ├── package.json └── sample.js ├── renovate.json ├── src ├── errors.js ├── event-bus.js ├── index.js ├── message │ ├── CQTag.js │ ├── index.js │ ├── isSupportedTag.js │ ├── models │ │ ├── CQAnonymous.js │ │ ├── CQAt.js │ │ ├── CQBFace.js │ │ ├── CQCustomMusic.js │ │ ├── CQDice.js │ │ ├── CQEmoji.js │ │ ├── CQFace.js │ │ ├── CQImage.js │ │ ├── CQMusic.js │ │ ├── CQRPS.js │ │ ├── CQRecord.js │ │ ├── CQSFace.js │ │ ├── CQShake.js │ │ ├── CQShare.js │ │ ├── CQText.js │ │ └── index.js │ └── parse.js └── util │ ├── callable.js │ ├── optional.js │ └── traverse.js ├── test ├── api │ └── index.test.js ├── connection │ ├── connection.test.js │ ├── error-after-success.js │ ├── failure-without-success.js │ ├── manual-reconnect-after-closed.js │ ├── manual-reconnect-after-success.js │ ├── multiple-connect-calls-before-success.js │ ├── multiple-disconnect-calls-before-disconnected.js │ ├── multiple-reconnect-calls-before-reconnected.js │ ├── success-after-failures.js │ └── success-without-failure.js ├── events │ ├── cq-event.test.js │ ├── events.js │ ├── events.test.js │ └── invalid-context.test.js ├── features │ └── auto-fetch-qq.test.js ├── fixture │ ├── CQFakeTag.js │ ├── FakeWebSocket.js │ ├── connect-success.js │ └── setup.js ├── message │ ├── CQTag.test.js │ ├── models.test.js │ ├── models │ │ ├── CQAnonymous.js │ │ ├── CQAt.js │ │ ├── CQBFace.js │ │ ├── CQCustomMusic.js │ │ ├── CQDice.js │ │ ├── CQEmoji.js │ │ ├── CQFace.js │ │ ├── CQImage.js │ │ ├── CQMusic.js │ │ ├── CQRPS.js │ │ ├── CQRecord.js │ │ ├── CQSFace.js │ │ ├── CQShake.js │ │ ├── CQShare.js │ │ └── CQText.js │ └── parse.test.js └── unit │ ├── CQWebsocket.test.js │ ├── call.test.js │ ├── connect.test.js │ ├── disconnect.test.js │ ├── isReady.test.js │ ├── isSocketConnected.test.js │ ├── off.test.js │ ├── on.test.js │ ├── once.test.js │ └── reconnect.test.js └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 4 | - https://www.buymeacoffee.com/momocow 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | [ 6 | "@semantic-release/changelog", 7 | { 8 | "changelogFile": "docs/CHANGELOG.md", 9 | "changelogTitle": "CQWebSocket" 10 | } 11 | ], 12 | "@semantic-release/npm", 13 | "@semantic-release/github", 14 | [ 15 | "@semantic-release/git", 16 | { 17 | "assets": [ 18 | "dist/**/*.js", 19 | "docs/CHANGELOG.md", 20 | "package.json", 21 | "package-lock.json", 22 | "npm-shrinkwrap.json" 23 | ] 24 | } 25 | ] 26 | ] 27 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 8 3 | 4 | script: 5 | - npm run lint 6 | - npm test 7 | - npm run build 8 | 9 | deploy: 10 | provider: script 11 | skip_cleanup: true 12 | on: 13 | branch: master 14 | script: 15 | - npm run release 16 | 17 | after_success: npm run coverage -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 牛牛/MomoCow 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **本項目即日起停止維護,隨著酷Q、CQHTTP一同走入歷史,畫下完美句點。感謝各方好友的支持與參與。** 2 | 3 | --- 4 | 5 | # node-cq-websocket 6 | [![npm](https://img.shields.io/npm/dt/cq-websocket.svg)](https://www.npmjs.com/package/cq-websocket) 7 | [![npm](https://img.shields.io/npm/v/cq-websocket.svg)](https://www.npmjs.com/package/cq-websocket) 8 | [![license](https://img.shields.io/github/license/momocow/node-cq-websocket.svg)](https://github.com/momocow/node-cq-websocket#readme) 9 | [![CQHttp](https://img.shields.io/badge/dependency-CQHttp-green.svg)](https://github.com/richardchien/coolq-http-api#readme) 10 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-ff69b4.svg)](http://commitizen.github.io/cz-cli/) 11 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 12 | [![Known Vulnerabilities](https://snyk.io//test/github/momocow/node-cq-websocket/badge.svg?targetFile=package.json)](https://snyk.io//test/github/momocow/node-cq-websocket?targetFile=package.json) 13 | 14 | ## 🚧 分支狀態 15 | - 主線 16 | - [![Build Status](https://travis-ci.org/momocow/node-cq-websocket.svg?branch=master)](https://travis-ci.org/momocow/node-cq-websocket) 17 | - [![Coverage Status](https://coveralls.io/repos/github/momocow/node-cq-websocket/badge.svg?branch=master)](https://coveralls.io/github/momocow/node-cq-websocket?branch=master) 18 | - dev 19 | - [![Build Status](https://travis-ci.org/momocow/node-cq-websocket.svg?branch=dev)](https://travis-ci.org/momocow/node-cq-websocket) 20 | - [![Coverage Status](https://coveralls.io/repos/github/momocow/node-cq-websocket/badge.svg?branch=dev)](https://coveralls.io/github/momocow/node-cq-websocket?branch=dev) 21 | 22 | ## 🗯️ 關於此 SDK 23 | 依賴 CQHTTP API 插件的 websocket 接口, 為 NodeJs 開發者提供一個搭建 QQ 聊天機器人的 SDK。 24 | 25 | 關於 CQHTTP API 插件,見 [richardchien/coolq-http-api](https://github.com/richardchien/coolq-http-api#readme) 26 | 27 | > 本 SDK 尚處於測試階段,使用上仍有機會碰到Bug,歡迎提交PR或issue回報。 28 | 29 | > 由於付費問題,本 SDK 目前僅針對酷Q Air做測試。 30 | 31 | ## 🎉 功能/特色 32 | - 輕鬆配置, 快速搭建 QQ 聊天機器人。 33 | - 自動維護底層連線, 開發者只需專注在聊天應用的開發。若斷線, 可依照配置[重新連線](docs/get-started/features.md#%E6%96%B7%E7%B7%9A%E9%87%8D%E9%80%A3)。 34 | - 支持消息監聽器內, [快速響應](docs/get-started/features.md#%E5%BF%AB%E9%80%9F%E9%9F%BF%E6%87%89)。 35 | - 連線建立時, 可[自動獲取機器人QQ號](docs/get-started/features.md#%E8%87%AA%E5%8B%95%E7%8D%B2%E5%8F%96%E6%A9%9F%E5%99%A8%E4%BA%BAqq%E8%99%9F)。 36 | 37 | ## 🗎 SDK 文件 38 | [閱讀更多 ➡️](docs/README.md) 39 | 40 | ## 🛠️ 開發者看板 41 | 本 SDK 採用 [ava](https://github.com/avajs/ava) 框架執行測試。 42 | 43 | ### 打包 CQWebSocket 至 browser 環境 44 | ``` 45 | npm run build 46 | ``` 47 | 使用 webpack 將 SDK 及所有依賴打包, 並在 `/dist`目錄下產生一個 `cq-websocket.min.js`。 48 | 49 | ### 建置 demo/webpack 50 | ``` 51 | npm run build-demo 52 | ``` 53 | 打包 `/demo/webpack/app.js` 內容, 在 `/demo/webpack/www` 目錄下產生一個 `bundle.js`。 54 | 55 | ### 開發日誌 56 | [閱讀更多 ➡️](docs/CHANGELOG.md) 57 | 58 | ### Known Issues 59 | - CQHTTP API 插件尚未支援收發 Fragmant, 暫時禁用 60 | - 自`v1.2.6` 61 | - [node-cq-websocket #2](https://github.com/momocow/node-cq-websocket/pull/2) 62 | - [coolq-http-api #85](https://github.com/richardchien/coolq-http-api/issues/85) 63 | - 在 Node 10.x 下, Buffer 寫入時的 RangeError (發生在 SDK 調用 API 方法時)。 64 | > 這是 Node 的問題, 暫時使用 Node 8.x 以下就沒問題。 65 | ``` 66 | RangeError [ERR_OUT_OF_RANGE]: The value of "value" is out of range. It must be >= 0 and <= 4294967295. Received -805456141 67 | at checkInt (internal/buffer.js:35:11) 68 | at writeU_Int32BE (internal/buffer.js:625:3) 69 | at Buffer.writeUInt32BE (internal/buffer.js:638:10) 70 | at WebSocketFrame.toBuffer (/***/node-cq-websocket/node_modules/websocket/lib/WebSocketFrame.js:257:24) 71 | at WebSocketConnection.sendFrame (/***/node-cq-websocket/node_modules/websocket/lib/WebSocketConnection.js:857:43) 72 | at WebSocketConnection.fragmentAndSend (/***/node-cq-websocket/node_modules/websocket/lib/WebSocketConnection.js:793:14) 73 | at WebSocketConnection.sendUTF (/***/node-cq-websocket/node_modules/websocket/lib/WebSocketConnection.js:733:10) 74 | at W3CWebSocket.send (/***/node-cq-websocket/node_modules/websocket/lib/W3CWebSocket.js:116:26) 75 | ``` 76 | 77 | ## 🍙 歡迎餵食 78 | 請勿拍打 🤜 無限期掙飯中 ☕ 79 | 80 | Buy Me A Coffee 81 | -------------------------------------------------------------------------------- /cq-websocket.d.ts: -------------------------------------------------------------------------------- 1 | export enum WebSocketType { 2 | API = '/api', 3 | EVENT = '/event' 4 | } 5 | export enum WebSocketState { 6 | DISABLED = -1, 7 | INIT = 0, 8 | CONNECTING = 1, 9 | CONNECTED = 2, 10 | CLOSING = 3, 11 | CLOSED = 4 12 | } 13 | export interface CQRequestOptions { 14 | timeout: number 15 | } 16 | export type WebSocketProtocol = "http:" | "https:" | "ws:" | "wss:" 17 | export interface CQWebSocketOption { 18 | accessToken: string 19 | enableAPI: boolean 20 | enableEvent: boolean 21 | protocol: WebSocketProtocol 22 | host: string 23 | port: number 24 | baseUrl: string 25 | qq: number | string 26 | reconnection: boolean 27 | reconnectionAttempts: number 28 | reconnectionDelay: number 29 | fragmentOutgoingMessages: boolean 30 | fragmentationThreshold: number 31 | tlsOptions: any 32 | requestOptions: CQRequestOptions 33 | } 34 | 35 | export type BaseEvents = 'message' 36 | | 'notice' 37 | | 'request' 38 | | 'error' 39 | | 'ready' 40 | export type MessageEvents = 'message.private' 41 | | 'message.discuss' 42 | | 'message.discuss.@' 43 | | 'message.discuss.@.me' 44 | | 'message.group' 45 | | 'message.group.@' 46 | | 'message.group.@.me' 47 | 48 | export type NoticeEvents = 'notice.group_upload' 49 | | 'notice.group_admin.set' 50 | | 'notice.group_admin.unset' 51 | | 'notice.group_decrease.leave' 52 | | 'notice.group_decrease.kick' 53 | | 'notice.group_decrease.kick_me' 54 | | 'notice.group_increase.approve' 55 | | 'notice.group_increase.invite' 56 | | 'notice.friend_add' 57 | // node 58 | | 'notice.group_admin' 59 | | 'notice.group_decrease' 60 | | 'notice.group_increase' 61 | 62 | export type RequestEvents = 'request.friend' 63 | | 'request.group.add' 64 | | 'request.group.invite' 65 | // node 66 | | 'request.group' 67 | 68 | export type MetaEvents = 'meta_event.lifecycle' 69 | | 'meta_event.heartbeat' 70 | 71 | export type SocketEvents = 'socket.connecting' 72 | | 'socket.connect' 73 | | 'socket.failed' 74 | | 'socket.reconnecting' 75 | | 'socket.reconnect' 76 | | 'socket.reconnect_failed' 77 | | 'socket.max_reconnect' 78 | | 'socket.closing' 79 | | 'socket.close' 80 | | 'socket.error' 81 | 82 | export type APIEvents = 'api.send.pre' | 'api.send.post' | 'api.response' 83 | 84 | export type Events = BaseEvents | MessageEvents | NoticeEvents | RequestEvents | SocketEvents | APIEvents 85 | 86 | export type ListenerReturn = void | Promise 87 | export type ArrayMessage = (CQTag|CQHTTPMessage|string)[] 88 | export type MessageListenerReturn = ListenerReturn | string | Promise | ArrayMessage | Promise | Promise 89 | export type MessageEventListener = (event: CQEvent, context: Record, tags: CQTag[]) => MessageListenerReturn 90 | export type ContextEventListener = (context: Record) => ListenerReturn 91 | export type SocketEventListener = (type: WebSocketType, attempts: number) => ListenerReturn 92 | export type SocketExcludeType = 'socket.connect' | 'socket.closing' | 'socket.close' | 'socket.error' 93 | 94 | export interface APITimeoutError extends Error { 95 | readonly req: APIRequest 96 | } 97 | 98 | export interface SocketError extends Error { } 99 | 100 | export interface InvalidWsTypeError extends Error { 101 | readonly which: WebSocketType 102 | } 103 | 104 | export interface InvalidContextError extends SyntaxError { 105 | readonly which: WebSocketType 106 | readonly data: string 107 | } 108 | 109 | export interface UnexpectedContextError extends Error { 110 | readonly context: Record 111 | readonly reason: string 112 | } 113 | 114 | export declare class CQEvent { 115 | readonly messageFormat: "string" | "array" 116 | stopPropagation (): void 117 | getMessage (): string | ArrayMessage 118 | setMessage (msg: string | ArrayMessage): void 119 | appendMessage (msg: string | CQTag | CQHTTPMessage): void 120 | hasMessage (): boolean 121 | onResponse (handler: (res: object) => void, options: number | CQRequestOptions): void 122 | onError (handler: (err: APITimeoutError) => void): void 123 | } 124 | 125 | export interface APIRequest { 126 | action: string, 127 | params?: any 128 | } 129 | export interface APIResponse { 130 | status: string, 131 | retcode: number, 132 | data: T 133 | } 134 | 135 | export class CQWebSocket { 136 | constructor (opt?: Partial) 137 | 138 | connect (wsType?: WebSocketType): CQWebSocket 139 | disconnect (wsType?: WebSocketType): CQWebSocket 140 | reconnect (delay?: number, wsType?: WebSocketType): CQWebSocket 141 | isSockConnected (wsType: WebSocketType): CQWebSocket 142 | isReady (): boolean 143 | 144 | on (event_type: MessageEvents | 'message', listener: MessageEventListener): CQWebSocket 145 | on ( 146 | event_type: NoticeEvents | RequestEvents | MetaEvents | 'notice' | 'request' | 'meta_event', 147 | listener: ContextEventListener 148 | ): CQWebSocket 149 | on (event_type: Exclude, listener: SocketEventListener): CQWebSocket 150 | on (event_type: 'socket.connect', listener: (type: WebSocketType, socket: any, attempts: number) => void): CQWebSocket 151 | on (event_type: 'socket.closing', listener: (type: WebSocketType) => void): CQWebSocket 152 | on (event_type: 'socket.close', listener: (type: WebSocketType, code: number, desc: string) => void): CQWebSocket 153 | on (event_type: 'socket.error', listener: (type: WebSocketType, err: SocketError) => void): CQWebSocket 154 | on (event_type: 'api.send.pre', listener: (apiRequest: APIRequest) => void): CQWebSocket 155 | on (event_type: 'api.send.post', listener: () => void): CQWebSocket 156 | on (event_type: 'api.response', listener: (result: APIResponse) => void): CQWebSocket 157 | on (event_type: 'error', listener: (err: InvalidContextError | UnexpectedContextError) => void): CQWebSocket 158 | on (event_type: 'ready', listener: () => void): CQWebSocket 159 | 160 | once (event_type: MessageEvents | 'message', listener: MessageEventListener): CQWebSocket 161 | once ( 162 | event_type: NoticeEvents | RequestEvents | MetaEvents | 'notice' | 'request' | 'meta_event', 163 | listener: ContextEventListener 164 | ): CQWebSocket 165 | once (event_type: Exclude, listener: SocketEventListener): CQWebSocket 166 | once (event_type: 'socket.connect', listener: (type: WebSocketType, socket: any, attempts: number) => void): CQWebSocket 167 | once (event_type: 'socket.closing', listener: (type: WebSocketType) => void): CQWebSocket 168 | once (event_type: 'socket.close', listener: (type: WebSocketType, code: number, desc: string) => void): CQWebSocket 169 | once (event_type: 'socket.error', listener: (type: WebSocketType, err: Error) => void): CQWebSocket 170 | once (event_type: 'api.send.pre', listener: (apiRequest: APIRequest) => void): CQWebSocket 171 | once (event_type: 'api.send.post', listener: () => void): CQWebSocket 172 | once (event_type: 'api.response', listener: (result: APIResponse) => void): CQWebSocket 173 | once (event_type: 'error', listener: (err: Error) => void): CQWebSocket 174 | once (event_type: 'ready', listener: () => void): CQWebSocket 175 | 176 | off (event_type?: Events, listener?: Function): CQWebSocket 177 | } 178 | export interface CQWebSocket { 179 | (method: string, params?: Record, options?: number | CQRequestOptions): Promise> 180 | } 181 | 182 | export default CQWebSocket 183 | 184 | /******************************************/ 185 | 186 | export type Serializable = string | number | boolean 187 | 188 | export interface CQHTTPMessage { 189 | type: string 190 | data: Record | null 191 | } 192 | 193 | export declare class CQTag { 194 | readonly tagName: string 195 | readonly data: Readonly> 196 | modifier: Record 197 | 198 | equals(another: CQTag): boolean 199 | coerce(): this 200 | toJSON(): CQHTTPMessage 201 | valueOf(): string 202 | toString(): string 203 | } 204 | 205 | export class CQAt extends CQTag { 206 | readonly qq: number 207 | constructor(qq: number) 208 | } 209 | 210 | export class CQAnonymous extends CQTag { 211 | ignore: boolean 212 | constructor(shouldIgnoreIfFailed?: boolean) 213 | } 214 | 215 | export class CQBFace extends CQTag { 216 | readonly id: number 217 | 218 | /** 219 | * To send a bface, not only `id` but also `p`, 220 | * which is the name of child directory of `data/bface`, 221 | * is required. 222 | * @see https://github.com/richardchien/coolq-http-api/wiki/CQ-%E7%A0%81%E7%9A%84%E5%9D%91 223 | */ 224 | constructor (id: number, p: string) 225 | } 226 | 227 | export class CQCustomMusic extends CQTag { 228 | readonly url: string 229 | readonly audio: string 230 | readonly title: string 231 | readonly content?: string 232 | readonly image?: string 233 | readonly type: "custom" 234 | constructor(url: string, audio: string, title: string, content?: string, image?: string) 235 | } 236 | 237 | export class CQDice extends CQTag { 238 | readonly type: number 239 | constructor() 240 | } 241 | 242 | export class CQEmoji extends CQTag { 243 | readonly id: number 244 | constructor(id: number) 245 | } 246 | 247 | export class CQFace extends CQTag { 248 | readonly id: number 249 | constructor(id: number) 250 | } 251 | 252 | export class CQImage extends CQTag { 253 | readonly file: string 254 | readonly url?: string 255 | cache?: boolean 256 | constructor(file: string, cache?: boolean) 257 | } 258 | 259 | export class CQMusic extends CQTag { 260 | readonly type: string 261 | readonly id: number 262 | constructor(type: string, id: number) 263 | } 264 | 265 | export class CQRecord extends CQTag { 266 | readonly file: string 267 | magic?: true 268 | constructor(file: string, magic?: boolean) 269 | hasMagic(): boolean 270 | } 271 | 272 | export class CQRPS extends CQTag { 273 | readonly type: number 274 | constructor() 275 | } 276 | 277 | export class CQSFace extends CQTag { 278 | readonly id: number 279 | constructor(id: number) 280 | } 281 | 282 | export class CQShake extends CQTag { 283 | constructor() 284 | } 285 | 286 | export class CQShare extends CQTag { 287 | readonly url: string 288 | readonly title: string 289 | readonly content?: string 290 | readonly image?: string 291 | constructor(url: string, title: string, content?: string, image?: string) 292 | } 293 | 294 | export class CQText extends CQTag { 295 | readonly text: string 296 | constructor(text: string) 297 | } 298 | -------------------------------------------------------------------------------- /demo/echobot.js: -------------------------------------------------------------------------------- 1 | ///******************************/ 2 | ///* 這是一台最基礎的複讀機 */ 3 | ///******************************/ 4 | 5 | const mri = require('mri') 6 | const options = mri(process.argv.slice(2), { 7 | alias: { 8 | help: 'h' 9 | }, 10 | boolean: [ 'help' ] 11 | }) 12 | 13 | if (options.help) { 14 | console.log('\nUsage: npm run demo-echobot -- [options]\n') 15 | console.log('Options:') 16 | console.log(' -h,--help Show usage\n') 17 | console.log(' --host CQHttp ws server host') 18 | console.log(' --port CQHttp ws server port') 19 | console.log(' --url CQHttp ws server base URL') 20 | console.log(' --token CQHttp ws server access token') 21 | console.log(' --qq QQ account of the bot, used to determine whether someone "@" the bot or not') 22 | } else { 23 | const CQWebsocket = require('../') 24 | let bot = new CQWebsocket({ 25 | host: options.host, 26 | port: options.port, 27 | baseUrl: options.url, 28 | qq: options.qq, 29 | accessToken: options.token 30 | }) 31 | 32 | // 設定訊息監聽 33 | bot 34 | // 連線例外處理 35 | .on('socket.error', console.error) 36 | .on('socket.connecting', (wsType) => console.log('[%s] 建立連線中, 請稍後...', wsType)) 37 | .on('socket.connect', (wsType, sock, attempts) => console.log('[%s] 連線成功 ヽ(✿゚▽゚)ノ 蛆蛆%d個嘗試', wsType, attempts)) 38 | .on('socket.failed', (wsType, attempts) => console.log('[%s] 連線失敗 。・゚・(つд`゚)・゚・ [丑%d] 對噗起', wsType, attempts)) 39 | .on('api.response', (resObj) => console.log('伺服器響應: %O', resObj)) 40 | .on('socket.close', (wsType, code, desc) => console.log('[%s] 連線關閉(%d: %s)', wsType, code, desc)) 41 | .on('ready', () => console.log('今天又是複讀複讀的一天 。:.゚ヽ(*´∀`)ノ゚.:。')) 42 | 43 | // 聽取私人信息 44 | .on('message.private', (e, context) => { 45 | console.log('叮咚 ✿') 46 | console.log(context) 47 | 48 | // 以下提供三種方式將原訊息以原路送回 49 | switch (Date.now() % 3) { 50 | case 0: 51 | // 1. 調用 CoolQ HTTP API 之 send_msg 方法 52 | bot('send_msg', context) 53 | break 54 | case 1: 55 | // 2. 或者透過返回值快速響應 56 | return context.message 57 | case 2: 58 | // 3. 或者透過CQEvent實例,先獲取事件處理權再設置響應訊息 59 | e.stopPropagation() 60 | e.setMessage(context.message) 61 | } 62 | }) 63 | 64 | bot.connect() 65 | } 66 | -------------------------------------------------------------------------------- /demo/webpack/app.js: -------------------------------------------------------------------------------- 1 | const { CQWebSocket } = require('../..') 2 | 3 | const qs = new URLSearchParams(window.location.search.substr(1)) 4 | 5 | const bot = new CQWebSocket({ 6 | host: qs.get('host') || undefined, 7 | port: qs.get('port') || undefined, 8 | baseUrl: qs.get('url') || undefined, 9 | qq: qs.get('qq') || undefined 10 | }) 11 | 12 | bot.on('message', function (e, { raw_message: rawMessage }) { 13 | document.getElementById('messages').appendChild(document.createElement('div')).innerHTML = ` 14 | ${new Date().toLocaleString()}${rawMessage} 15 | ` 16 | }) 17 | 18 | bot.connect() 19 | -------------------------------------------------------------------------------- /demo/webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: path.join(__dirname, 'app.js'), 6 | output: { 7 | path: path.join(__dirname, 'www'), 8 | filename: 'bundle.js' 9 | } 10 | } -------------------------------------------------------------------------------- /demo/webpack/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CQWebSocket Demo 5 | 6 | 7 | 8 | 9 |

Message Box

10 |
11 | 12 | -------------------------------------------------------------------------------- /docs/CHANGELOG-v1.md: -------------------------------------------------------------------------------- 1 | # 開發日誌 2 | 列為`棄用`表示**仍然支援**, 但請盡速修正為最新版本的實作。 3 | 4 | ## v1.6.1 5 | - 修正 6 | - `message` 事件監聽器返回值的類型聲明。[#25](https://github.com/momocow/node-cq-websocket/issues/25) [#26](https://github.com/momocow/node-cq-websocket/issues/26) [#27](https://github.com/momocow/node-cq-websocket/issues/27) 7 | - API 響應文本的類型聲明,包含 `api.response` 事件的第一個參數及 callable 的返回值。 [#27](https://github.com/momocow/node-cq-websocket/issues/27) 8 | 9 | ## v1.6.0 10 | - 新增 11 | - 類型聲明, 支持 Typescript。[#18](https://github.com/momocow/node-cq-websocket/issues/18) [#20](https://github.com/momocow/node-cq-websocket/issues/20) 12 | - 默認 API 導出 (default export)。[#21](https://github.com/momocow/node-cq-websocket/issues/21) 13 | 14 | ## v1.5.0 15 | - 新增 16 | - 支持在 browser 環境運行。(須使用 browserify 或 webpack 等工具先行打包, 可見 [/demo/webpack 示例](../demo/webpack))) 17 | - 本倉庫 dist/ 目錄下已經打包了一個 cq-websocket.min.js 可直接在 web 引用, 並透過 `window.CQWebSocket` 變數使用本 SDK。 18 | - [`message` 事件快速響應](../README.md#事件傳播)的新機制: 為了追蹤快速響應的結果(成功或失敗), 監聽器一旦判定該訊息須由它來進行回應, 則須先調用 CQEvent `#stopPropagation()` (原 `#cancel()`) 獲取響應的處理權, 同監聽器內還可透過 CQEvent `#onResponse()` 設置結果監聽器, 並透過 CQEvent `#onError()` 處理響應的錯誤。若沒有 CQEvent `#onError()` 進行錯誤處理, 則會觸發 [`error` 事件](../README.md#基本事件)。 19 | - CQEvent `#appendMessage()` 20 | - [自動獲取機器人QQ號](../README.md#自動獲取機器人qq號): 建立連線時, 若有啟用 API 連線且未配置QQ號, 則自動透過API連線獲取。 21 | - `message.discuss.@`, `message.group.@` 兩個事件。可參考文件在 [message 子事件](../README.md#message-子事件) 及 [CQTag 類別](../README.md#cqtag-類別)的章節 22 | - `CQWebSocket` 建構子新增 [`requestOptions` 選項](../README.md#new-cqwebsocketopt), 該選項下目前只有一個 `timeout` 字段, 調用 API 方法時作為全局默認 timeout 選項。 23 | 24 | - 變更 25 | - [api 子事件](../README.md#api-子事件) 移除監聽器中原第一個參數 WebsocketType。 26 | - 直接對 CQWebSocket 實例進行[方法調用](../README.md#方法調用)之返回值, 由 `this` 變更為 `Promise`, 用以追蹤方法調用的結果。 27 | 28 | - 棄用 29 | - CQEvent `#cancel()` => [`#stopPropagation()`](#cqevent-stoppropagation)) 30 | - CQEvent `#isCanceled()` (禁用, 無替代) 31 | - ~~`message.discuss.@me`~~ 和 ~~`message.group.@me`~~ 事件, 將更名為 `message..@.me`事件。請見 [message 子事件](../README.md#message-子事件)文件。 32 | 33 | ## v1.4.2 34 | - 新增 35 | - 默認 `socket.error` 監聽器將會由 stderr 輸出警告訊息。[#4](https://github.com/momocow/node-cq-websocket/issues/4) 36 | - 內部狀態控管, 加強連線管理。[#5](https://github.com/momocow/node-cq-websocket/issues/5) 37 | - `socket.reconnecting`, `socket.reconnect`, `socket.reconnect_failed` 及 `socket.max_reconnect` 事件。(參見 [socket 子事件](../README.md#socket-子事件)) 38 | - CQWebSocket 建構時的選項增加 `baseUrl` 一項, 為某些如反向代理之網路環境提供較彈性的設定方式。 39 | - 變更 40 | - `ready` 事件不再針對個別連線(`/api`, `/event`)進行上報, 改為在**所有已啟用**之連線準備就緒後, 一次性發布。若需要掌握個別連線, 請利用 `socket.connect` 事件。 41 | - 修正 42 | - 事件名稱錯誤。(`closing` => `socket.closing`, `connecting` => `socket.connecting`) 43 | 44 | ## v1.4.0 45 | 增強對連線的管理與維護, 斷線後自動嘗試重新連線。 46 | - 新增 47 | - [`off()` 方法](../README.md#cqwebsocket-offevent_type-listener)以移除指定監聽器。 48 | - [reconnect() 方法](../README.md#cqwebsocket-reconnectdelay-wstype)以重新建立連線。 49 | - [isSockConnected() 方法](../README.md#cqwebsocket-issockconnectedwstype)檢測 socket 是否正在連線。 50 | - `socket.connecting`, `socket.failed` 及 `socket.closing` 事件(參見 [socket 子事件](../README.md#socket-子事件))。 51 | - 變更 52 | - [`connect()`](../README.md#cqwebsocket-connectwstype), [`disconnect()`](../README.md#cqwebsocket-disconnectwstype), [`reconnect()`](../README.md#cqwebsocket-reconnectdelay-wstype) 三個方法增加參數 `wsType` 以指定目標連線, 若 `wsType` 為 undefined 指涉全部連線。 53 | - [CQWebSocket 建構子](../README.md#new-cqwebsocketopt)增加額外3個設定, `reconnection`, `reconnectionAttempts` 及 `reconnectionDelay`, 提供連線失敗時自動重連之功能。 54 | - 修正 55 | - [`once()` 方法](../README.md#cqwebsocket-onceevent_type-listener)執行後無法正確移除監聽器之問題。 56 | - 棄用 57 | - `isConnected()` 方法 58 | > 重新命名為 [`isReady()`](../README.md#cqwebsocket-isready)。 59 | 60 | ## v1.3.0 61 | 兼容 CoolQ HTTP API v3.x 及 v4.x 兩個主版本。 62 | - 新增 63 | - 給予 CoolQ HTTP API v4.x 之上報事件 `notice` 實作更多[子事件](../README.md#notice-子事件)。(群文件上傳, 群管變動, 群成員增減, 好友添加) 64 | - 給予上報事件 `request` 實作更多[子事件](../README.md#request-子事件)。(好友請求, 群請求/群邀請) 65 | - 棄用 66 | - 上報事件: `event` -> 請改用 `notice`事件。 67 | ## v1.2.6 68 | - 變更 69 | - 禁用 websocket fragment, 待 CoolQ HTTP API 修正問題時再次啟用。 70 | > 於此帖追蹤進度。[coolq-http-api #85](https://github.com/richardchien/coolq-http-api/issues/85) -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CQWebSocket 2 | 3 | ## [2.1.1](https://github.com/momocow/node-cq-websocket/compare/v2.1.0...v2.1.1) (2020-03-23) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * Fix typing ([#87](https://github.com/momocow/node-cq-websocket/issues/87)) and upgrade dependencies. ([bbb54ad](https://github.com/momocow/node-cq-websocket/commit/bbb54adc15a1e59d963f77e0111a56c0227473a3)) 9 | 10 | # [2.1.0](https://github.com/momocow/node-cq-websocket/compare/v2.0.2...v2.1.0) (2020-01-31) 11 | 12 | 13 | ### Features 14 | 15 | * add group_ban event ([928e257](https://github.com/momocow/node-cq-websocket/commit/928e257)), closes [#84](https://github.com/momocow/node-cq-websocket/issues/84) 16 | 17 | ## [2.0.2](https://github.com/momocow/node-cq-websocket/compare/v2.0.1...v2.0.2) (2019-07-23) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **types:** Fix definitions of meta events. ([ca1a731](https://github.com/momocow/node-cq-websocket/commit/ca1a731)), closes [#77](https://github.com/momocow/node-cq-websocket/issues/77) 23 | 24 | ## [2.0.1](https://github.com/momocow/node-cq-websocket/compare/v2.0.0...v2.0.1) (2019-04-21) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **pack:** Fix missing .d.ts in the NPM package. ([a6bf75b](https://github.com/momocow/node-cq-websocket/commit/a6bf75b)) 30 | 31 | # [2.0.0](https://github.com/momocow/node-cq-websocke/compare/v1.8.1...v2.0.0) (2018-11-02) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * fix CQTag #equals() performs not as expected ([0d1bbbd](https://github.com/momocow/node-cq-websocke/commit/0d1bbbd)), closes [#17](https://github.com/momocow/node-cq-websocke/issues/17) [#34](https://github.com/momocow/node-cq-websocke/issues/34) 37 | * fix error JSON representative of CQText ([732522d](https://github.com/momocow/node-cq-websocke/commit/732522d)) 38 | * fix string messages without CQTags are not parsed as a CQText instance. ([0f345cf](https://github.com/momocow/node-cq-websocke/commit/0f345cf)), closes [#17](https://github.com/momocow/node-cq-websocke/issues/17) 39 | * replace CQEvent #cancel() with #stopPropagation() ([1ad3408](https://github.com/momocow/node-cq-websocke/commit/1ad3408)), closes [#29](https://github.com/momocow/node-cq-websocke/issues/29) 40 | 41 | 42 | ### Code Refactoring 43 | 44 | * expose CQWebSocket as default export ([f36e864](https://github.com/momocow/node-cq-websocke/commit/f36e864)), closes [#21](https://github.com/momocow/node-cq-websocke/issues/21) [#23](https://github.com/momocow/node-cq-websocke/issues/23) 45 | * remove "event" event in favour of "notice" event ([405b48c](https://github.com/momocow/node-cq-websocke/commit/405b48c)), closes [#29](https://github.com/momocow/node-cq-websocke/issues/29) 46 | * remove CQEvent instance method "cancel()" ([c8923d8](https://github.com/momocow/node-cq-websocke/commit/c8923d8)), closes [#29](https://github.com/momocow/node-cq-websocke/issues/29) 47 | * remove CQWebSocket instance method "isConnected()" ([c38b1cf](https://github.com/momocow/node-cq-websocke/commit/c38b1cf)), closes [#29](https://github.com/momocow/node-cq-websocke/issues/29) 48 | * rename constructor option "access_token" to "accessToken" ([6caedb6](https://github.com/momocow/node-cq-websocke/commit/6caedb6)), closes [#29](https://github.com/momocow/node-cq-websocke/issues/29) 49 | * use CQWebSocket instead of CQWebsocket for public API ([0b81b53](https://github.com/momocow/node-cq-websocke/commit/0b81b53)), closes [#22](https://github.com/momocow/node-cq-websocke/issues/22) 50 | * **browser:** expose the API under global variable "CQWebSocketSDK". ([db37019](https://github.com/momocow/node-cq-websocke/commit/db37019)), closes [#23](https://github.com/momocow/node-cq-websocke/issues/23) 51 | 52 | 53 | ### Features 54 | 55 | * add CQ tags and add the 3rd parameter of message events to be a list of parsed tags ([06e38a7](https://github.com/momocow/node-cq-websocke/commit/06e38a7)), closes [#17](https://github.com/momocow/node-cq-websocke/issues/17) [#34](https://github.com/momocow/node-cq-websocke/issues/34) 56 | * CQ tags parsing now is based on "message" field instead of "raw_message" field ([e6b4992](https://github.com/momocow/node-cq-websocke/commit/e6b4992)) 57 | * support mixing string, CQTag and CQHTTPMessage in array-type messages. ([777281e](https://github.com/momocow/node-cq-websocke/commit/777281e)) 58 | * support to append string when the CQEvent message is in array type ([b3091da](https://github.com/momocow/node-cq-websocke/commit/b3091da)) 59 | 60 | 61 | ### Tests 62 | 63 | * remove tests for event "message.discuss.[@me](https://github.com/me)" and "message.group.[@me](https://github.com/me)" ([0698300](https://github.com/momocow/node-cq-websocke/commit/0698300)), closes [#29](https://github.com/momocow/node-cq-websocke/issues/29) 64 | 65 | 66 | ### BREAKING CHANGES 67 | 68 | * **browser:** global variable "CQWebSocketSDK" will retrieve the same structure of API as 69 | require('cq-websocket') 70 | * all message events now receive a list of parsed tags as the 3rd parameter 71 | * Option renaming from "access_token" to "accessToken" 72 | * No more CQWebsocket => use CQWebSocket 73 | * CQWebSocket is exposed as default export and a named export, "CQWebSocket". 74 | * Use "message.discuss.@.me" instead of "message.discuss.@me" and 75 | "message.group.@.me" instead of "message.group.@me" 76 | * Use CQEvent #stopPropagation() instead of #cancel(). 77 | * Use CQWebSocket #isReady() instead of #isConnected() 78 | * No longer provide support for CQHTTP v3.x 79 | 80 | ## [1.8.1](https://github.com/momocow/node-cq-websocket/compare/v1.8.0...v1.8.1) (2018-10-24) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * do not include version in filename of browser bundle ([374f378](https://github.com/momocow/node-cq-websocket/commit/374f378)), closes [#36](https://github.com/momocow/node-cq-websocket/issues/36) 86 | 87 | # [1.8.0](https://github.com/momocow/node-cq-websocket/compare/v1.7.0...v1.8.0) (2018-10-22) 88 | 89 | 90 | ### Features 91 | 92 | * add meta events: lifecycle and heartbeat. ([bd8b9f9](https://github.com/momocow/node-cq-websocket/commit/bd8b9f9)), closes [#35](https://github.com/momocow/node-cq-websocket/issues/35) 93 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | cq-websocket.js.org -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | > **本項目即日起停止維護,隨著酷Q、CQHTTP一同走入歷史,畫下完美句點。感謝各方好友的支持與參與。** 2 | 3 | --- 4 | 5 | # CQWebSocket SDK 說明文件 6 | 7 | ## 使用方式 8 | ### CDN 9 | 10 | 如果你在網頁前端上使用,可以通過 CDN 引入。 11 | 12 | - 最新版 13 | ```html 14 | 15 | ``` 16 | 17 | - 指定版本 (以 `v2.0.0` 為例, 可依照實際需求版本自行替換版號) 18 | > CDN 引入方式僅提供 v1.8.1 以上的版本使用 19 | ```html 20 | 21 | ``` 22 | 23 | 在你的 js 代碼中, 使用全局變數 `CQWebSocketSDK` 獲取 SDK。 24 | 25 | ```js 26 | // 全局變數 CQWebSocketSDK 存在於 window 對象下 27 | const { CQWebSocket } = window.CQWebSocketSDK 28 | const bot = new CQWebSocket() 29 | ``` 30 | 31 | ### NPM 32 | 33 | 如果你使用打包工具(如 webpack, browserify...)或 NodeJS,可以通過 NPM 安裝。 34 | 35 | ``` 36 | npm install cq-websocket 37 | ``` 38 | 39 | 將 SDK 導入代碼 40 | ```js 41 | const { CQWebSocket } = require('cq-websocket') 42 | ``` 43 | 44 | 或是使用 ES6 import 45 | ```js 46 | import { CQWebSocket } from 'cq-websocket' 47 | ``` 48 | 49 | ## 快速開始 50 | [閱讀更多 ➡️](get-started/README.md) 51 | 52 | ## API 文件 53 | [閱讀更多 ➡️](api/README.md) -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-architect -------------------------------------------------------------------------------- /docs/api/ArrayMessage.md: -------------------------------------------------------------------------------- 1 | # ArrayMessage 2 | ```ts 3 | type ArrayMessage = (CQTag | CQHTTPMessage | string)[] 4 | ``` 5 | 6 | - `CQTag` 見 [CQTag](message/README.md#CQTag)。 7 | - `CQHTTPMessage` 見 [CQHTTPMessage](CQHTTPMessage.md)。 8 | 9 | 此結構可作為**快速響應**或是 **API 調用發送訊息**之消息格式。 10 | 11 | 可見 CQHTTP API 之[消息格式](https://cqhttp.cc/docs/#/Message)說明。 12 | -------------------------------------------------------------------------------- /docs/api/CQEvent.md: -------------------------------------------------------------------------------- 1 | # CQEvents 2 | 3 | - [CQEvents](#cqevents) 4 | - [stopPropagation](#stoppropagation) 5 | - [messageFormat](#messageformat) 6 | - [getMessage](#getmessage) 7 | - [setMessage](#setmessage) 8 | - [appendMessage](#appendmessage) 9 | - [hasMessage](#hasmessage) 10 | - [onResponse](#onresponse) 11 | - [onError](#onerror) 12 | 13 | 此類別無法自行創建實例。 14 | 此類別的實例於 `message` 事件監聽器,作為第一個參數傳入。 15 | 16 | ## stopPropagation 17 | ```js 18 | e.stopPropagation() 19 | ``` 20 | - 返回值: `void` 21 | 22 | 23 | 截獲事件並停止[事件傳播](#事件傳播)。 24 | 25 | ## messageFormat 26 | ```js 27 | e.messageFormat 28 | ``` 29 | - `"array"` | `"string"` 30 | 31 | messageFormat 字段下可以知道當前響應訊息的型態為 "string" 或 "array"。 32 | 33 | 可參考[CQHTTP API 消息格式](https://cqhttp.cc/docs/#/Message)。 34 | 35 | ## getMessage 36 | ```js 37 | e.getMessage() 38 | ``` 39 | - 返回值: `string` | [`ArrayMessage`](ArrayMessage.md) 40 | 41 | 取得目前的響應訊息。 42 | 43 | ## setMessage 44 | ```js 45 | e.setMessage(msg) 46 | ``` 47 | - `msg` string | [`ArrayMessage`](ArrayMessage.md) 48 | - 返回值: `void` 49 | 50 | 設置響應訊息。 51 | 52 | ## appendMessage 53 | ```js 54 | e.appendMessage(msg) 55 | ``` 56 | - `msg` string | [CQTag](message/README.md) | [CQHTTPMessage](CQHTTPMessage.md) 57 | - 返回值: `void` 58 | 59 | 串接響應訊息。 60 | 61 | ## hasMessage 62 | ```js 63 | e.hasMessage() 64 | ``` 65 | 66 | - 返回值: `boolean` 67 | 68 | 是否有響應訊息。 69 | 70 | ## onResponse 71 | ```js 72 | e.onResponse(handler[, options]) 73 | e.onResponse(options) 74 | ``` 75 | - `handler` (res: ResObj) => void 76 | - `options` object 同[API 調用](CQWebSocket.md#api-call)之 options 77 | 78 | 設置響應結果的處理器, 用以追蹤訊息是否傳送成功。 79 | 80 | `ResObj` 對象, 此為 CQHttp API 的[回應對象](https://cqhttp.cc/docs/#/WebSocketAPI?id=api-%E6%8E%A5%E5%8F%A3)。 81 | 82 | ## onError 83 | ```js 84 | e.onError(handler) 85 | ``` 86 | - `handler` (err: ApiTimeoutError) => void 87 | 88 | 設置錯誤處理器, 可能的錯誤已知有響應超時。 -------------------------------------------------------------------------------- /docs/api/CQHTTPMessage.md: -------------------------------------------------------------------------------- 1 | # CQHTTPMessage 2 | ```ts 3 | interface CQHTTPMessage { 4 | type: string 5 | data: null | { 6 | [key: string]: string 7 | } 8 | } 9 | ``` 10 | 11 | 見 CQHTTP API 之[消息段](https://cqhttp.cc/docs/#/Message?id=%E6%B6%88%E6%81%AF%E6%AE%B5%EF%BC%88%E5%B9%BF%E4%B9%89-cq-%E7%A0%81%EF%BC%89)說明。 12 | -------------------------------------------------------------------------------- /docs/api/CQWebSocket.md: -------------------------------------------------------------------------------- 1 | # CQWebSocket 2 | 3 | - [CQWebSocket](#cqwebsocket) 4 | - [constructor](#constructor) 5 | - [CQWebSocketOption](#cqwebsocketoption) 6 | - [connect()](#connect) 7 | - [disconnect](#disconnect) 8 | - [reconnect](#reconnect) 9 | - [isSockConnected](#issockconnected) 10 | - [isReady](#isready) 11 | - [on](#on) 12 | - [once](#once) 13 | - [off](#off) 14 | - [API call](#api-call) 15 | - [範例](#%E7%AF%84%E4%BE%8B) 16 | 17 | ## constructor 18 | ```js 19 | new CQWebSocket(opt) 20 | ``` 21 | 22 | - `opt` [CQWebSocketOption](#cqwebsocketoption) 23 | 24 | ### CQWebSocketOption 25 | | 屬性 | 類型 | 默認值 | 說明 26 | | - | - | - | - | 27 | | `accessToken` | string | `""` | API 訪問 token 。見 CQHTTP API 之[配置文件說明](https://cqhttp.cc/docs/#/Configuration) | 28 | | `enableAPI` | boolean | `true` | 啟用 /api 連線 | 29 | | `enableEvent` | boolean | `true` | 啟用 /event 連線 | 30 | | `protocol` | string | `"ws:"` | 協議名 | 31 | | `host` | string | `"127.0.0.1"` | 酷Q伺服器 IP | 32 | | `port` | number | 6700 | 酷Q伺服器端口 | 33 | | `baseUrl` | string | 6700 | 酷Q伺服器位址 (SDK在建立連線時會依照此設定加上前綴項 `ws://` 及後綴項 `/[?accessToken={token}]`) | 34 | | `qq` | number | string | -1 | 觸發 `@me` 事件用的QQ帳號,通常同登入酷Q之帳號,用在討論組消息及群消息中辨認是否有人at此帳號 | 35 | | `reconnection` | boolean | true | 是否連線錯誤時自動重連 | 36 | | `reconnectionAttempts` | number | Infinity | **連續**連線失敗的次數不超過這個值 | 37 | | `reconnectionDelay` | number | 1000 | 重複連線的延遲時間, 單位: ms | 38 | | `fragmentOutgoingMessages` | boolean | false | 由於 CQHTTP API 插件的 websocket 服務器尚未支持 fragment, 故建議維持 `false` 禁用 fragment。
※詳情請見 [WebSocketClient 選項說明](https://github.com/theturtle32/WebSocket-Node/blob/master/docs/WebSocketClient.md#client-config-options)。 | 39 | | `fragmentationThreshold` | number | 0x4000 | 每個 frame 的最大容量, 默認為 16 KiB, 單位: byte
※詳情請見 [WebSocketClient 選項說明](https://github.com/theturtle32/WebSocket-Node/blob/master/docs/WebSocketClient.md#client-config-options)。 | 40 | | `tlsOptions` | object | {} | 若需調用安全連線 [https.request](https://nodejs.org/api/https.html#https_https_request_options_callback) 時的選項 | 41 | | `requestOptions` | {
`timeout`: number
} | {} | 調用 API 方法時的全局默認選項。 | 42 | 43 | ## connect() 44 | ```js 45 | bot.connect([socketType]) 46 | ``` 47 | 48 | - `socketType` [WebSocketType](WebSocketType.md) 未提供此項,則默認所有連線。 49 | - 返回值: `this` 50 | - 事件 51 | - `ready` 所有 socket 就緒。 52 | - `socket.connecting` 呼叫後立刻觸發,在任何連線嘗試之前。 53 | - `socket.connect` 連線成功。 54 | - `socket.failed` 連線失敗。 55 | - `socket.error` 連線失敗會一併觸發 error 事件。 56 | 57 | ## disconnect 58 | ```js 59 | bot.disconnect([socketType]) 60 | ``` 61 | 62 | - `socketType` [WebSocketType](WebSocketType.md) 未提供此項,則默認所有連線。 63 | - 返回值: `this` 64 | - 事件 65 | - `socket.closing` 正在關閉連線。 66 | - `socket.close` 連線斷開後。 67 | 68 | ## reconnect 69 | ```js 70 | bot.reconnect([delay[, socketType]]) 71 | ``` 72 | 73 | - `delay` number 單位為 ms,表示`socket.close`**事件觸發後的延遲時間**, 延遲時間過後才會呼叫 connect()。 74 | - `socketType` [WebSocketType](WebSocketType.md) 未提供此項,則默認所有連線。 75 | - 返回值: `this` 76 | - 事件 77 | > 此方法會先呼叫 disconnect() 等待 `socket.close` 事件觸發後再呼叫 connect(), 可以參考以上兩個方法的事件。 78 | 79 | ## isSockConnected 80 | ```js 81 | bot.isSockConnected(socketType) 82 | ``` 83 | 84 | - `socketType` [WebSocketType](WebSocketType.md) 85 | - 返回值: `boolean` 86 | 87 | > ※若未給定 `socketType`,使用此方法會**拋出錯誤**。 88 | 89 | ## isReady 90 | ```js 91 | bot.isReady() 92 | ``` 93 | 94 | - 返回值: `boolean` 95 | 96 | 檢查連線狀態是否就緒。 97 | 98 | > 僅檢查已透過 `enableAPI` 及 `enableEvent` 啟用之連線。 99 | 100 | ## on 101 | ```js 102 | bot.on(event, listener) 103 | ``` 104 | 105 | - `event` string 106 | - `listener` [EventListener](EventListener.md) 107 | - 返回值: `this` 108 | 109 | 註冊常駐監聽器。 110 | 111 | ## once 112 | ```js 113 | bot.once(event, listener) 114 | ``` 115 | 116 | - `event` string 117 | - `listener` [EventListener](EventListener.md#OnceListener) 118 | - 返回值: `this` 119 | 120 | 註冊一次性監聽器。 121 | 122 | ## off 123 | ```js 124 | bot.off([event[, listener]]) 125 | ``` 126 | 127 | - `event` string 128 | - `listener` [EventListener](EventListener.md) 129 | - 返回值: `this` 130 | 131 | 移除 `event` 事件中的 `listener` 監聽器。 132 | 若 `event` 不為字串,則移除所有監聽器。 133 | 若 `listener` 不為方法,則移除所有該事件的監聽器。 134 | 135 | ## API call 136 | ```js 137 | bot(method[, params[, options]]) 138 | ``` 139 | - `method` string 見 [API 列表](https://cqhttp.cc/docs/#/API?id=api-%E5%88%97%E8%A1%A8) 140 | - `params` object 見 [API 列表](https://cqhttp.cc/docs/#/API?id=api-%E5%88%97%E8%A1%A8) 141 | - `options` object | number 142 | - `timeout` number (默認: `Infinity`) 143 | - 返回值: `Promise` 144 | 145 | 返回值為一個 Promise 對象, 用作追蹤該次 API 調用的結果。 146 | 147 | Promise 對象實現後第一個參數會拿到 `ResObj` 對象, 此為 CQHttp API 的[回應對象](https://cqhttp.cc/docs/#/WebSocketAPI?id=api-%E6%8E%A5%E5%8F%A3)。 148 | 149 | 若有配置 `timeout` 選項(原先默認為 `Infinity`, 不會對請求計時), 則發生超時之後, 將放棄收取本次調用的結果, 並拋出一個 `ApiTimeoutError`。 150 | 151 | `options` 除了是一個對象外, 也可以直接給一個數值, 該數值會被直接當作 `timeout` 使用。 152 | 153 | ### 範例 154 | ```js 155 | bot('send_private_msg', { 156 | user_id: 123456789, 157 | message: 'Hello world!' 158 | }, { 159 | timeout: 10000 // 10 sec 160 | }) 161 | .then((res) => { 162 | console.log(res) 163 | // { 164 | // status: 'ok', 165 | // retcode: 0, 166 | // data: null 167 | // } 168 | }) 169 | .catch((err) => { 170 | console.error('請求超時!') 171 | }) 172 | ``` -------------------------------------------------------------------------------- /docs/api/EventListener.md: -------------------------------------------------------------------------------- 1 | # 事件監聽器 2 | 3 | - [事件監聽器](#%E4%BA%8B%E4%BB%B6%E7%9B%A3%E8%81%BD%E5%99%A8) 4 | - [EventListener](#eventlistener) 5 | - [OnceListener](#oncelistener) 6 | - [MessageEventListener](#messageeventlistener) 7 | - [OnceMessageEventListener](#oncemessageeventlistener) 8 | 9 | ## EventListener 10 | ```ts 11 | listener: (context: object) => void | Promise 12 | ``` 13 | 14 | - `context` 為上報的文本,可見 CQHTTP API 之[事件列表](https://cqhttp.cc/docs/#/Post?id=%E4%BA%8B%E4%BB%B6%E5%88%97%E8%A1%A8)。 15 | 16 | 17 | ## OnceListener 18 | ```ts 19 | listener: (context: object) => void | Promise | false 20 | ``` 21 | 用於 [`bot.once(event, listener)`](CQWebSocket.md#once)。 22 | 23 | 當返回值為 `false` ,指涉該監聽器並未完成任務,則保留該監聽器繼續聽取事件,不做移除。下一次事件發生時,該監聽器在調用後會再次以返回值判定去留。若返回值非 `false` ,指涉該監聽器處理完畢,立即移除。 24 | 25 | ## MessageEventListener 26 | ```ts 27 | listener: (e: CQEvent, context: object, tags: CQTag[]) => void | Promise | string | Promise | ArrayMessage | Promise 28 | ``` 29 | 30 | - `CQEvent` 見 [CQEvent](CQEvent.md)。 31 | - `CQTag` 見 [CQTag](message/README.md#CQTag)。 32 | - `ArrayMessage` 見 [ArrayMessage](ArrayMessage.md)。 33 | 34 | 用於監聽 [`message` 及其子事件](events.md#message)。 35 | 36 | 返回值為 `string | Promise | ArrayMessage | Promise` 時,會以該返回值作為響應訊息。 37 | 38 | ## OnceMessageEventListener 39 | ```ts 40 | listener: (e: CQEvent, context: object, tags: CQTag[]) => void | Promise | string | Promise | ArrayMessage | Promise | false 41 | ``` 42 | 用於一次性監聽 [`message` 及其子事件](events.md#message)。 43 | 44 | 返回值為 `false` 時,行為同 [OnceListener](#oncelistener);返回值非 `false` 時,行為同 [MessageEventListener](#messageeventlistener)。 -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | # API 文件 2 | 3 | - [CQWebSocket SDK](CQWebSocket.md) 4 | - [CQEvent](CQEvent.md) 5 | - [CQ 碼 🐴](messages.md) 6 | - [響應/發送消息結構](ArrayMessage.md) 7 | - [消息段](CQHTTPMessage.md) 8 | - [事件監聽器](EventListener.md) 9 | - [事件列表](events.md) 10 | - [WebSocketType](WebSocketType.md) 11 | - [WebSocketState](WebSocketState.md) 12 | -------------------------------------------------------------------------------- /docs/api/WebSocketState.md: -------------------------------------------------------------------------------- 1 | # WebSocketState 2 | 3 | ```ts 4 | enum WebSocketState { 5 | DISABLED = -1, 6 | INIT = 0, 7 | CONNECTING = 1, 8 | CONNECTED = 2, 9 | CLOSING = 3, 10 | CLOSED = 4 11 | } 12 | ``` -------------------------------------------------------------------------------- /docs/api/WebSocketType.md: -------------------------------------------------------------------------------- 1 | # WebSocketType 2 | 3 | ```ts 4 | enum WebSocketType { 5 | API = '/api', 6 | EVENT = '/event' 7 | } 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/api/errors.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/momocow/node-cq-websocket/fafce7389578b559a114700ed5d8693eff9066ed/docs/api/errors.md -------------------------------------------------------------------------------- /docs/api/events.md: -------------------------------------------------------------------------------- 1 | # 事件列表 2 | 3 | - [事件列表](#%E4%BA%8B%E4%BB%B6%E5%88%97%E8%A1%A8) 4 | - [事件樹](#%E4%BA%8B%E4%BB%B6%E6%A8%B9) 5 | - [基本事件](#%E5%9F%BA%E6%9C%AC%E4%BA%8B%E4%BB%B6) 6 | - [message](#message) 7 | - [notice](#notice) 8 | - [request](#request) 9 | - [meta_event](#metaevent) 10 | - [socket](#socket) 11 | - [api](#api) 12 | 13 | ## 事件樹 14 | ``` 15 | ├─ message 16 | │ ├─ private 17 | │ ├─ discuss 18 | │ │ └─ @ 19 | │ │ └─ me 20 | │ └─ group 21 | │ └─ @ 22 | │ └─ me 23 | ├─ notice 24 | │ ├─ group_upload 25 | │ ├─ group_admin 26 | │ │ ├─ set 27 | │ │ └─ unset 28 | │ ├─ group_decrease 29 | │ │ ├─ leave 30 | │ │ ├─ kick 31 | │ │ └─ kick_me 32 | │ ├─ group_increase 33 | │ │ ├─ approve 34 | │ │ └─ invite 35 | │ └─ friend_add 36 | ├─ request 37 | │ ├─ friend 38 | │ └─ group 39 | | ├─ add 40 | | └─ invite 41 | ├─ meta_event 42 | | ├─ lifecycle 43 | | └─ heartbeat 44 | ├─ error 45 | ├─ ready 46 | ├─ socket ※ 47 | │ ├─ connecting 48 | │ ├─ connect 49 | │ ├─ failed 50 | │ ├─ reconnecting 51 | │ ├─ reconnect 52 | │ ├─ reconnect_failed 53 | │ ├─ max_reconnect 54 | │ ├─ closing 55 | │ ├─ close 56 | │ └─ error 57 | └─ api ※ 58 | ├─ response 59 | └─ send ※ 60 | ├─ pre 61 | └─ post 62 | 63 | ※: 表示無法在該節點進行監聽 64 | ``` 65 | 66 | ## 基本事件 67 | 前三個基本事件之說明,可以另外參考 CQHTTP API 的[數據上報格式](https://cqhttp.cc/docs/4.2/#/Post?id=%E4%B8%8A%E6%8A%A5%E6%95%B0%E6%8D%AE%E6%A0%BC%E5%BC%8F)。 68 | 69 | 參數 `context` 可見[事件列表](https://cqhttp.cc/docs/4.2/#/Post?id=%E4%BA%8B%E4%BB%B6%E5%88%97%E8%A1%A8)。 70 | 71 | | 事件類型 | 監聽器參數 `...args` | 說明 | 72 | | - | - | - | 73 | | message | `event` [CQEvent](#cqevent-類別)
`context` object
`tags` CQTag[]| 所有流入的訊息。 | 74 | | notice | `context` object | 群文件上傳, 群管變動, 群成員增減, 好友添加...等QQ事件。 | 75 | | request | `context` object | 好友請求, 群請求/群邀請...等QQ事件。 | 76 | | meta_event | `context` object | 來自 CQHTTP API 的元事件。 | 77 | | error | `err` Error | 應用層面的錯誤, 如 CQHttp API 消息格式錯誤, 響應超時... 等 | 78 | | ready | `this` | 設定中啟用之連線均成功並初始化完成,可以開始調用API (送消息...等操作)。 | 79 | 80 | ### message 81 | | 事件類型 | 監聽器參數 | 說明 | 82 | | - | - | - | 83 | | message.private | `event` CQEvent
`context` object
`tags` CQTag[] | 私聊消息。 | 84 | | message.discuss | `event` CQEvent
`context` object
`tags` CQTag[] | 討論組消息。 | 85 | | message.discuss.@ | `event` CQEvent
`context` object
`tags` CQTag[] | 有人於討論組消息中被at。 | 86 | | message.discuss.@.me | `event` CQEvent
`context` object
`tags` CQTag[] | 有人於討論組消息at機器人。 | 87 | | message.group | `event` CQEvent
`context` object
`tags` CQTag[] | 群消息。 | 88 | | message.group.@ | `event` CQEvent
`context` object
`tags` CQTag[] | 有人於群消息中被at。 | 89 | | message.group.@.me | `event` CQEvent
`context` object
`tags` CQTag[] | 有人於群消息at機器人。 | 90 | 91 | ### notice 92 | | 事件類型 | 監聽器參數 | 說明 | 93 | | - | - | - | 94 | | notice.group_upload | `context` object | 群文件上傳。 | 95 | | notice.group_admin.set | `context` object | 設置管理員。 | 96 | | notice.group_admin.unset | `context` object | 取消管理員。 | 97 | | notice.group_decrease.leave | `context` object | 自主退群。 | 98 | | notice.group_decrease.kick | `context` object | 被動踢出群。 | 99 | | notice.group_decrease.kick_me | `context` object | 機器人被踢出群。 | 100 | | notice.group_increase.approve | `context` object | 管理員同意入群。 | 101 | | notice.group_increase.invite | `context` object | 管理員邀請入群。 | 102 | | notice.friend_add | `context` object | 新添加好友。 | 103 | 104 | ### request 105 | | 事件類型 | 監聽器參數 | 說明 | 106 | | - | - | - | 107 | | request.friend | `context` object | 私聊消息。 | 108 | | request.group.add | `context` object | 加群請求。 | 109 | | request.group.invite | `context` object | 邀請入群。 | 110 | 111 | ### meta_event 112 | | 事件類型 | 監聽器參數 | 說明 | 113 | | - | - | - | 114 | | meta_event.lifecycle | `context` object | 生命周期。 | 115 | | meta_event.heartbeat | `context` object | 心跳。 | 116 | 117 | ### socket 118 | 底層 socket 連線的事件, 可用於掌握連線狀況。 119 | 120 | | 事件類型 | 監聽器參數 | 說明 | 121 | | - | - | - | 122 | | socket.connecting | `type` WebsocketType
`attempts` number | 開始嘗試連線, 連線成功/失敗之前。 | 123 | | socket.connect | `type` WebsocketType
`socket` [WebSocketConnection](https://github.com/theturtle32/WebSocket-Node/blob/d941f975e8ef6b55eafc0ef45996f4198013832c/docs/WebSocketConnection.md#websocketconnection)
`attempts` number | 連線成功後,尚未初始化之前。 | 124 | | socket.failed | `type` WebsocketType
`attempts` number | 連線失敗。 | 125 | | socket.reconnecting | `type` WebsocketType
`attempts` number | 開始嘗試重新連線, 若存在持續中的連線, 則先斷線。 | 126 | | socket.reconnect | `type` WebsocketType
`attempts` number | 重連成功。 | 127 | | socket.reconnect_failed | `type` WebsocketType
`attempts` number | 重連失敗。 | 128 | | socket.max_reconnect | `type` WebsocketType
`attempts` number | 已抵達重連次數上限。 | 129 | | socket.closing | `type` WebsocketType | 連線關閉之前。 | 130 | | socket.close | `type` WebsocketType
`code` number
`desc` string | 連線關閉。(連線關閉代碼 `code` 可參照 [RFC 文件](https://tools.ietf.org/html/rfc6455#section-7.4))) | 131 | | socket.error | `type` WebsocketType
`err` Error | 連線錯誤。若該事件無監聽器,則會安裝默認監聽器,固定拋出例外。 | 132 | 133 | ### api 134 | | 事件類型 | 監聽器參數 | 說明 | 135 | | - | - | - | 136 | | api.send.pre | `apiRequest` object | 傳送 API 請求之前。關於 `apiRequest` 可見 [/api/接口說明](https://cqhttp.cc/docs/4.2/#/WebSocketAPI?id=api-%E6%8E%A5%E5%8F%A3)。 | 137 | | api.send.post | | 傳送 API 請求之後。 | 138 | | api.response | `result` object | 對於 API 請求的響應。詳細格式見 [/api/接口說明](https://cqhttp.cc/docs/4.2/#/WebSocketAPI?id=api-%E6%8E%A5%E5%8F%A3)。
此為集中處理所有 API 請求的響應, 若需對個別請求追蹤結果, 請參考[方法調用](#方法調用)中返回的 Promise 對象。
若需追蹤消息快速響應的結果, 請參考 [響應結果追蹤](#響應結果追蹤)。 | 139 | -------------------------------------------------------------------------------- /docs/api/messages.md: -------------------------------------------------------------------------------- 1 | # CQ 碼 🐴 2 | 3 | - [CQ 碼 🐴](#cq-%E7%A2%BC-%F0%9F%90%B4) 4 | - [CQTag](#cqtag) 5 | - [CQTag tagName](#cqtag-tagname) 6 | - [CQTag data](#cqtag-data) 7 | - [CQTag modifier](#cqtag-modifier) 8 | - [CQTag equals](#cqtag-equals) 9 | - [CQTag coerce](#cqtag-coerce) 10 | - [CQTag toString](#cqtag-tostring) 11 | - [CQTag valueOf](#cqtag-valueof) 12 | - [範例](#%E7%AF%84%E4%BE%8B) 13 | - [CQTag toJSON](#cqtag-tojson) 14 | - [CQAnonymous](#cqanonymous) 15 | - [CQAnonymous constructor](#cqanonymous-constructor) 16 | - [CQAnonymous ignore](#cqanonymous-ignore) 17 | - [CQAt](#cqat) 18 | - [CQAt constructor](#cqat-constructor) 19 | - [CQAt qq](#cqat-qq) 20 | - [CQBFace](#cqbface) 21 | - [CQBFace constructor](#cqbface-constructor) 22 | - [CQBFace id](#cqbface-id) 23 | - [CQCustomMusic](#cqcustommusic) 24 | - [CQCustomMusic constructor](#cqcustommusic-constructor) 25 | - [CQCustomMusic type](#cqcustommusic-type) 26 | - [CQCustomMusic url](#cqcustommusic-url) 27 | - [CQCustomMusic audio](#cqcustommusic-audio) 28 | - [CQCustomMusic title](#cqcustommusic-title) 29 | - [CQCustomMusic content](#cqcustommusic-content) 30 | - [CQCustomMusic image](#cqcustommusic-image) 31 | - [CQDice](#cqdice) 32 | - [CQDice constructor](#cqdice-constructor) 33 | - [CQDice type](#cqdice-type) 34 | - [CQEmoji](#cqemoji) 35 | - [CQEmoji constructor](#cqemoji-constructor) 36 | - [CQEmoji id](#cqemoji-id) 37 | - [CQFace](#cqface) 38 | - [CQFace constructor](#cqface-constructor) 39 | - [CQFace id](#cqface-id) 40 | - [CQImage](#cqimage) 41 | - [CQImage constructor](#cqimage-constructor) 42 | - [CQImage file](#cqimage-file) 43 | - [CQImage url](#cqimage-url) 44 | - [CQImage cache](#cqimage-cache) 45 | - [CQMusic](#cqmusic) 46 | - [CQMusic constructor](#cqmusic-constructor) 47 | - [CQMusic type](#cqmusic-type) 48 | - [CQMusic id](#cqmusic-id) 49 | - [CQRecord](#cqrecord) 50 | - [CQRecord constructor](#cqrecord-constructor) 51 | - [CQRecord file](#cqrecord-file) 52 | - [CQRecord magic](#cqrecord-magic) 53 | - [CQRecord hasMagic](#cqrecord-hasmagic) 54 | - [CQRPS](#cqrps) 55 | - [CQRPS constructor](#cqrps-constructor) 56 | - [CQRPS type](#cqrps-type) 57 | - [CQSFace](#cqsface) 58 | - [CQSFace constructor](#cqsface-constructor) 59 | - [CQSFace id](#cqsface-id) 60 | - [CQShake](#cqshake) 61 | - [CQShake constructor](#cqshake-constructor) 62 | - [CQShare](#cqshare) 63 | - [CQShare constructor](#cqshare-constructor) 64 | - [CQShare url](#cqshare-url) 65 | - [CQShare title](#cqshare-title) 66 | - [CQShare content](#cqshare-content) 67 | - [CQShare image](#cqshare-image) 68 | - [CQText](#cqtext) 69 | - [CQText constructor](#cqtext-constructor) 70 | - [CQText text](#cqtext-text) 71 | 72 | ## CQTag 73 | CQTag 為一個抽象類別,*正常情況下並**不會**直接建立一個 CQTag 的實例*,而是使用其子類別,如 CQAt、CQImage... 等。 74 | 75 | CQTag 作為所有 CQ 碼的親類別。 76 | 77 | ### CQTag tagName 78 | ```js 79 | tag.tagName 80 | ``` 81 | - `string` 82 | 83 | CQ碼功能名,如 `"at"`、`"image"`... 等。 84 | 85 | ### CQTag data 86 | ```js 87 | tag.data 88 | ``` 89 | - `ReadOnly` 90 | 91 | `data` 對象的值可能為 `string`, `boolean` 及 `number`。 92 | 93 | `data` 對象包含的內容為,可能會出現在**上報消息**中的 CQ 碼參數,依照參數名稱,描述於 `data` 對象的各字段下。 94 | 95 | 如:上報消息中含有 `"[CQ:at,qq=123456789]"`,則 `tag.data` 對象內容為 `{ qq: 123456789 }`。 96 | 97 | ### CQTag modifier 98 | ```js 99 | tag.modifier 100 | ``` 101 | - `object` 102 | 103 | 只出現在**API 調用**中的 CQ 碼參數。 104 | 105 | 如:調用 API 發送圖片,若要禁用緩存,須加上之 cache 參數即為 `modifier` 的內容,CQ 碼為 `"[CQ:image,cache=0,file=file]"`,`modifier` 對象內容為 `{ cache: 0 }`。 106 | 107 | 108 | ### CQTag equals 109 | ```js 110 | tag.equals(another) 111 | ``` 112 | - `another` CQTag 113 | - 返回值: `boolean` 114 | 115 | 若 `another` 非繼承自 `CQTag` 類別,則 `false`。 116 | 若 `another.tagName` 不同於 `this.tagName`,則 `false`。 117 | 若 `another.data` 與 `this.data` 進行 [Deep Equal](https://github.com/substack/node-deep-equal) (strict mode) 比對不相符,則 `false`。 118 | 其餘返回 `true`。 119 | 120 | ### CQTag coerce 121 | ```js 122 | tag.coerce() 123 | ``` 124 | - 返回值: `this` 125 | 126 | 將 `data` 對象的值,依照各 CQ 碼的定義,強制轉型。 127 | 128 | 此方法為通常為**內部使用**。 129 | 130 | ### CQTag toString 131 | ```js 132 | tag.toString() 133 | ``` 134 | - 返回值: `string` 135 | 136 | 返回 CQ 碼的文字型態,如 `"[CQ:at,qq=123456789]"`。 137 | 138 | ### CQTag valueOf 139 | ```js 140 | tag.valueOf() 141 | ``` 142 | - 返回值: `string` 143 | 144 | 同 [CQTag #toString()](#cqtag-tostring)。 145 | 146 | 藉此方法,使 CQ 碼可以進行如字串相加... 等運算。 147 | 148 | #### 範例 149 | ```js 150 | const tag = new CQAt(123456789) 151 | 152 | console.log(tag + ' 你好') // "[CQ:at,qq=123456789] 你好" 153 | ``` 154 | 155 | ### CQTag toJSON 156 | ```js 157 | tag.toJSON() 158 | ``` 159 | - 返回值: [CQHTTPMessage](CQHTTPMessage.md) 160 | 161 | 見 CQHTTP API 之[消息段](https://cqhttp.cc/docs/#/Message?id=%E6%B6%88%E6%81%AF%E6%AE%B5%EF%BC%88%E5%B9%BF%E4%B9%89-cq-%E7%A0%81%EF%BC%89)說明。 162 | 163 | ## CQAnonymous 164 | ### CQAnonymous constructor 165 | ```js 166 | new CQAnonymous([shouldIgnoreIfFailed]) 167 | ``` 168 | - `shouldIgnoreIfFailed` boolean *[modifier]* 169 | 170 | ### CQAnonymous ignore 171 | ```js 172 | tag.ignore 173 | ``` 174 | - `boolean` *[modifier]* 175 | 176 | ## CQAt 177 | ### CQAt constructor 178 | ```js 179 | new CQAt(qq) 180 | ``` 181 | - `qq` number *[data]* 182 | 183 | ### CQAt qq 184 | ```js 185 | tag.qq 186 | ``` 187 | - `ReadOnly` *[data]* 188 | 189 | ## CQBFace 190 | ### CQBFace constructor 191 | ```js 192 | new CQBFace(id, p) 193 | ``` 194 | - `id` number *[data]* 195 | - `p` string *[modifier]* 196 | 197 | 關於這個神祕的 `p`,可以參考 [CQ 码的坑](https://github.com/richardchien/coolq-http-api/wiki/CQ-%E7%A0%81%E7%9A%84%E5%9D%91)。 198 | 199 | ### CQBFace id 200 | ```js 201 | tag.id 202 | ``` 203 | 204 | - `ReadOnly` *[data]* 205 | 206 | ## CQCustomMusic 207 | ### CQCustomMusic constructor 208 | ```js 209 | new CQCustomMusic(url, audio, title[, content[, image]]) 210 | ``` 211 | - `url` string *[data]* 212 | - `audio` string *[data]* 213 | - `title` string *[data]* 214 | - `content` string *[data]* 215 | - `image` string *[data]* 216 | 217 | ### CQCustomMusic type 218 | ```js 219 | tag.type // "custom" 220 | ``` 221 | - `ReadOnly<"custom">` *[data]* 222 | 223 | ### CQCustomMusic url 224 | ```js 225 | tag.url 226 | ``` 227 | - `ReadOnly` *[data]* 228 | 229 | ### CQCustomMusic audio 230 | ```js 231 | tag.audio 232 | ``` 233 | - `ReadOnly` *[data]* 234 | 235 | ### CQCustomMusic title 236 | ```js 237 | tag.title 238 | ``` 239 | - `ReadOnly` *[data]* 240 | 241 | ### CQCustomMusic content 242 | ```js 243 | tag.content 244 | ``` 245 | - `ReadOnly` *[data]* 246 | 247 | ### CQCustomMusic image 248 | ```js 249 | tag.image 250 | ``` 251 | - `ReadOnly` *[data]* 252 | 253 | ## CQDice 254 | ### CQDice constructor 255 | ```js 256 | new CQDice() 257 | ``` 258 | 259 | ### CQDice type 260 | ```js 261 | tag.type 262 | ``` 263 | - ReadOnly *[data]* 264 | 265 | ## CQEmoji 266 | ### CQEmoji constructor 267 | ```js 268 | new CQEmoji(id) 269 | ``` 270 | - `id` number *[data]* 271 | 272 | ### CQEmoji id 273 | ```js 274 | tag.id 275 | ``` 276 | - `ReadOnly` *[data]* 277 | 278 | ## CQFace 279 | ### CQFace constructor 280 | ```js 281 | new CQFace(id) 282 | ``` 283 | - `id` number *[data]* 284 | 285 | ### CQFace id 286 | ```js 287 | tag.id 288 | ``` 289 | - `ReadOnly` *[data]* 290 | 291 | ## CQImage 292 | ### CQImage constructor 293 | ```js 294 | new CQImage(file[, cache]) 295 | ``` 296 | - `file` string *[data]* 297 | - `cache` boolean *[modifier]* 298 | 299 | ### CQImage file 300 | ```js 301 | tag.file 302 | ``` 303 | - `ReadOnly` *[data]* 304 | 305 | ### CQImage url 306 | ```js 307 | tag.url 308 | ``` 309 | - `ReadOnly` *[data]* 310 | 311 | ### CQImage cache 312 | ```js 313 | tag.cache 314 | ``` 315 | - `boolean` *[modifier]* 316 | 317 | ## CQMusic 318 | ### CQMusic constructor 319 | ```js 320 | new CQMusic(type, id) 321 | ``` 322 | - `type` string *[data]* 323 | - `id` number *[data]* 324 | 325 | ### CQMusic type 326 | ```js 327 | tag.type 328 | ``` 329 | - `ReadOnly` *[data]* 330 | 331 | ### CQMusic id 332 | ```js 333 | tag.id 334 | ``` 335 | - `ReadOnly` *[data]* 336 | 337 | ## CQRecord 338 | ### CQRecord constructor 339 | ```js 340 | new CQRecord(file[, magic]) 341 | ``` 342 | - `file` string 343 | - `magic` boolean 344 | 345 | ### CQRecord file 346 | ```js 347 | tag.file 348 | ``` 349 | - `ReadOnly` *[data]* 350 | 351 | ### CQRecord magic 352 | ```js 353 | tag.magic 354 | ``` 355 | - `true | undefined` *[modifier]* 356 | 357 | ### CQRecord hasMagic 358 | ```js 359 | tag.hasMagic() 360 | ``` 361 | - 返回值: boolean 362 | 363 | ## CQRPS 364 | ### CQRPS constructor 365 | ```js 366 | new CQRPS() 367 | ``` 368 | 369 | ### CQRPS type 370 | ```js 371 | tag.type 372 | ``` 373 | - ReadOnly *[data]* 374 | 375 | ## CQSFace 376 | ### CQSFace constructor 377 | ```js 378 | new CQSFace(id) 379 | ``` 380 | - `id` number *[data]* 381 | 382 | ### CQSFace id 383 | ```js 384 | tag.id 385 | ``` 386 | - `ReadOnly` *[data]* 387 | 388 | ## CQShake 389 | ### CQShake constructor 390 | ```js 391 | new CQShake() 392 | ``` 393 | 394 | ## CQShare 395 | ### CQShare constructor 396 | ```js 397 | new CQShare(url, title[, content[, image]]) 398 | ``` 399 | - `url` string *[data]* 400 | - `title` string *[data]* 401 | - `content` string *[data]* 402 | - `image` string *[data]* 403 | 404 | ### CQShare url 405 | ```js 406 | tag.url 407 | ``` 408 | - `ReadOnly` *[data]* 409 | 410 | ### CQShare title 411 | ```js 412 | tag.title 413 | ``` 414 | - `ReadOnly` *[data]* 415 | 416 | ### CQShare content 417 | ```js 418 | tag.content 419 | ``` 420 | - `ReadOnly` *[data]* 421 | 422 | ### CQShare image 423 | ```js 424 | tag.image 425 | ``` 426 | - `ReadOnly` *[data]* 427 | 428 | ## CQText 429 | ### CQText constructor 430 | ```js 431 | new CQText(text) 432 | ``` 433 | - `text` string *[data]* 434 | 435 | ### CQText text 436 | ```js 437 | tag.text 438 | ``` 439 | - `ReadOnly` *[data]* 440 | 441 | [偽 CQ 碼](https://cqhttp.cc/docs/#/Message?id=%E6%A0%BC%E5%BC%8F)。 -------------------------------------------------------------------------------- /docs/get-started/README.md: -------------------------------------------------------------------------------- 1 | # 快速開始 2 | 3 | - [快速開始](#%E5%BF%AB%E9%80%9F%E9%96%8B%E5%A7%8B) 4 | - [創建機器人實例](#%E5%89%B5%E5%BB%BA%E6%A9%9F%E5%99%A8%E4%BA%BA%E5%AF%A6%E4%BE%8B) 5 | - [延伸閱讀](#%E5%BB%B6%E4%BC%B8%E9%96%B1%E8%AE%80) 6 | - [對接 CQHTTP API](#%E5%B0%8D%E6%8E%A5-cqhttp-api) 7 | - [認識 CQWebSocket SDK](#%E8%AA%8D%E8%AD%98-cqwebsocket-sdk) 8 | - [錯誤處理](#%E9%8C%AF%E8%AA%A4%E8%99%95%E7%90%86) 9 | 10 | ## 創建機器人實例 11 | 一個機器人的實例象徵一組對 CQHTTP API 的連線,其中包含了兩個 socket (`/event` 及 `/api`)、所有來自 CQHTTP API 的消息上報、對 CQHTTP API 的 API 調用。 12 | 13 | 首先我們需要先創建機器人的實例,代碼如下: 14 | ```js 15 | const { CQWebSocket } = require('cq-websocket') 16 | const bot = new CQWebSocket() 17 | ``` 18 | 19 | `bot` 便是機器人的實例,這邊我們完全採用默認值進行連線。 20 | 21 | 設定 ws 伺服器位址時, 你可以從以下方式擇一配置。 22 | 1. 使用 `baseUrl` 項指定伺服器 URL。 23 | 2. 使用 `protocol`, `host`, `port` (皆為可選) 指定目標伺服器。 24 | 25 | ### 延伸閱讀 26 | - [API: CQWebSocket Constructor](../api/CQWebSocket.md#constructor) 27 | 28 | ## 對接 CQHTTP API 29 | 可以前往以下文件了解如何與 CQHTTP API 進行連接。 30 | 31 | [閱讀更多 ➡️](connection.md) 32 | 33 | ## 認識 CQWebSocket SDK 34 | 這邊會提及一些 SDK 默認的行為,消息收發的模式。 35 | 36 | [閱讀更多 ➡️](features.md) 37 | 38 | ## 錯誤處理 39 | [閱讀更多 ➡️](errors.md) -------------------------------------------------------------------------------- /docs/get-started/connection.md: -------------------------------------------------------------------------------- 1 | # 連線狀態維護 2 | 3 | - [連線狀態維護](#%E9%80%A3%E7%B7%9A%E7%8B%80%E6%85%8B%E7%B6%AD%E8%AD%B7) 4 | - [建立連線](#%E5%BB%BA%E7%AB%8B%E9%80%A3%E7%B7%9A) 5 | - [延伸閱讀](#%E5%BB%B6%E4%BC%B8%E9%96%B1%E8%AE%80) 6 | - [斷線重連](#%E6%96%B7%E7%B7%9A%E9%87%8D%E9%80%A3) 7 | - [範例](#%E7%AF%84%E4%BE%8B) 8 | - [WebSocket 關閉之狀態碼](#websocket-%E9%97%9C%E9%96%89%E4%B9%8B%E7%8B%80%E6%85%8B%E7%A2%BC) 9 | 10 | ## 建立連線 11 | 為了與事先配置完成的 CQHTTP API 插件建立連線,端口、服務器位址都在創建實例時便已經配置好了,這邊只需要呼叫 connect 方法即可。 12 | 13 | ```js 14 | bot.connect() 15 | ``` 16 | 17 | 由於連線的建立是屬於異步操作,呼叫 connect 後立刻發送消息並沒有任何卵用 ┐(´д`)┌ 。 18 | 我們需要靜待 `ready` 事件的發生,示意機器人就緒,可以開始進行消息操作。 19 | 20 | ```js 21 | bot.on('ready', function () { 22 | // 機器人就緒 23 | }) 24 | ``` 25 | 26 | 值得注意的是,如果此時發生暫時性的網路問題,造成連線中斷,SDK 會自動嘗試重新建立連線,一旦連線再次建立完畢,會再次觸發 `ready` 事件,因此如果有些代碼在整個程序運行過程中,只能執行一次,這邊可以使用 `bot.once('ready')` 而非範例中的 `bot.on('ready')`。 27 | 28 | ### 延伸閱讀 29 | - [API: CQWebSocket #on()](../api/CQWebSocket.md#on) 30 | - [API: CQWebSocket #once()](../api/CQWebSocket.md#once) 31 | 32 | ## 斷線重連 33 | 我們可以注意到 CQWebSocket Constructor 裡面有幾項關於重新連接的配置。 34 | - `reconnection` 35 | - `reconnectionAttempts` 36 | - `reconnectionDelay` 37 | 38 | 將 `reconnection` 設定為 true 啟用自動重連, 若發生網路錯誤, 例如無法連線到伺服器端, 連線建立失敗將會觸發重連, 若連續發生連線錯誤, 則重連次數不超過 `reconnectionAttempts`, 每次重連間隔 `reconnectionDelay` 毫秒。連續連線失敗將會在下一次連線成功時重新計數。 39 | 40 | 而每次進行重新連接時(不會是偵測到網路中斷而自動重連,或是開發者自行呼叫 `bot.reconnect()`),都會先對機器人呼叫 `bot.disconnect()` 確認已經斷線,再進行 `bot.connect()`。 41 | 42 | 因此我們可以在 `socket.reconnecting`、`socket.connecting` 及 `socket.connect` 事件追蹤連線的狀況。 43 | `socket.reconnecting`、`socket.connecting` 及 `socket.connect` 事件中帶有 `attempts` 參數,可以作為參照某次連線是否成功,`attempts` 的值表示**連續** *(N - 1)* 次失敗後的第 *N* 次連線嘗試。 44 | 45 | `attempts` 會在連線成功後歸零。 46 | 47 | SDK 底層封裝了兩個 socket 均會各自發布 `socket` 事件。 48 | 49 | ### 範例 50 | ```js 51 | const CQWebSocket = require('cq-websocket') 52 | const { WebsocketType } = CQWebSocket 53 | const bot = new CQWebSocket() 54 | 55 | // 手動連接兩個連線 56 | bot.connect(WebsocketType.API) 57 | bot.connect(WebsocketType.EVENT) 58 | 59 | // 上面兩行 connect 代碼等同這一句 60 | bot.connect() 61 | 62 | bot.on('socket.connecting', function (socketType, attempts) { 63 | console.log('嘗試第 %d 次連線 _(:з」∠)_', attempts) 64 | }).on('socket.connect', function (socketType, sock, attempts) { 65 | console.log('第 %d 次連線嘗試成功 ヽ(✿゚▽゚)ノ', attempts) 66 | }).on('socket.failed', function (socketType, attempts) { 67 | console.log('第 %d 次連線嘗試失敗 。・゚・(つд`゚)・゚・', attempts) 68 | }) 69 | ``` 70 | 71 | ## WebSocket 關閉之狀態碼 72 | 若呼叫 `CQWebSocket #disconnect()` 會對服務器端發送夾帶 `1000` 狀態碼的關閉訊息, 表示正常關閉, 無需重連。 73 | 74 | 若發生網路斷線、服務器重啟... 等意外斷線, 通常會獲得 `1006` 狀態碼, 此狀態表示 websocket 客戶端 (即機器人端) 觀察到服務器關閉。 -------------------------------------------------------------------------------- /docs/get-started/errors.md: -------------------------------------------------------------------------------- 1 | # 錯誤處理 2 | 3 | ## `error` 事件 4 | 目前有兩個狀況會發布這個事件。 5 | 6 | 1. 底層 socket 收到的消息,於 `JSON.parse()` 時報錯,這很可能意味著 JSON 格式錯誤,或是消息並非 UTF8 編碼,這個錯誤應該來自於消息發送端。 7 | 2. SDK 在分發事件時,若遇到未預期的文本字段,則會發布這個事件。例如,收到 `post_type` 為 `message` 的事件,但 `message_type` 非已知的訊息類型,則發布 `error` 事件。 8 | 9 | ### `socket.error` 10 | 由於 `socket.error` 屬於連線失誤的事件,如果沒有適當的監聽器配套措施,會造成無防備的狀況下無法順利連線,徒增困擾。 11 | 12 | 13 | 為此而產生了 `socket.error` 事件之默認監聽器,當開發者沒有主動監聽 `socket.error` 事件,則會使用默認監聽器,發生錯誤時會將收到的錯誤實例拋出,而該錯誤實例下有一個 `which` 字段(內容為 `string` 類型且必為 `/api` `/event` 兩者任一)指出是哪一個連線出了問題。 14 | 15 | 默認監聽器除了拋出錯誤外, 還會在 stderr 輸出以下警示訊息: 16 | ``` 17 | You should listen on "socket.error" yourself to avoid those unhandled promise warnings. 18 | ``` 19 | 20 | ※**自行**監聽 `socket.error` 可避免*默認監聽器*的行為。 21 | -------------------------------------------------------------------------------- /docs/get-started/example.md: -------------------------------------------------------------------------------- 1 | # 範例 2 | 基本創建一個複讀機器人的代碼範例如下(可參見[demo/echo-bot.js](https://github.com/momocow/node-cq-websocket/blob/master/demo/echo-bot.js)): 3 | 4 | ```js 5 | const CQWebSocket = require('cq-websocket') 6 | 7 | // 採用默認參數創建機器人實例 8 | let bot = new CQWebSocket() 9 | 10 | // 設定訊息監聽 11 | bot.on('message', (e, context) => { 12 | // 若要追蹤訊息發送狀況, 須獲取事件處理權, 並使用下面2或3的方式響應訊息 13 | e.stopPropagation() 14 | // 監聽訊息發送成功與否 15 | e.onResponse(console.log) 16 | // 監聽訊息發送超時與否 17 | e.onError(console.error) 18 | 19 | // 以下提供三種方式將原訊息以原路送回 20 | 21 | // 1. 調用 CQHTTP API 之 send_msg 方法 22 | // (這就是一般的API方法調用, 直接在該方法的返回值之Promise追蹤結果) 23 | // bot('send_msg', context) 24 | // .then(console.log) 25 | // .catch(console.error) 26 | 27 | // 2. 或者透過返回值快速響應 28 | // return context.message 29 | 30 | // 3. 或者透過CQEvent實例,先獲取事件處理權再設置響應訊息 31 | // e.stopPropagation() 32 | // e.setMessage(context.message) 33 | }) 34 | 35 | bot.connect() 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/get-started/features.md: -------------------------------------------------------------------------------- 1 | # SDK 介紹 2 | 3 | - [SDK 介紹](#sdk-%E4%BB%8B%E7%B4%B9) 4 | - [核心概念](#%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5) 5 | - [自動獲取機器人QQ號](#%E8%87%AA%E5%8B%95%E7%8D%B2%E5%8F%96%E6%A9%9F%E5%99%A8%E4%BA%BAqq%E8%99%9F) 6 | - [事件傳播](#%E4%BA%8B%E4%BB%B6%E5%82%B3%E6%92%AD) 7 | - [快速響應](#%E5%BF%AB%E9%80%9F%E9%9F%BF%E6%87%89) 8 | - [範例: 協議式響應](#%E7%AF%84%E4%BE%8B-%E5%8D%94%E8%AD%B0%E5%BC%8F%E9%9F%BF%E6%87%89) 9 | - [發送結果追蹤](#%E7%99%BC%E9%80%81%E7%B5%90%E6%9E%9C%E8%BF%BD%E8%B9%A4) 10 | - [消息格式](#%E6%B6%88%E6%81%AF%E6%A0%BC%E5%BC%8F) 11 | - [上報消息](#%E4%B8%8A%E5%A0%B1%E6%B6%88%E6%81%AF) 12 | - [發送消息](#%E7%99%BC%E9%80%81%E6%B6%88%E6%81%AF) 13 | - [延伸閱讀](#%E5%BB%B6%E4%BC%B8%E9%96%B1%E8%AE%80) 14 | 15 | ## 核心概念 16 | 17 | CQWebSocket SDK 是基於 CQHTTP API 插件之 WebSocket 通訊,底層封裝了兩個 socket,分別為 `/api` 和 `/event` (詳細功能描述可見 [coolq-http-api/websocket](https://cqhttp.cc/docs/#/WebSocketAPI?id=api-%E6%8E%A5%E5%8F%A3))。 18 | 19 | CQWebSocket SDK 使開發者能夠更專心於機器人應用的開發,SDK 為開發者提供底層連線的維護、斷線重連等功能,並第一手先處理了上報事件,依照不同事件類型,將事件文本分發至各事件監聽器處理。 20 | 21 | ## 自動獲取機器人QQ號 22 | 若機器人配置 `enableAPI` 為 true, 且沒有通過 `qq` 項配置機器人ID的話, 連線建立成功後會主動發送 API 請求向 CQHTTP API 取得酷Q正登錄的QQ號作為機器人QQ號。 23 | 24 | 此操作為異步操作, 在API響應之前, `@.me` 事件均不會發布。 25 | 26 | **除非**真的有人QQ號是 `-1`, 哪尼口雷 Σ(*゚д゚ノ)ノ 27 | 28 | ## 事件傳播 29 | 事件具有向上傳播的機制,一個事件上報之後,該事件之所有**親事件**也會依序上報。 30 | 事件名稱以 `.` 相互連接,形成具有繼承關係的結構,如 `message` (任意消息事件) 為 `message.group` (群消息) 的親事件。 31 | 關於事件親子關係的構成,可參考[事件樹](../api/events.md#%E4%BA%8B%E4%BB%B6%E6%A8%B9)。 32 | 33 | 舉個例子,群消息有人@某機器人,該機器人則會首先上報 `message.group.@.me` 事件,該事件之親事件由下而上依序為 `message.group.@`, `message.group` 、 `message` ,則這幾個事件也會依照這個順序上報,這樣稱為一次**事件傳播**。 34 | 35 | ## 快速響應 36 | 37 | > 僅 `message` 及其子事件支援此機制。 38 | 39 | `message` 及其子事件監聽器的第一個參數: `CQEvent` 類別實例,在這個機制中扮演重要的角色。 40 | 透過 `CQEvent` 實例,所有監聽器皆可在自己的運行期間調用 `CQEvent #stopPropagation()` 方法聲明自己的處理權,以截獲事件並阻斷後續監聽器的調用,並立即以該事件返回之文字訊息(或透過調用 `CQEvent #setMessage(msg)` 設定之文字訊息,也可以透過 `Promise` 對象 resolve 之文字訊息)作為響應,送回至 CQHTTP API 插件端。 41 | 42 | 由於在一次事件傳播中的所有監聽器都會收到同一個 `CQEvent` 實例,因此對於響應的決定方式,除了 `CQEvent #stopPropagation()` 所提供的事件截獲機制之外,也可以採取協議式的方式,就是透過每個監聽器調用 `CQEvent #getMessage()` `CQEvent #setMessage(msg)` 協議出一個最終的響應訊息。 43 | 44 | ### 範例: 協議式響應 45 | 假設機器人具有以下代碼。 46 | 47 | ```js 48 | // app.js 49 | 50 | const { CQWebSocket } = require('cq-websocket') 51 | const bot = CQWebSocket() 52 | 53 | const plugins = [ 54 | require('./pluginA'), 55 | require('./pluginB') 56 | ] 57 | 58 | plugins.forEach(plugin => { 59 | bot.on('message.private', plugin) 60 | }) 61 | 62 | ``` 63 | 64 | ```js 65 | // pluginA.js 66 | module.exports = function (e) { 67 | if (e.hasMessage()) { 68 | e.appendMessage(' world!') 69 | } else { 70 | e.setMessage('Hello') 71 | } 72 | } 73 | ``` 74 | 75 | ```js 76 | // pluginB.js 77 | module.exports = function (e) { 78 | if (e.hasMessage()) { 79 | e.appendMessage(' CQWebSocket!') 80 | } else { 81 | e.setMessage('Hi') 82 | } 83 | } 84 | ``` 85 | 86 | 此時若機器人收到了一則私人消息,則會響應 `"Hello CQWebSocket!"`。 87 | 88 | 我們也可以藉此機制,加入消息過濾的功能。 89 | 首先在機器人的 `app.js` 加入以下代碼。 90 | ```js 91 | // app.js 92 | const pluginGuard = require('./pluginGuard') 93 | 94 | // 於最上層親事件檢查所有響應訊息 95 | bot.on('message', pluginGuard) 96 | ``` 97 | 98 | ```js 99 | // pluginGuard.js 100 | module.exports = function (e) { 101 | if (e.hasMessage()) { 102 | e.stopPropagation() 103 | 104 | // 關鍵字過濾 105 | e.setMessage( 106 | e.getMessage().replace(/關鍵字/g, '') 107 | ) 108 | } 109 | } 110 | ``` 111 | 112 | ## 發送結果追蹤 113 | 不論是快速響應或是 API 調用均有此機制。 114 | 115 | 追蹤快速響應之結果,可通過 CQEvent `#onResponse()` 設置結果監聽器, 並透過 CQEvent `#onError()` 處理響應的錯誤。 116 | 若沒有 CQEvent `#onError()` 進行錯誤處理, 發生響應錯誤時會觸發 [`error` 事件](#基本事件)。 117 | 118 | 追蹤 API 調用之結果,可以利用 [`bot(method[, params[, options]])`](../api/CQWebSocket.md#api-call) 返回的 Promise 對象進行追蹤。 119 | 120 | ## 消息格式 121 | ### 上報消息 122 | `message` 事件中,監聽器第三個參數為一個 `CQTag` 的數組,關於該數組內可能出現的元素,可以參考[CQ 碼相關類別](../api/messages.md)。 123 | 124 | ※除了已定義的 CQTag 之外,其餘的內容都會當作 CQText 的字符串處理。 125 | 126 | 舉例,上報消息中含有一個不在定義中的 CQ 碼 `"[CQ:unknown,key=value]"`,則此 CQ 碼會被 Parser 判定為一個 CQText 實例,等同 `new CQText('[CQ:unknown,key=value]')`。 127 | 128 | ### 發送消息 129 | 不論是快速響應或是 API 調用發送消息,均可使用以下消息格式。 130 | 1. [字符串格式](https://cqhttp.cc/docs/#/Message?id=%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%A0%BC%E5%BC%8F) 131 | 2. [數組格式](https://cqhttp.cc/docs/#/Message?id=%E6%95%B0%E7%BB%84%E6%A0%BC%E5%BC%8F) 132 | 133 | 其中,數組格式在本 SDK 的擴增下,支持了將本 SDK 的 [CQ 碼相關類別](../api/messages.md)直接作為數組的內容。 134 | 135 | 首先可以先了解一下 CQHTTP API 所提供的[消息段對象](../api/CQHTTPMessage.md),此消息段在這邊我們姑且稱之為 `CQHTTPMessage`。 136 | 137 | 快速響應或是 API 調用發送消息時,可以使用一個含有 `CQHTTPMessage` **或** `CQTag` **或** `string` 的數組作為消息文本。 138 | (`CQTag` 可參考 [CQ 碼相關類別](../api/messages.md)) 139 | 140 | 舉個快速響應的例子: 141 | ```js 142 | bot.on('message', () => { 143 | return [ 144 | { 145 | type: 'at', 146 | data: { 147 | qq: '123' 148 | } 149 | }, 150 | '你好~ ', 151 | new CQEmoji(129303) // 🤗 152 | ] 153 | }) 154 | ``` 155 | 每當機器人收到消息時,便會回應 `"[CQ:at,qq=123]你好~ 🤗"`。 156 | 157 | ### 延伸閱讀 158 | - [CQ 碼](https://d.cqp.me/Pro/CQ%E7%A0%81) 159 | - [CQHTTP API 之消息格式](https://cqhttp.cc/docs/#/Message) 160 | - [CQHTTP API 之 CQ 碼](https://cqhttp.cc/docs/#/CQCode) 161 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cq-websocket", 3 | "version": "2.1.1", 4 | "description": "A Node SDK for developing QQ chatbots based on WebSocket, which is depending on CoolQ and CQHTTP API plugin. ", 5 | "engines": { 6 | "cqhttp": "^4.5.0", 7 | "node": ">=8" 8 | }, 9 | "main": "./src/index.js", 10 | "types": "./cq-websocket.d.ts", 11 | "files": [ 12 | "src", 13 | "dist/**/*.min.js", 14 | "cq-websocket.d.ts" 15 | ], 16 | "scripts": { 17 | "test": "nyc ava test/**/*.test.js", 18 | "build": "webpack --config ./webpack.config.js", 19 | "build-demo": "webpack --config demo/webpack/webpack.config.js", 20 | "demo-browser": "http-server demo/webpack/www", 21 | "demo-echobot": "node ./demo/echobot.js", 22 | "coverage": "nyc report --reporter=text-lcov | coveralls", 23 | "commit": "git-cz", 24 | "lint": "eslint src", 25 | "lint:fix": "eslint src --fix", 26 | "release": "semantic-release" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/momocow/node-cq-websocket.git" 31 | }, 32 | "keywords": [ 33 | "CoolQ", 34 | "websocket" 35 | ], 36 | "author": "MomoCow", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/momocow/node-cq-websocket/issues" 40 | }, 41 | "homepage": "https://github.com/momocow/node-cq-websocket#readme", 42 | "config": { 43 | "commitizen": { 44 | "path": "cz-conventional-changelog" 45 | } 46 | }, 47 | "dependencies": { 48 | "deep-equal": "^1.1.1", 49 | "lodash.get": "^4.4.2", 50 | "shortid": "^2.2.15", 51 | "websocket": "^1.0.31" 52 | }, 53 | "devDependencies": { 54 | "@semantic-release/changelog": "^3.0.6", 55 | "@semantic-release/git": "^7.0.18", 56 | "@semantic-release/release-notes-generator": "^7.3.5", 57 | "ava": "^1.4.1", 58 | "commitizen": "^4.0.3", 59 | "coveralls": "^3.0.11", 60 | "cz-conventional-changelog": "^2.1.0", 61 | "eslint": "^5.16.0", 62 | "eslint-config-standard": "^12.0.0", 63 | "eslint-plugin-import": "^2.20.1", 64 | "eslint-plugin-node": "^8.0.1", 65 | "eslint-plugin-promise": "^4.2.1", 66 | "eslint-plugin-standard": "^4.0.1", 67 | "http-server": "^0.12.1", 68 | "kaomojify-webpack-plugin": "^0.1.1", 69 | "mri": "^1.1.4", 70 | "nyc": "^14.1.1", 71 | "proxyquire": "^2.1.3", 72 | "semantic-release": "^15.14.0", 73 | "sinon": "^7.5.0", 74 | "tape": "^4.13.2", 75 | "webpack": "^4.42.0", 76 | "webpack-cli": "^3.3.11" 77 | }, 78 | "nyc": { 79 | "include": [ 80 | "src/**/*.js", 81 | "!src/util/**/*" 82 | ] 83 | }, 84 | "ava": { 85 | "babel": false, 86 | "compileEnhancements": false 87 | }, 88 | "browser": "./src/index.js" 89 | } 90 | -------------------------------------------------------------------------------- /performance/leak.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const { iterate } = require('leakage') 3 | const { EventEmitter } = require('events') 4 | 5 | const { CQWebSocket } = require('..') 6 | 7 | test('EventEmitter#once()', function (t) { 8 | const bot = new EventEmitter() 9 | iterate(() => { 10 | let a = 0 11 | bot.once('message', function (arg) { 12 | // zero side effect 13 | arg++ 14 | }) 15 | bot.emit('message', a) 16 | }) 17 | t.same(bot.listeners('message').length, 0) 18 | t.end() 19 | }) 20 | 21 | test('CQWebSocket#once()', function (t) { 22 | const bot = new CQWebSocket() 23 | iterate(() => { 24 | let a = 0 25 | bot.once('message', function (arg) { 26 | // zero side effect 27 | arg++ 28 | }) 29 | bot._eventBus.emit('message', a) 30 | }) 31 | t.same(bot._eventBus._EventMap.message[''].length, 0) 32 | t.end() 33 | }) 34 | -------------------------------------------------------------------------------- /performance/leakage/on-off.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const { iterate } = require('leakage') 3 | const { EventEmitter } = require('events') 4 | 5 | const { CQWebSocket } = require('../../') 6 | 7 | test('EventEmitter#on(), EventEmitter#removeAllListener()', function (t) { 8 | const bot = new EventEmitter() 9 | iterate(() => { 10 | let a = 0 11 | bot.on('message', function (arg) { 12 | // zero side effect 13 | arg++ 14 | }) 15 | bot.emit('message', a) 16 | bot.removeAllListeners('message') 17 | }) 18 | t.same(bot.listeners('message').length, 0) 19 | t.end() 20 | }) 21 | 22 | test('CQWebSocket#on(), CQWebSocket#off()', function (t) { 23 | const bot = new CQWebSocket() 24 | iterate.async(async () => { 25 | let a = 0 26 | bot.on('message', function (arg) { 27 | // zero side effect 28 | arg++ 29 | }) 30 | await bot._eventBus.emit('message', a) 31 | bot.off('message') 32 | }) 33 | .then(function () { 34 | t.same(bot._eventBus._EventMap.message[''].length, 0) 35 | t.end() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /performance/leakage/once.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const { iterate } = require('leakage') 3 | const { EventEmitter } = require('events') 4 | 5 | const { CQWebSocket } = require('../../') 6 | 7 | test('EventEmitter#once()', function (t) { 8 | const bot = new EventEmitter() 9 | iterate(() => { 10 | let a = 0 11 | bot.once('message', function (arg) { 12 | // zero side effect 13 | arg++ 14 | }) 15 | bot.emit('message', a) 16 | }) 17 | t.same(bot.listeners('message').length, 0) 18 | t.end() 19 | }) 20 | 21 | test('CQWebSocket#once()', function (t) { 22 | const bot = new CQWebSocket() 23 | iterate.async(() => { 24 | let a = 0 25 | bot.once('message', function (arg) { 26 | // zero side effect 27 | arg++ 28 | }) 29 | return bot._eventBus.emit('message', a) 30 | }) 31 | .then(function () { 32 | t.same(bot._eventBus._EventMap.message[''].length, 0) 33 | t.end() 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /performance/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cq-websocket-perf", 3 | "scripts": { 4 | "perf": "tape performance/**/*.test.js" 5 | }, 6 | "devDependencies": { 7 | "tape": "^4.11.0", 8 | "leakage": "^0.4.0" 9 | } 10 | } -------------------------------------------------------------------------------- /performance/sample.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const { iterate, MemoryLeakError } = require('leakage') 3 | 4 | test('leak free', function (t) { 5 | iterate(() => { 6 | const a = [] 7 | a.push('test'.repeat(10)) 8 | }) 9 | 10 | t.pass() 11 | t.end() 12 | }) 13 | 14 | test('leak found', function (t) { 15 | const a = [] 16 | t.throws(() => { 17 | iterate(() => { 18 | a.push('test'.repeat(10)) 19 | }) 20 | }, MemoryLeakError) 21 | t.end() 22 | }) 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "assignees": ["momocow"], 3 | "baseBranches": ["dev"], 4 | "extends": ["config:base"], 5 | "vulnerabilityAlerts": { 6 | "labels": ["security"], 7 | "assignees": ["momocow"] 8 | }, 9 | "reviewers": ["momocow"], 10 | "semanticCommits": true, 11 | "semanticCommitType": "chore", 12 | "schedule": "before 3am on Monday", 13 | "packageRules": [ 14 | { 15 | "packagePatterns": ["^eslint"], 16 | "groupName": "eslint packages" 17 | }, 18 | { 19 | "packagePatterns": ["^webpack"], 20 | "groupName": "webpack packages" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | class InvalidWsTypeError extends Error { 2 | constructor (type) { 3 | super(`"${type}" is not a valid websocket type.`) 4 | this.which = type 5 | } 6 | } 7 | 8 | class InvalidContextError extends SyntaxError { 9 | constructor (type, data) { 10 | super(`[Websocket: ${type}] has received an invalid context.\nRaw data: ${data}`) 11 | this.name = 'InvalidContextError' 12 | this.which = type 13 | this.data = data 14 | } 15 | } 16 | 17 | class UnexpectedContextError extends Error { 18 | constructor (context, reason) { 19 | super('Unexpected context is received.') 20 | this.name = 'UnexpectedContextError' 21 | this.context = context 22 | this.reason = reason 23 | } 24 | } 25 | 26 | class SocketError extends Error { 27 | /** 28 | * @param {string} desc 29 | */ 30 | constructor (desc) { 31 | super(desc) 32 | this.name = 'SocketError' 33 | } 34 | } 35 | 36 | class APITimeoutError extends Error { 37 | constructor (timeout, apiReq) { 38 | super(`The API response has reached the timeout (${timeout} ms).`) 39 | this.req = apiReq 40 | } 41 | } 42 | 43 | module.exports = { 44 | SocketError, 45 | UnexpectedContextError, 46 | InvalidWsTypeError, 47 | InvalidContextError, 48 | APITimeoutError 49 | } 50 | -------------------------------------------------------------------------------- /src/event-bus.js: -------------------------------------------------------------------------------- 1 | const $get = require('lodash.get') 2 | 3 | const $traverse = require('./util/traverse') 4 | 5 | const CQTag = require('./message/CQTag') 6 | const CQText = require('./message/models/CQText') 7 | 8 | class CQEventBus { 9 | constructor (cqbot) { 10 | // eventType-to-handlers mapping 11 | // blank keys refer to default keys 12 | this._EventMap = { 13 | message: { 14 | '': [], 15 | private: [], 16 | group: { 17 | '': [], 18 | '@': { 19 | '': [], 20 | 'me': [] 21 | } 22 | }, 23 | discuss: { 24 | '': [], 25 | '@': { 26 | '': [], 27 | 'me': [] 28 | } 29 | } 30 | }, 31 | event: [], 32 | notice: { 33 | '': [], 34 | group_upload: [], 35 | group_admin: { 36 | '': [], 37 | set: [], 38 | unset: [] 39 | }, 40 | group_decrease: { 41 | '': [], 42 | leave: [], 43 | kick: [], 44 | kick_me: [] 45 | }, 46 | group_increase: { 47 | '': [], 48 | approve: [], 49 | invite: [] 50 | }, 51 | friend_add: [], 52 | group_ban: { 53 | '': [], 54 | ban: [], 55 | lift_ban: [] 56 | } 57 | }, 58 | request: { 59 | '': [], 60 | friend: [], 61 | group: { 62 | '': [], 63 | add: [], 64 | invite: [] 65 | } 66 | }, 67 | ready: [], 68 | error: [], 69 | socket: { 70 | connecting: [], 71 | connect: [], 72 | failed: [], 73 | reconnecting: [], 74 | reconnect: [], 75 | reconnect_failed: [], 76 | max_reconnect: [], 77 | error: [], 78 | closing: [], 79 | close: [] 80 | }, 81 | api: { 82 | response: [], 83 | send: { 84 | pre: [], 85 | post: [] 86 | } 87 | }, 88 | meta_event: { 89 | '': [], 90 | lifecycle: [], 91 | heartbeat: [] 92 | } 93 | } 94 | 95 | /** 96 | * A function-to-function mapping 97 | * from the original listener received via #once(event, listener) 98 | * to the once listener wrapper function 99 | * which wraps the original listener 100 | * and is the listener that is actually registered via #on(event, listener) 101 | * @type {WeakMap} 102 | */ 103 | this._onceListeners = new WeakMap() 104 | 105 | this._isSocketErrorHandled = false 106 | this._bot = cqbot 107 | 108 | // has a default handler; automatically removed when developers register their own ones 109 | this._installDefaultErrorHandler() 110 | } 111 | 112 | _getHandlerQueue (eventType) { 113 | let queue = $get(this._EventMap, eventType) 114 | if (Array.isArray(queue)) { 115 | return queue 116 | } 117 | queue = $get(this._EventMap, `${eventType}.`) 118 | return Array.isArray(queue) ? queue : undefined 119 | } 120 | 121 | count (eventType) { 122 | let queue = this._getHandlerQueue(eventType) 123 | return queue ? queue.length : undefined 124 | } 125 | 126 | has (eventType) { 127 | return this._getHandlerQueue(eventType) !== undefined 128 | } 129 | 130 | emit (eventType, ...args) { 131 | return this._emitThroughHierarchy(eventType, ...args) 132 | } 133 | 134 | async _emitThroughHierarchy (eventType, ...args) { 135 | let queue = [] 136 | let isResponsable = eventType.startsWith('message') 137 | 138 | for (let hierarchy = eventType.split('.'); hierarchy.length > 0; hierarchy.pop()) { 139 | let currentQueue = this._getHandlerQueue(hierarchy.join('.')) 140 | if (currentQueue && currentQueue.length > 0) { 141 | queue.push(...currentQueue) 142 | } 143 | } 144 | 145 | if (queue && queue.length > 0) { 146 | let cqevent = isResponsable ? new CQEvent() : undefined 147 | if (isResponsable && Array.isArray(args)) { 148 | args.unshift(cqevent) 149 | } 150 | 151 | for (let handler of queue) { 152 | if (isResponsable) { 153 | // reset 154 | cqevent._errorHandler = cqevent._responseHandler = cqevent._responseOptions = null 155 | } 156 | 157 | let returned = await Promise.resolve(handler(...args)) 158 | 159 | if (isResponsable && (typeof returned === 'string' || Array.isArray(returned))) { 160 | cqevent.stopPropagation() 161 | cqevent.setMessage(returned) 162 | } 163 | 164 | if (isResponsable && cqevent._isCanceled) { 165 | break 166 | } 167 | } 168 | 169 | if (isResponsable && cqevent.hasMessage()) { 170 | let message = cqevent.getMessage() 171 | message = !Array.isArray(message) ? message 172 | : message 173 | .filter(cqmsg => typeof cqmsg === 'object') 174 | .map(cqmsg => cqmsg instanceof CQTag ? cqmsg.toJSON() : cqmsg) 175 | 176 | this._bot('send_msg', { ...args[1], message }, cqevent._responseOptions) 177 | .then(ctxt => { 178 | if (typeof cqevent._responseHandler === 'function') { 179 | cqevent._responseHandler(ctxt) 180 | } 181 | }) 182 | .catch(err => { 183 | if (typeof cqevent._errorHandler === 'function') { 184 | cqevent._errorHandler(err) 185 | } else { 186 | this.emit('error', err) 187 | } 188 | }) 189 | } 190 | } 191 | } 192 | 193 | once (eventType, handler) { 194 | const onceWrapper = (...args) => { 195 | let returned = handler(...args) 196 | // if the returned value is `false` which indicates the handler have not yet finish its task, 197 | // keep the handler for next event handling 198 | if (returned === false) return 199 | 200 | this.off(eventType, onceWrapper) 201 | return returned 202 | } 203 | this._onceListeners.set(handler, onceWrapper) 204 | return this.on(eventType, onceWrapper) 205 | } 206 | 207 | off (eventType, handler) { 208 | if (typeof eventType !== 'string') { 209 | this._onceListeners = new WeakMap() 210 | $traverse(this._EventMap, (value) => { 211 | // clean all handler queues 212 | if (Array.isArray(value)) { 213 | value.splice(0, value.length) 214 | return false 215 | } 216 | }) 217 | this._installDefaultErrorHandler() 218 | return this 219 | } 220 | 221 | const queue = this._getHandlerQueue(eventType) 222 | if (queue === undefined) { 223 | return this 224 | } 225 | 226 | if (typeof handler !== 'function') { 227 | // clean all handlers of the event queue specified by eventType 228 | queue.splice(0, queue.length) 229 | if (eventType === 'socket.error') { 230 | this._installDefaultErrorHandler() 231 | } 232 | return this 233 | } 234 | 235 | const idx = queue.indexOf(handler) 236 | const wrapperIdx = this._onceListeners.has(handler) 237 | ? queue.indexOf(this._onceListeners.get(handler)) : -1 238 | 239 | // no matter the listener is a once listener wrapper or not, 240 | // the first occurence of the "handler" (2nd arg passed in) or its wrapper will be removed from the queue 241 | const victimIdx = idx >= 0 && wrapperIdx >= 0 242 | ? Math.min(idx, wrapperIdx) 243 | : idx >= 0 244 | ? idx 245 | : wrapperIdx >= 0 246 | ? wrapperIdx 247 | : -1 248 | 249 | if (victimIdx >= 0) { 250 | queue.splice(victimIdx, 1) 251 | if (victimIdx === wrapperIdx) { 252 | this._onceListeners.delete(handler) 253 | } 254 | if (eventType === 'socket.error' && queue.length === 0) { 255 | this._installDefaultErrorHandler() 256 | } 257 | return this 258 | } 259 | 260 | return this 261 | } 262 | 263 | _installDefaultErrorHandler () { 264 | if (this._EventMap.socket.error.length === 0) { 265 | this._EventMap.socket.error.push(onSocketError) 266 | this._isSocketErrorHandled = false 267 | } 268 | } 269 | 270 | _removeDefaultErrorHandler () { 271 | if (!this._isSocketErrorHandled) { 272 | this._EventMap.socket.error.splice(0, this._EventMap.socket.error.length) 273 | this._isSocketErrorHandled = true 274 | } 275 | } 276 | 277 | /** 278 | * @param {string} eventType 279 | * @param {function} handler 280 | */ 281 | on (eventType, handler) { 282 | // @deprecated 283 | // keep the compatibility for versions lower than v1.5.0 284 | if (eventType.endsWith('@me')) { 285 | eventType = eventType.replace(/\.@me$/, '.@.me') 286 | } 287 | 288 | if (!this.has(eventType)) { 289 | return this 290 | } 291 | 292 | if (eventType === 'socket.error') { 293 | this._removeDefaultErrorHandler() 294 | } 295 | 296 | let queue = this._getHandlerQueue(eventType) 297 | if (queue) { 298 | queue.push(handler) 299 | } 300 | return this 301 | } 302 | } 303 | 304 | function onSocketError (which, err) { 305 | err.which = which 306 | console.error('\nYou should listen on "socket.error" yourself to avoid those unhandled promise warnings.\n') 307 | throw err 308 | } 309 | 310 | class CQEvent { 311 | constructor () { 312 | this._isCanceled = false 313 | /** 314 | * @type {CQTag[] | string} 315 | */ 316 | this._message = '' 317 | this._responseHandler = null 318 | this._responseOptions = null 319 | this._errorHandler = null 320 | } 321 | 322 | get messageFormat () { 323 | return typeof this._message === 'string' ? 'string' : 'array' 324 | } 325 | 326 | stopPropagation () { 327 | this._isCanceled = true 328 | } 329 | 330 | getMessage () { 331 | return this._message 332 | } 333 | 334 | hasMessage () { 335 | return typeof this._message === 'string' 336 | ? Boolean(this._message) : this._message.length > 0 337 | } 338 | 339 | setMessage (msgIn) { 340 | if (Array.isArray(msgIn)) { 341 | msgIn = msgIn.map(m => typeof m === 'string' ? new CQText(m) : m) 342 | } 343 | this._message = msgIn 344 | } 345 | 346 | appendMessage (msgIn) { 347 | if (typeof this._message === 'string') { 348 | this._message += String(msgIn) 349 | } else { 350 | if (typeof msgIn === 'string') { 351 | msgIn = new CQText(msgIn) 352 | } 353 | this._message.push(msgIn) 354 | } 355 | } 356 | 357 | /** 358 | * @param {(res: object)=>void} handler 359 | */ 360 | onResponse (handler, options) { 361 | if (typeof handler !== 'function') { 362 | options = handler 363 | handler = null 364 | } 365 | 366 | this._responseHandler = handler 367 | this._responseOptions = options 368 | } 369 | 370 | /** 371 | * @param {(error: Error)=>void} handler 372 | */ 373 | onError (handler) { 374 | this._errorHandler = handler 375 | } 376 | } 377 | 378 | module.exports = { 379 | CQEventBus, CQEvent 380 | } 381 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const $WebSocket = require('websocket').w3cwebsocket 2 | const shortid = require('shortid') 3 | const $get = require('lodash.get') 4 | const $CQEventBus = require('./event-bus.js').CQEventBus 5 | const $Callable = require('./util/callable') 6 | const message = require('./message') 7 | const { parse: parseCQTags } = message 8 | const { 9 | SocketError, 10 | InvalidWsTypeError, 11 | InvalidContextError, 12 | APITimeoutError, 13 | UnexpectedContextError 14 | } = require('./errors') 15 | 16 | const WebSocketType = { 17 | API: '/api', 18 | EVENT: '/event' 19 | } 20 | 21 | const WebSocketState = { 22 | DISABLED: -1, INIT: 0, CONNECTING: 1, CONNECTED: 2, CLOSING: 3, CLOSED: 4 23 | } 24 | 25 | const WebSocketProtocols = [ 26 | 'https:', 27 | 'http:', 28 | 'ws:', 29 | 'wss:' 30 | ] 31 | 32 | class CQWebSocket extends $Callable { 33 | constructor ({ 34 | // connectivity configs 35 | protocol = 'ws:', 36 | host = '127.0.0.1', 37 | port = 6700, 38 | accessToken = '', 39 | baseUrl, 40 | 41 | // application aware configs 42 | enableAPI = true, 43 | enableEvent = true, 44 | qq = -1, 45 | 46 | // reconnection configs 47 | reconnection = true, 48 | reconnectionAttempts = Infinity, 49 | reconnectionDelay = 1000, 50 | 51 | // API request options 52 | requestOptions = {}, 53 | 54 | // underlying websocket configs, only meaningful in Nodejs environment 55 | fragmentOutgoingMessages = false, 56 | fragmentationThreshold, 57 | tlsOptions 58 | } = {}) { 59 | super('__call__') 60 | 61 | /// *****************/ 62 | // poka-yoke 😇 63 | /// *****************/ 64 | protocol = protocol.toLowerCase() 65 | if (protocol && !protocol.endsWith(':')) protocol += ':' 66 | if ( 67 | baseUrl && 68 | WebSocketProtocols.filter(proto => baseUrl.startsWith(proto + '//')).length === 0 69 | ) { 70 | baseUrl = `${protocol}//${baseUrl}` 71 | } 72 | 73 | /// *****************/ 74 | // options 75 | /// *****************/ 76 | 77 | this._token = String(accessToken) 78 | this._qq = parseInt(qq) 79 | this._baseUrl = baseUrl || `${protocol}//${host}:${port}` 80 | 81 | this._reconnectOptions = { 82 | reconnection, 83 | reconnectionAttempts, 84 | reconnectionDelay 85 | } 86 | 87 | this._requestOptions = requestOptions 88 | 89 | this._wsOptions = { } 90 | 91 | Object 92 | .entries({ 93 | fragmentOutgoingMessages, 94 | fragmentationThreshold, 95 | tlsOptions 96 | }) 97 | .filter(([ k, v ]) => v !== undefined) 98 | .forEach(([ k, v ]) => { 99 | this._wsOptions[k] = v 100 | }) 101 | 102 | /// *****************/ 103 | // states 104 | /// *****************/ 105 | 106 | this._monitor = { 107 | EVENT: { 108 | attempts: 0, 109 | state: enableEvent ? WebSocketState.INIT : WebSocketState.DISABLED, 110 | reconnecting: false 111 | }, 112 | API: { 113 | attempts: 0, 114 | state: enableAPI ? WebSocketState.INIT : WebSocketState.DISABLED, 115 | reconnecting: false 116 | } 117 | } 118 | 119 | /** 120 | * @type {Map} 121 | */ 122 | this._responseHandlers = new Map() 123 | 124 | this._eventBus = new $CQEventBus(this) 125 | } 126 | 127 | off (eventType, handler) { 128 | this._eventBus.off(eventType, handler) 129 | return this 130 | } 131 | 132 | on (eventType, handler) { 133 | this._eventBus.on(eventType, handler) 134 | return this 135 | } 136 | 137 | once (eventType, handler) { 138 | this._eventBus.once(eventType, handler) 139 | return this 140 | } 141 | 142 | __call__ (method, params, optionsIn) { 143 | if (!this._apiSock) return Promise.reject(new Error('API socket has not been initialized.')) 144 | 145 | let options = { 146 | timeout: Infinity, 147 | ...this._requestOptions 148 | } 149 | 150 | if (typeof optionsIn === 'number') { 151 | options.timeout = optionsIn 152 | } else if (typeof optionsIn === 'object') { 153 | options = { 154 | ...options, 155 | ...optionsIn 156 | } 157 | } 158 | 159 | return new Promise((resolve, reject) => { 160 | let ticket 161 | const apiRequest = { 162 | action: method, 163 | params: params 164 | } 165 | 166 | this._eventBus.emit('api.send.pre', apiRequest) 167 | 168 | const onSuccess = (ctxt) => { 169 | if (ticket) { 170 | clearTimeout(ticket) 171 | ticket = undefined 172 | } 173 | this._responseHandlers.delete(reqid) 174 | delete ctxt.echo 175 | resolve(ctxt) 176 | } 177 | 178 | const onFailure = (err) => { 179 | this._responseHandlers.delete(reqid) 180 | reject(err) 181 | } 182 | 183 | const reqid = shortid.generate() 184 | 185 | this._responseHandlers.set(reqid, { onFailure, onSuccess }) 186 | this._apiSock.send(JSON.stringify({ 187 | ...apiRequest, 188 | echo: { reqid } 189 | })) 190 | 191 | this._eventBus.emit('api.send.post') 192 | 193 | if (options.timeout < Infinity) { 194 | ticket = setTimeout(() => { 195 | this._responseHandlers.delete(reqid) 196 | onFailure(new APITimeoutError(options.timeout, apiRequest)) 197 | }, options.timeout) 198 | } 199 | }) 200 | } 201 | 202 | _handle (msgObj) { 203 | switch (msgObj.post_type) { 204 | case 'message': 205 | // parsing coolq tags 206 | const tags = parseCQTags(msgObj.message) 207 | 208 | switch (msgObj.message_type) { 209 | case 'private': 210 | this._eventBus.emit('message.private', msgObj, tags) 211 | break 212 | case 'discuss': 213 | { 214 | // someone is @-ed 215 | const attags = tags.filter(t => t.tagName === 'at') 216 | if (attags.length > 0) { 217 | if (attags.filter(t => t.qq === this._qq).length > 0) { 218 | this._eventBus.emit('message.discuss.@.me', msgObj, tags) 219 | } else { 220 | this._eventBus.emit('message.discuss.@', msgObj, tags) 221 | } 222 | } else { 223 | this._eventBus.emit('message.discuss', msgObj, tags) 224 | } 225 | } 226 | break 227 | case 'group': 228 | { 229 | const attags = tags.filter(t => t.tagName === 'at') 230 | if (attags.length > 0) { 231 | if (attags.filter(t => t.qq === this._qq).length > 0) { 232 | this._eventBus.emit('message.group.@.me', msgObj, tags) 233 | } else { 234 | this._eventBus.emit('message.group.@', msgObj, tags) 235 | } 236 | } else { 237 | this._eventBus.emit('message.group', msgObj, tags) 238 | } 239 | } 240 | break 241 | default: 242 | this._eventBus.emit('error', new UnexpectedContextError( 243 | msgObj, 244 | 'unexpected "message_type"' 245 | )) 246 | } 247 | break 248 | case 'notice': // Added, reason: CQHttp 4.X 249 | switch (msgObj.notice_type) { 250 | case 'group_upload': 251 | this._eventBus.emit('notice.group_upload', msgObj) 252 | break 253 | case 'group_admin': 254 | switch (msgObj.sub_type) { 255 | case 'set': 256 | this._eventBus.emit('notice.group_admin.set', msgObj) 257 | break 258 | case 'unset': 259 | this._eventBus.emit('notice.group_admin.unset', msgObj) 260 | break 261 | default: 262 | this._eventBus.emit('error', new UnexpectedContextError( 263 | msgObj, 264 | 'unexpected "sub_type"' 265 | )) 266 | } 267 | break 268 | case 'group_decrease': 269 | switch (msgObj.sub_type) { 270 | case 'leave': 271 | this._eventBus.emit('notice.group_decrease.leave', msgObj) 272 | break 273 | case 'kick': 274 | this._eventBus.emit('notice.group_decrease.kick', msgObj) 275 | break 276 | case 'kick_me': 277 | this._eventBus.emit('notice.group_decrease.kick_me', msgObj) 278 | break 279 | default: 280 | this._eventBus.emit('error', new UnexpectedContextError( 281 | msgObj, 282 | 'unexpected "sub_type"' 283 | )) 284 | } 285 | break 286 | case 'group_increase': 287 | switch (msgObj.sub_type) { 288 | case 'approve': 289 | this._eventBus.emit('notice.group_increase.approve', msgObj) 290 | break 291 | case 'invite': 292 | this._eventBus.emit('notice.group_increase.invite', msgObj) 293 | break 294 | default: 295 | this._eventBus.emit('error', new UnexpectedContextError( 296 | msgObj, 297 | 'unexpected "sub_type"' 298 | )) 299 | } 300 | break 301 | case 'friend_add': 302 | this._eventBus.emit('notice.friend_add', msgObj) 303 | break 304 | case 'group_ban': 305 | switch (msgObj.sub_type) { 306 | case 'ban': 307 | this._eventBus.emit('notice.group_ban.ban', msgObj) 308 | break 309 | case 'lift_ban': 310 | this._eventBus.emit('notice.group_ban.lift_ban', msgObj) 311 | break 312 | default: 313 | this._eventBus.emit('error', new UnexpectedContextError( 314 | msgObj, 315 | 'unexpected "sub_type"' 316 | )) 317 | } 318 | break 319 | default: 320 | this._eventBus.emit('error', new UnexpectedContextError( 321 | msgObj, 322 | 'unexpected "notice_type"' 323 | )) 324 | } 325 | break 326 | case 'request': 327 | switch (msgObj.request_type) { 328 | case 'friend': 329 | this._eventBus.emit('request.friend', msgObj) 330 | break 331 | case 'group': 332 | switch (msgObj.sub_type) { 333 | case 'add': 334 | this._eventBus.emit('request.group.add', msgObj) 335 | break 336 | case 'invite': 337 | this._eventBus.emit('request.group.invite', msgObj) 338 | break 339 | default: 340 | this._eventBus.emit('error', new UnexpectedContextError( 341 | msgObj, 342 | 'unexpected "sub_type"' 343 | )) 344 | } 345 | break 346 | default: 347 | this._eventBus.emit('error', new UnexpectedContextError( 348 | msgObj, 349 | 'unexpected "request_type"' 350 | )) 351 | } 352 | break 353 | case 'meta_event': 354 | switch (msgObj.meta_event_type) { 355 | case 'lifecycle': 356 | this._eventBus.emit('meta_event.lifecycle', msgObj) 357 | break 358 | case 'heartbeat': 359 | this._eventBus.emit('meta_event.heartbeat', msgObj) 360 | break 361 | default: 362 | this._eventBus.emit('error', new UnexpectedContextError( 363 | msgObj, 364 | 'unexpected "meta_event_type"' 365 | )) 366 | } 367 | break 368 | default: 369 | this._eventBus.emit('error', new UnexpectedContextError( 370 | msgObj, 371 | 'unexpected "post_type"' 372 | )) 373 | } 374 | } 375 | 376 | /** 377 | * @param {(wsType: "/api"|"/event", label: "EVENT"|"API", client: $WebSocket) => void} cb 378 | * @param {"/api"|"/event"} [types] 379 | */ 380 | _forEachSock (cb, types = [ WebSocketType.EVENT, WebSocketType.API ]) { 381 | if (!Array.isArray(types)) { 382 | types = [ types ] 383 | } 384 | 385 | types.forEach((wsType) => { 386 | cb(wsType, wsType === WebSocketType.EVENT ? 'EVENT' : 'API') 387 | }) 388 | } 389 | 390 | isSockConnected (wsType) { 391 | if (wsType === WebSocketType.API) { 392 | return this._monitor.API.state === WebSocketState.CONNECTED 393 | } else if (wsType === WebSocketType.EVENT) { 394 | return this._monitor.EVENT.state === WebSocketState.CONNECTED 395 | } else { 396 | throw new InvalidWsTypeError(wsType) 397 | } 398 | } 399 | 400 | connect (wsType) { 401 | this._forEachSock((_type, _label) => { 402 | if ([ WebSocketState.INIT, WebSocketState.CLOSED ].includes(this._monitor[_label].state)) { 403 | const tokenQS = this._token ? `?access_token=${this._token}` : '' 404 | 405 | let _sock = new $WebSocket(`${this._baseUrl}/${_label.toLowerCase()}${tokenQS}`, undefined, this._wsOptions) 406 | 407 | if (_type === WebSocketType.EVENT) { 408 | this._eventSock = _sock 409 | } else { 410 | this._apiSock = _sock 411 | } 412 | 413 | _sock.addEventListener('open', () => { 414 | this._monitor[_label].state = WebSocketState.CONNECTED 415 | this._eventBus.emit('socket.connect', WebSocketType[_label], _sock, this._monitor[_label].attempts) 416 | if (this._monitor[_label].reconnecting) { 417 | this._eventBus.emit('socket.reconnect', WebSocketType[_label], this._monitor[_label].attempts) 418 | } 419 | this._monitor[_label].attempts = 0 420 | this._monitor[_label].reconnecting = false 421 | 422 | if (this.isReady()) { 423 | this._eventBus.emit('ready', this) 424 | 425 | // if /api is not disabled, it is ready now. 426 | // if qq < 0, it is not configured manually by user 427 | if (this._monitor.API.state !== WebSocketState.DISABLED && this._qq < 0) { 428 | this('get_login_info') 429 | .then((ctxt) => { 430 | this._qq = parseInt($get(ctxt, 'data.user_id', -1)) 431 | }) 432 | .catch(err => { 433 | this._eventBus.emit('error', err) 434 | }) 435 | } 436 | } 437 | }, { 438 | once: true 439 | }) 440 | 441 | const _onMessage = (e) => { 442 | let context 443 | try { 444 | context = JSON.parse(e.data) 445 | } catch (err) { 446 | this._eventBus.emit('error', new InvalidContextError(_type, e.data)) 447 | return 448 | } 449 | 450 | if (_type === WebSocketType.EVENT) { 451 | this._handle(context) 452 | } else { 453 | const reqid = $get(context, 'echo.reqid', '') 454 | 455 | let { onSuccess } = this._responseHandlers.get(reqid) || {} 456 | 457 | if (typeof onSuccess === 'function') { 458 | onSuccess(context) 459 | } 460 | 461 | this._eventBus.emit('api.response', context) 462 | } 463 | } 464 | _sock.addEventListener('message', _onMessage) 465 | 466 | _sock.addEventListener('close', (e) => { 467 | this._monitor[_label].state = WebSocketState.CLOSED 468 | this._eventBus.emit('socket.close', WebSocketType[_label], e.code, e.reason) 469 | // code === 1000 : normal disconnection 470 | if (e.code !== 1000 && this._reconnectOptions.reconnection) { 471 | this.reconnect(this._reconnectOptions.reconnectionDelay, WebSocketType[_label]) 472 | } 473 | 474 | // clean up events 475 | _sock.removeEventListener('message', _onMessage) 476 | 477 | // clean up refs 478 | _sock = null 479 | if (_type === WebSocketType.EVENT) { 480 | this._eventSock = null 481 | } else { 482 | this._apiSock = null 483 | } 484 | }, { 485 | once: true 486 | }) 487 | 488 | _sock.addEventListener('error', () => { 489 | const errMsg = this._monitor[_label].state === WebSocketState.CONNECTING 490 | ? 'Failed to establish the websocket connection.' 491 | : this._monitor[_label].state === WebSocketState.CONNECTED 492 | ? 'The websocket connection has been hung up unexpectedly.' 493 | : `Unknown error occured. Conection state: ${this._monitor[_label].state}` 494 | this._eventBus.emit('socket.error', WebSocketType[_label], new SocketError(errMsg)) 495 | 496 | if (this._monitor[_label].state === WebSocketState.CONNECTED) { 497 | // error occurs after the websocket is connected 498 | this._monitor[_label].state = WebSocketState.CLOSING 499 | this._eventBus.emit('socket.closing', WebSocketType[_label]) 500 | } else if (this._monitor[_label].state === WebSocketState.CONNECTING) { 501 | // error occurs while trying to establish the connection 502 | this._monitor[_label].state = WebSocketState.CLOSED 503 | this._eventBus.emit('socket.failed', WebSocketType[_label], this._monitor[_label].attempts) 504 | if (this._monitor[_label].reconnecting) { 505 | this._eventBus.emit('socket.reconnect_failed', WebSocketType[_label], this._monitor[_label].attempts) 506 | } 507 | this._monitor[_label].reconnecting = false 508 | if (this._reconnectOptions.reconnection && 509 | this._monitor[_label].attempts <= this._reconnectOptions.reconnectionAttempts 510 | ) { 511 | this.reconnect(this._reconnectOptions.reconnectionDelay, WebSocketType[_label]) 512 | } else { 513 | this._eventBus.emit('socket.max_reconnect', WebSocketType[_label], this._monitor[_label].attempts) 514 | } 515 | } 516 | }, { 517 | once: true 518 | }) 519 | 520 | this._monitor[_label].state = WebSocketState.CONNECTING 521 | this._monitor[_label].attempts++ 522 | this._eventBus.emit('socket.connecting', _type, this._monitor[_label].attempts) 523 | } 524 | }, wsType) 525 | return this 526 | } 527 | 528 | disconnect (wsType) { 529 | this._forEachSock((_type, _label) => { 530 | if (this._monitor[_label].state === WebSocketState.CONNECTED) { 531 | const _sock = _type === WebSocketType.EVENT ? this._eventSock : this._apiSock 532 | 533 | this._monitor[_label].state = WebSocketState.CLOSING 534 | // explicitly provide status code to support both browsers and Node environment 535 | _sock.close(1000, 'Normal connection closure') 536 | this._eventBus.emit('socket.closing', _type) 537 | } 538 | }, wsType) 539 | return this 540 | } 541 | 542 | reconnect (delay, wsType) { 543 | if (typeof delay !== 'number') delay = 0 544 | 545 | const _reconnect = (_type, _label) => { 546 | setTimeout(() => { 547 | this.connect(_type) 548 | }, delay) 549 | } 550 | 551 | this._forEachSock((_type, _label) => { 552 | if (this._monitor[_label].reconnecting) return 553 | 554 | switch (this._monitor[_label].state) { 555 | case WebSocketState.CONNECTED: 556 | this._monitor[_label].reconnecting = true 557 | this._eventBus.emit('socket.reconnecting', _type, this._monitor[_label].attempts) 558 | this.disconnect(_type) 559 | this._eventBus.once('socket.close', (_closedType) => { 560 | return _closedType === _type ? _reconnect(_type, _label) : false 561 | }) 562 | break 563 | case WebSocketState.CLOSED: 564 | case WebSocketState.INIT: 565 | this._monitor[_label].reconnecting = true 566 | this._eventBus.emit('socket.reconnecting', _type, this._monitor[_label].attempts) 567 | _reconnect(_type, _label) 568 | } 569 | }, wsType) 570 | return this 571 | } 572 | 573 | isReady () { 574 | let isEventReady = this._monitor.EVENT.state === WebSocketState.DISABLED || this._monitor.EVENT.state === WebSocketState.CONNECTED 575 | let isAPIReady = this._monitor.API.state === WebSocketState.DISABLED || this._monitor.API.state === WebSocketState.CONNECTED 576 | return isEventReady && isAPIReady 577 | } 578 | } 579 | 580 | module.exports = { 581 | default: CQWebSocket, 582 | CQWebSocket, 583 | WebSocketType, 584 | WebSocketState, 585 | SocketError, 586 | InvalidWsTypeError, 587 | InvalidContextError, 588 | APITimeoutError, 589 | UnexpectedContextError, 590 | ...message 591 | } 592 | -------------------------------------------------------------------------------- /src/message/CQTag.js: -------------------------------------------------------------------------------- 1 | const deepEqual = require('deep-equal') 2 | 3 | module.exports = class CQTag { 4 | /** 5 | * @param {string} type 6 | * @param {object} data 7 | */ 8 | constructor (type, data = null) { 9 | this.data = data 10 | this._type = type 11 | this._modifier = null 12 | } 13 | 14 | get tagName () { 15 | return this._type 16 | } 17 | 18 | get modifier () { 19 | return this._modifier || new Proxy({}, { 20 | set: (t, prop, value) => { 21 | // lazy init 22 | this._modifier = { 23 | [prop]: value 24 | } 25 | return true 26 | } 27 | }) 28 | } 29 | 30 | set modifier (val) { 31 | this._modifier = val 32 | } 33 | 34 | /** 35 | * @param {CQTag} another 36 | */ 37 | equals (another) { 38 | if (!(another instanceof CQTag)) return false 39 | if (this._type !== another._type) return false 40 | return deepEqual(this.data, another.data, { 41 | strict: true 42 | }) 43 | } 44 | 45 | toJSON () { 46 | const data = {} 47 | 48 | for (let k of Object.keys(this.data || {})) { 49 | if (this.data[k] !== undefined) { 50 | data[k] = String(this.data[k]) 51 | } 52 | } 53 | 54 | for (let k of Object.keys(this._modifier || {})) { 55 | if (this._modifier[k] !== undefined) { 56 | data[k] = String(this._modifier[k]) 57 | } 58 | } 59 | 60 | return { 61 | type: this._type, 62 | data: Object.keys(data).length > 0 ? data : null 63 | } 64 | } 65 | 66 | valueOf () { 67 | return this.toString() 68 | } 69 | 70 | toString () { 71 | let ret = `[CQ:${this._type}` 72 | 73 | for (let k of Object.keys(this.data || {})) { 74 | if (this.data[k] !== undefined) { 75 | ret += `,${k}=${this.data[k]}` 76 | } 77 | } 78 | 79 | for (let k of Object.keys(this._modifier || {})) { 80 | if (this._modifier[k] !== undefined) { 81 | ret += `,${k}=${this._modifier[k]}` 82 | } 83 | } 84 | 85 | ret += ']' 86 | return ret 87 | } 88 | 89 | /** 90 | * @abstract 91 | * Force data to cast into proper types 92 | */ 93 | coerce () { 94 | return this 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/message/index.js: -------------------------------------------------------------------------------- 1 | const isSupportedTag = require('./isSupportedTag') 2 | const parse = require('./parse') 3 | const models = require('./models') 4 | 5 | module.exports = { 6 | parse, 7 | isSupportedTag, 8 | ...models 9 | } 10 | -------------------------------------------------------------------------------- /src/message/isSupportedTag.js: -------------------------------------------------------------------------------- 1 | const CQTAG_TYPES = [ 2 | 'face', 3 | 'emoji', 4 | 'bface', 5 | 'sface', 6 | 'image', 7 | 'record', 8 | 'at', 9 | 'rps', 10 | 'dice', 11 | 'shake', 12 | 'anonymous', 13 | 'music', 14 | 'share', 15 | 'text' 16 | ] 17 | 18 | module.exports = function isSupportedTag (tagName) { 19 | return CQTAG_TYPES.includes(tagName) 20 | } 21 | -------------------------------------------------------------------------------- /src/message/models/CQAnonymous.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQAnonymous extends CQTag { 4 | constructor (shouldIgnoreIfFailed) { 5 | super('anonymous') 6 | this.modifier.ignore = Boolean(shouldIgnoreIfFailed) 7 | } 8 | 9 | get ignore () { 10 | return this.modifier.ignore 11 | } 12 | 13 | set ignore (shouldIgnoreIfFailed) { 14 | this.modifier.ignore = Boolean(shouldIgnoreIfFailed) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQAt.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQAt extends CQTag { 4 | constructor (qq) { 5 | super('at', { qq }) 6 | } 7 | 8 | get qq () { 9 | return this.data.qq 10 | } 11 | 12 | coerce () { 13 | this.data.qq = Number(this.data.qq) 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQBFace.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQBFace extends CQTag { 4 | /** 5 | * @param {number} id 6 | * @param {string} p the unknown, mysterious "P" 7 | * @see https://github.com/richardchien/coolq-http-api/wiki/CQ-%E7%A0%81%E7%9A%84%E5%9D%91 8 | */ 9 | constructor (id, p) { 10 | super('bface', { id }) 11 | this.modifier.p = p 12 | } 13 | 14 | get id () { 15 | return this.data.id 16 | } 17 | 18 | coerce () { 19 | this.data.id = Number(this.data.id) 20 | return this 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/message/models/CQCustomMusic.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | const optional = require('../../util/optional') 3 | 4 | module.exports = class CQCustomMusic extends CQTag { 5 | constructor (url, audio, title, content, image) { 6 | super('music', { type: 'custom', url, audio, title, content, image }) 7 | } 8 | 9 | get type () { 10 | return 'custom' 11 | } 12 | 13 | get url () { 14 | return this.data.url 15 | } 16 | 17 | get audio () { 18 | return this.data.audio 19 | } 20 | 21 | get title () { 22 | return this.data.title 23 | } 24 | 25 | get content () { 26 | return this.data.content 27 | } 28 | 29 | get image () { 30 | return this.data.image 31 | } 32 | 33 | coerce () { 34 | this.data.type = 'custom' 35 | this.data.url = String(this.data.url) 36 | this.data.audio = String(this.data.audio) 37 | this.data.title = String(this.data.title) 38 | this.data.content = optional(this.data.content, String) 39 | this.data.image = optional(this.data.image, String) 40 | return this 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/message/models/CQDice.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQDice extends CQTag { 4 | constructor () { 5 | super('dice') 6 | } 7 | 8 | get type () { 9 | return this.data.type 10 | } 11 | 12 | coerce () { 13 | this.data.type = Number(this.data.type) 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQEmoji.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQEmoji extends CQTag { 4 | constructor (id) { 5 | super('emoji', { id }) 6 | } 7 | 8 | get id () { 9 | return this.data.id 10 | } 11 | 12 | coerce () { 13 | this.data.id = Number(this.data.id) 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQFace.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQFace extends CQTag { 4 | constructor (id) { 5 | super('face', { id }) 6 | } 7 | 8 | get id () { 9 | return this.data.id 10 | } 11 | 12 | coerce () { 13 | this.data.id = Number(this.data.id) 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQImage.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | const optional = require('../../util/optional') 3 | 4 | module.exports = class CQImage extends CQTag { 5 | constructor (file, cache = true) { 6 | super('image', { file }) 7 | this.cache = cache 8 | } 9 | 10 | get file () { 11 | return this.data.file 12 | } 13 | 14 | get url () { 15 | return this.data.url 16 | } 17 | 18 | get cache () { 19 | return this.modifier.cache 20 | } 21 | 22 | set cache (cache) { 23 | this.modifier.cache = !cache ? 0 : undefined 24 | } 25 | 26 | coerce () { 27 | this.data.file = String(this.data.file) 28 | this.data.url = optional(this.data.url, String) 29 | return this 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/message/models/CQMusic.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQMusic extends CQTag { 4 | constructor (type, id) { 5 | super('music', { type, id }) 6 | } 7 | 8 | get type () { 9 | return this.data.type 10 | } 11 | 12 | get id () { 13 | return this.data.id 14 | } 15 | 16 | coerce () { 17 | this.data.type = String(this.data.type) 18 | this.data.id = Number(this.data.id) 19 | return this 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/message/models/CQRPS.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQRPS extends CQTag { 4 | constructor () { 5 | super('rps') 6 | } 7 | 8 | get type () { 9 | return this.data.type 10 | } 11 | 12 | coerce () { 13 | this.data.type = Number(this.data.type) 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQRecord.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQRecord extends CQTag { 4 | constructor (file, magic) { 5 | super('record', { file }) 6 | this.magic = magic 7 | } 8 | 9 | get file () { 10 | return this.data.file 11 | } 12 | 13 | hasMagic () { 14 | return Boolean(this.modifier.magic) 15 | } 16 | 17 | get magic () { 18 | return this.modifier.magic 19 | } 20 | 21 | set magic (magic) { 22 | this.modifier.magic = magic ? true : undefined 23 | } 24 | 25 | coerce () { 26 | this.data.file = String(this.data.file) 27 | return this 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/message/models/CQSFace.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQSFace extends CQTag { 4 | constructor (id) { 5 | super('sface', { id }) 6 | } 7 | 8 | get id () { 9 | return this.data.id 10 | } 11 | 12 | coerce () { 13 | this.data.id = Number(this.data.id) 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/message/models/CQShake.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQShake extends CQTag { 4 | constructor () { 5 | super('shake') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/message/models/CQShare.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | const optional = require('../../util/optional') 3 | 4 | module.exports = class CQShare extends CQTag { 5 | constructor (url, title, content, image) { 6 | super('share', { url, title, content, image }) 7 | } 8 | 9 | get url () { 10 | return this.data.url 11 | } 12 | 13 | get title () { 14 | return this.data.title 15 | } 16 | 17 | get content () { 18 | return this.data.content 19 | } 20 | 21 | get image () { 22 | return this.data.image 23 | } 24 | 25 | coerce () { 26 | this.data.url = String(this.data.url) 27 | this.data.title = String(this.data.title) 28 | this.data.content = optional(this.data.content, String) 29 | this.data.image = optional(this.data.image, String) 30 | return this 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/message/models/CQText.js: -------------------------------------------------------------------------------- 1 | const CQTag = require('../CQTag') 2 | 3 | module.exports = class CQText extends CQTag { 4 | constructor (text) { 5 | super('text', { text }) 6 | } 7 | 8 | get text () { 9 | return this.data.text 10 | } 11 | 12 | coerce () { 13 | this.data.text = String(this.data.text) 14 | return this 15 | } 16 | 17 | toString () { 18 | return this.data.text 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/message/models/index.js: -------------------------------------------------------------------------------- 1 | const CQAnonymous = require('./CQAnonymous') 2 | const CQAt = require('./CQAt') 3 | const CQBFace = require('./CQBFace') 4 | const CQCustomMusic = require('./CQCustomMusic') 5 | const CQDice = require('./CQDice') 6 | const CQEmoji = require('./CQEmoji') 7 | const CQFace = require('./CQFace') 8 | const CQImage = require('./CQImage') 9 | const CQMusic = require('./CQMusic') 10 | const CQRecord = require('./CQRecord') 11 | const CQRPS = require('./CQRPS') 12 | const CQSFace = require('./CQSFace') 13 | const CQShake = require('./CQShake') 14 | const CQShare = require('./CQShare') 15 | const CQText = require('./CQText') 16 | 17 | module.exports = { 18 | CQAnonymous, 19 | CQAt, 20 | CQBFace, 21 | CQCustomMusic, 22 | CQDice, 23 | CQEmoji, 24 | CQFace, 25 | CQImage, 26 | CQMusic, 27 | CQRecord, 28 | CQRPS, 29 | CQSFace, 30 | CQShake, 31 | CQShare, 32 | CQText 33 | } 34 | -------------------------------------------------------------------------------- /src/message/parse.js: -------------------------------------------------------------------------------- 1 | const isSupportedTag = require('./isSupportedTag') 2 | const CQTag = require('./CQTag') 3 | const { 4 | CQAt, 5 | CQAnonymous, 6 | CQBFace, 7 | CQCustomMusic, 8 | CQDice, 9 | CQEmoji, 10 | CQFace, 11 | CQImage, 12 | CQMusic, 13 | CQRecord, 14 | CQRPS, 15 | CQSFace, 16 | CQShake, 17 | CQShare, 18 | CQText 19 | } = require('./models') 20 | 21 | const CQTAGS_EXTRACTOR = /\[CQ[^\]]*\]/g 22 | const CQTAG_ANALYSOR = /\[CQ:([a-z]+?)(?:,((,?[a-zA-Z0-9-_.]+=[^,[\]]*)*))?\]/ 23 | 24 | function parseData (dataStr = '') { 25 | return dataStr 26 | ? dataStr.split(',') 27 | .map(opt => opt.split('=')) 28 | .reduce((data, [ k, v ]) => { 29 | data[k] = v 30 | return data 31 | }, {}) 32 | : null 33 | } 34 | 35 | function castCQTag (cqtag) { 36 | let proto 37 | switch (cqtag._type) { 38 | case 'anonymous': 39 | proto = CQAnonymous.prototype 40 | break 41 | case 'at': 42 | proto = CQAt.prototype 43 | break 44 | case 'bface': 45 | proto = CQBFace.prototype 46 | break 47 | case 'music': 48 | proto = cqtag.data.type === 'custom' 49 | ? CQCustomMusic.prototype : CQMusic.prototype 50 | break 51 | case 'dice': 52 | proto = CQDice.prototype 53 | break 54 | case 'emoji': 55 | proto = CQEmoji.prototype 56 | break 57 | case 'face': 58 | proto = CQFace.prototype 59 | break 60 | case 'image': 61 | proto = CQImage.prototype 62 | break 63 | case 'record': 64 | proto = CQRecord.prototype 65 | break 66 | case 'rps': 67 | proto = CQRPS.prototype 68 | break 69 | case 'sface': 70 | proto = CQSFace.prototype 71 | break 72 | case 'shake': 73 | proto = CQShake.prototype 74 | break 75 | case 'share': 76 | proto = CQShare.prototype 77 | break 78 | case 'text': 79 | proto = CQText.prototype 80 | } 81 | 82 | return Object.setPrototypeOf(cqtag, proto).coerce() 83 | } 84 | 85 | /** 86 | * @param {string|any[]} message 87 | */ 88 | module.exports = function parse (message) { 89 | if (typeof message === 'string') { 90 | let textTagScanner = 0 91 | const nonTextTags = (message.match(CQTAGS_EXTRACTOR) || []) 92 | .map(_tag => _tag.match(CQTAG_ANALYSOR)) 93 | .filter(_tag => _tag && isSupportedTag(_tag[1])) 94 | .map(_tag => new CQTag(_tag[1], parseData(_tag[2]))) 95 | .map(castCQTag) 96 | 97 | // insert text tags into appropriate position 98 | const ret = nonTextTags.reduce((tags, cqtag, index) => { 99 | const cqtagStr = cqtag.toString() 100 | const cqtagIndex = message.indexOf(cqtagStr) 101 | if (cqtagIndex !== textTagScanner) { 102 | const text = message.substring(textTagScanner, cqtagIndex) 103 | tags.push(new CQText(text)) 104 | } 105 | tags.push(cqtag) 106 | textTagScanner = cqtagIndex + cqtagStr.length 107 | return tags 108 | }, []) 109 | 110 | if (textTagScanner < message.length) { 111 | // there is still text 112 | const text = message.substring(textTagScanner) 113 | ret.push(new CQText(text)) 114 | } 115 | return ret 116 | } 117 | 118 | if (Array.isArray(message)) { 119 | return message 120 | .filter(_tag => isSupportedTag(_tag.type)) 121 | .map(_tag => new CQTag(_tag.type, _tag.data)) 122 | .map(castCQTag) 123 | } 124 | 125 | return [] 126 | } 127 | -------------------------------------------------------------------------------- /src/util/callable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/richardchien/cqhttp-node-sdk/blob/master/src/callable.js 3 | */ 4 | 5 | function CallableInstance (property) { 6 | const func = this.constructor.prototype[property] 7 | const apply = function () { return func.apply(apply, arguments) } 8 | Object.setPrototypeOf(apply, this.constructor.prototype) 9 | Object.getOwnPropertyNames(func).forEach(function (p) { 10 | Object.defineProperty(apply, p, Object.getOwnPropertyDescriptor(func, p)) 11 | }) 12 | return apply 13 | } 14 | CallableInstance.prototype = Object.create(Function.prototype) 15 | 16 | module.exports = CallableInstance 17 | -------------------------------------------------------------------------------- /src/util/optional.js: -------------------------------------------------------------------------------- 1 | module.exports = function optional (value, formatter) { 2 | return value !== undefined ? formatter(value) : undefined 3 | } 4 | -------------------------------------------------------------------------------- /src/util/traverse.js: -------------------------------------------------------------------------------- 1 | module.exports = function traverse (obj, cb = function () {}) { 2 | if (typeof obj !== 'object') return 3 | 4 | Object.keys(obj).forEach(function (k) { 5 | if (cb(obj[k], k, obj) === false) return 6 | traverse(obj[k], cb) 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /test/api/index.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default 2 | 3 | const { 4 | default: defaultExport, 5 | CQWebSocket, 6 | WebSocketType, 7 | WebSocketState, 8 | CQAnonymous, 9 | CQAt, 10 | CQBFace, 11 | CQCustomMusic, 12 | CQDice, 13 | CQEmoji, 14 | CQFace, 15 | CQImage, 16 | CQMusic, 17 | CQRecord, 18 | CQRPS, 19 | CQSFace, 20 | CQShake, 21 | CQShare, 22 | CQText, 23 | SocketError, 24 | InvalidWsTypeError, 25 | InvalidContextError, 26 | APITimeoutError, 27 | UnexpectedContextError 28 | } = require('../..') 29 | 30 | const { CQEvent } = require('../../src/event-bus') 31 | 32 | const exported = [ 33 | CQAnonymous, 34 | CQAt, 35 | CQBFace, 36 | CQCustomMusic, 37 | CQDice, 38 | CQEmoji, 39 | CQFace, 40 | CQImage, 41 | CQMusic, 42 | CQRecord, 43 | CQRPS, 44 | CQSFace, 45 | CQShake, 46 | CQShare, 47 | CQText, 48 | SocketError, 49 | InvalidWsTypeError, 50 | InvalidContextError, 51 | APITimeoutError, 52 | UnexpectedContextError 53 | ] 54 | 55 | test('CQWebSocket is exposed as default export.', (t) => { 56 | t.plan(1) 57 | t.is(defaultExport, CQWebSocket) 58 | }) 59 | 60 | test('API: CQWebSocket', t => { 61 | t.plan(18) 62 | 63 | t.true(typeof CQWebSocket === 'function') 64 | t.true(typeof CQWebSocket.prototype === 'object') 65 | t.true(typeof CQWebSocket.prototype.connect === 'function') 66 | t.is(CQWebSocket.prototype.connect.length, 1) 67 | t.true(typeof CQWebSocket.prototype.disconnect === 'function') 68 | t.is(CQWebSocket.prototype.disconnect.length, 1) 69 | t.true(typeof CQWebSocket.prototype.isReady === 'function') 70 | t.is(CQWebSocket.prototype.isReady.length, 0) 71 | t.true(typeof CQWebSocket.prototype.isSockConnected === 'function') 72 | t.is(CQWebSocket.prototype.isSockConnected.length, 1) 73 | t.true(typeof CQWebSocket.prototype.off === 'function') 74 | t.is(CQWebSocket.prototype.off.length, 2) 75 | t.true(typeof CQWebSocket.prototype.on === 'function') 76 | t.is(CQWebSocket.prototype.on.length, 2) 77 | t.true(typeof CQWebSocket.prototype.once === 'function') 78 | t.is(CQWebSocket.prototype.once.length, 2) 79 | t.true(typeof CQWebSocket.prototype.reconnect === 'function') 80 | t.is(CQWebSocket.prototype.reconnect.length, 2) 81 | }) 82 | 83 | test('API: CQEvent', t => { 84 | t.plan(16) 85 | 86 | t.true(typeof CQEvent === 'function') 87 | t.true(typeof CQEvent.prototype === 'object') 88 | t.true(typeof CQEvent.prototype.appendMessage === 'function') 89 | t.is(CQEvent.prototype.appendMessage.length, 1) 90 | t.true(typeof CQEvent.prototype.getMessage === 'function') 91 | t.is(CQEvent.prototype.getMessage.length, 0) 92 | t.true(typeof CQEvent.prototype.hasMessage === 'function') 93 | t.is(CQEvent.prototype.hasMessage.length, 0) 94 | t.true(typeof CQEvent.prototype.onError === 'function') 95 | t.is(CQEvent.prototype.onError.length, 1) 96 | t.true(typeof CQEvent.prototype.onResponse === 'function') 97 | t.is(CQEvent.prototype.onResponse.length, 2) 98 | t.true(typeof CQEvent.prototype.setMessage === 'function') 99 | t.is(CQEvent.prototype.setMessage.length, 1) 100 | t.true(typeof CQEvent.prototype.stopPropagation === 'function') 101 | t.is(CQEvent.prototype.stopPropagation.length, 0) 102 | }) 103 | 104 | test('API: WebSocketType', t => { 105 | t.plan(1) 106 | t.deepEqual(WebSocketType, { 107 | API: '/api', 108 | EVENT: '/event' 109 | }) 110 | }) 111 | 112 | test('API: WebSocketState', t => { 113 | t.plan(1) 114 | t.deepEqual(WebSocketState, { 115 | DISABLED: -1, INIT: 0, CONNECTING: 1, CONNECTED: 2, CLOSING: 3, CLOSED: 4 116 | }) 117 | }) 118 | 119 | test('API: misc', t => { 120 | t.plan(exported.length) 121 | 122 | exported.forEach(ex => { 123 | t.truthy(ex) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /test/connection/connection.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default 2 | 3 | test.cb('success without failure', require('./success-without-failure')) 4 | test.cb('success after serveral consecutive failures', require('./success-after-failures')) 5 | test.cb('continuous failure without success', require('./failure-without-success')) 6 | test.cb('error after connection established', require('./error-after-success')) 7 | test.cb('manually reconnect after a connection success', require('./manual-reconnect-after-success')) 8 | test.cb('manually reconnect after a normal connection closure', require('./manual-reconnect-after-closed')) 9 | test.cb('multiple calls to #connect() are ignored before the first connection success', require('./multiple-connect-calls-before-success')) 10 | test.cb('multiple calls to #disconnect() are ignored before the first disconnection', require('./multiple-disconnect-calls-before-disconnected')) 11 | test.cb('multiple calls to #reconnect() are ignored before the first reconnection success', require('./multiple-reconnect-calls-before-reconnected')) 12 | -------------------------------------------------------------------------------- /test/connection/error-after-success.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon') 2 | 3 | const setup = require('../fixture/setup') 4 | const { bot, planCount, assertSpies, onError, done } = setup() 5 | 6 | const errorStub = stub() 7 | errorStub.onCall(0).callsFake(onError) 8 | 9 | module.exports = function (t) { 10 | t.plan(planCount()) 11 | 12 | bot 13 | .on('ready', function () { 14 | errorStub() 15 | 16 | if (errorStub.calledTwice) { 17 | // Assertion 18 | assertSpies(t, { connectCount: 4, connectingCount: 4, closingCount: 2, closeCount: 2, errorCount: 2, reconnectingCount: 2, reconnectCount: 2 }) 19 | t.end() 20 | done() 21 | } 22 | }) 23 | .connect() 24 | } 25 | -------------------------------------------------------------------------------- /test/connection/failure-without-success.js: -------------------------------------------------------------------------------- 1 | const RECONNECT_ATTEMPTS = 10 2 | 3 | const setup = require('../fixture/setup') 4 | const { wsStub, bot, planCount, assertSpies, done } = setup({ reconnectionAttempts: RECONNECT_ATTEMPTS }) 5 | 6 | const FakeWebSocket = require('../fixture/FakeWebSocket') 7 | const fws = FakeWebSocket.getSeries(Infinity) 8 | wsStub.callsFake(fws) 9 | 10 | module.exports = function (t) { 11 | t.plan(planCount() + 2) 12 | 13 | const wsTypes = [ '/api', '/event' ] 14 | 15 | bot 16 | .on('socket.max_reconnect', function (wsType, attempts) { 17 | wsTypes.splice(wsTypes.indexOf(wsType), 1) 18 | 19 | // Assertion 20 | // plus 1 is because "re"-connect means it has already tried to connect once 21 | t.is(attempts, RECONNECT_ATTEMPTS + 1) 22 | 23 | if (wsTypes.length === 0) { 24 | const expectedAttempts = (RECONNECT_ATTEMPTS + 1) * 2 25 | assertSpies(t, { 26 | connectCount: 0, 27 | connectingCount: expectedAttempts, 28 | failedCount: expectedAttempts, 29 | errorCount: expectedAttempts, 30 | reconnectingCount: RECONNECT_ATTEMPTS * 2, 31 | reconnectFailedCount: RECONNECT_ATTEMPTS * 2 32 | }) 33 | t.end() 34 | done() 35 | } 36 | }) 37 | .connect() 38 | } 39 | -------------------------------------------------------------------------------- /test/connection/manual-reconnect-after-closed.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon') 2 | const setup = require('../fixture/setup') 3 | 4 | const { bot, planCount, assertSpies, done } = setup() 5 | 6 | const manualReconnectAfterClosed = stub() 7 | manualReconnectAfterClosed.onCall(0).callsFake(function () { 8 | bot.disconnect() 9 | }) 10 | manualReconnectAfterClosed.onCall(1).callsFake(function () { 11 | bot.reconnect() 12 | }) 13 | 14 | module.exports = function (t) { 15 | t.plan(planCount()) 16 | 17 | bot 18 | .on('ready', function () { 19 | if (manualReconnectAfterClosed.callCount === 0) { 20 | manualReconnectAfterClosed() 21 | } else { 22 | // Assertion 23 | assertSpies(t, { connectingCount: 4, connectCount: 4, closingCount: 2, closeCount: 2, reconnectingCount: 2, reconnectCount: 2 }) 24 | t.end() 25 | done() 26 | } 27 | }) 28 | .on('socket.close', function () { 29 | if (manualReconnectAfterClosed.callCount === 1) { 30 | manualReconnectAfterClosed() 31 | } 32 | }) 33 | .connect() 34 | } 35 | -------------------------------------------------------------------------------- /test/connection/manual-reconnect-after-success.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon') 2 | const setup = require('../fixture/setup') 3 | 4 | const { bot, planCount, assertSpies, done } = setup() 5 | 6 | const manualReconnect = stub() 7 | manualReconnect.callsFake(function () { 8 | bot.reconnect() 9 | }) 10 | 11 | module.exports = function (t) { 12 | t.plan(planCount()) 13 | 14 | bot 15 | .on('ready', function () { 16 | if (manualReconnect.called) { 17 | // Assertion 18 | assertSpies(t, { 19 | connectingCount: 4, 20 | connectCount: 4, 21 | closingCount: 2, 22 | closeCount: 2, 23 | reconnectingCount: 2, 24 | reconnectCount: 2 25 | }) 26 | t.end() 27 | done() 28 | } else { 29 | manualReconnect() 30 | } 31 | }) 32 | .connect() 33 | } 34 | -------------------------------------------------------------------------------- /test/connection/multiple-connect-calls-before-success.js: -------------------------------------------------------------------------------- 1 | const setup = require('../fixture/setup') 2 | 3 | const { bot, planCount, wsStub, assertSpies, done } = setup() 4 | 5 | module.exports = function (t) { 6 | t.plan(planCount() + 1) 7 | 8 | bot 9 | .connect() 10 | .connect() 11 | .connect() 12 | .connect() 13 | .connect() 14 | 15 | setTimeout(function () { 16 | bot 17 | .connect() 18 | .connect() 19 | .connect() 20 | .connect() 21 | .connect() 22 | }, 400) // CONNECT_DELAY (500) - 100 (tolerance) 23 | 24 | setTimeout(function () { 25 | // Assertion 26 | assertSpies(t, { connectCount: 2, connectingCount: 2 }) 27 | t.true(wsStub.calledTwice) 28 | t.end() 29 | done() 30 | }, 1000) // CONNECT_DELAY (500) + 500 (tolerance 31 | } 32 | -------------------------------------------------------------------------------- /test/connection/multiple-disconnect-calls-before-disconnected.js: -------------------------------------------------------------------------------- 1 | const { spy } = require('sinon') 2 | 3 | const setup = require('../fixture/setup') 4 | const { bot, planCount, assertSpies, done } = setup() 5 | 6 | module.exports = function (t) { 7 | t.plan(planCount() + 2) 8 | 9 | bot 10 | .on('ready', function () { 11 | const closeSpies = { 12 | EVENT: spy(bot._eventSock, 'close'), 13 | API: spy(bot._apiSock, 'close') 14 | } 15 | 16 | bot 17 | .disconnect() 18 | .disconnect() 19 | .disconnect() 20 | .disconnect() 21 | .disconnect() 22 | 23 | setTimeout(function () { 24 | bot 25 | .disconnect() 26 | .disconnect() 27 | .disconnect() 28 | .disconnect() 29 | .disconnect() 30 | }, 400) // close_delay - 100 31 | 32 | setTimeout(function () { 33 | // Assertion 34 | assertSpies(t, { connectCount: 2, connectingCount: 2, closingCount: 2, closeCount: 2 }) 35 | t.true(closeSpies.EVENT.calledOnce) 36 | t.true(closeSpies.API.calledOnce) 37 | t.end() 38 | done() 39 | 40 | closeSpies.EVENT.restore() 41 | closeSpies.API.restore() 42 | }, 1000) // close_delay + 500 43 | }) 44 | .connect() 45 | } 46 | -------------------------------------------------------------------------------- /test/connection/multiple-reconnect-calls-before-reconnected.js: -------------------------------------------------------------------------------- 1 | const { spy } = require('sinon') 2 | 3 | const setup = require('../fixture/setup') 4 | const { bot, wsStub, planCount, assertSpies, done } = setup() 5 | 6 | module.exports = function (t) { 7 | t.plan(planCount() + 3) 8 | 9 | let hasRun = false 10 | 11 | bot 12 | .on('ready', function () { 13 | if (hasRun) return 14 | 15 | hasRun = true 16 | 17 | const closeSpies = { 18 | EVENT: spy(bot._eventSock, 'close'), 19 | API: spy(bot._apiSock, 'close') 20 | } 21 | 22 | bot 23 | .reconnect() 24 | .reconnect() 25 | .reconnect() 26 | .reconnect() 27 | .reconnect() 28 | 29 | setTimeout(function () { 30 | bot 31 | .reconnect() 32 | .reconnect() 33 | .reconnect() 34 | .reconnect() 35 | .reconnect() 36 | }, 900) // connect_delay + close_delay - 100 (tolerance) 37 | 38 | setTimeout(function () { 39 | // Assertion 40 | assertSpies(t, { 41 | connectingCount: 4, 42 | connectCount: 4, 43 | closingCount: 2, 44 | closeCount: 2, 45 | reconnectingCount: 2, 46 | reconnectCount: 2 47 | }) 48 | t.is(wsStub.callCount, 4) // API and EVENT of #connect() + API and EVENT of #reconnect() 49 | t.true(closeSpies.EVENT.calledOnce) 50 | t.true(closeSpies.API.calledOnce) 51 | t.end() 52 | done() 53 | 54 | closeSpies.EVENT.restore() 55 | closeSpies.API.restore() 56 | }, 1500) // connect_delay + close_delay + 500 (tolerance) 57 | }) 58 | .connect() 59 | } 60 | -------------------------------------------------------------------------------- /test/connection/success-after-failures.js: -------------------------------------------------------------------------------- 1 | // configs 2 | const FAILURE_COUNT = 5 3 | 4 | const setup = require('../fixture/setup') 5 | const FakeWebSocket = require('../fixture/FakeWebSocket') 6 | // since there should be 5 failed /event socks and 5 failed /api socks 7 | const fws = FakeWebSocket.getSeries(FAILURE_COUNT * 2) 8 | 9 | const { wsStub, bot, planCount, assertSpies, done } = setup() 10 | wsStub.callsFake(fws) 11 | 12 | module.exports = function (t) { 13 | t.plan(planCount()) 14 | 15 | bot 16 | .on('socket.error', () => {}) 17 | .on('ready', function () { 18 | // Assertion 19 | assertSpies(t, { 20 | connectCount: 2, 21 | connectingCount: FAILURE_COUNT * 2 + 2, 22 | failedCount: FAILURE_COUNT * 2, 23 | errorCount: FAILURE_COUNT * 2, 24 | reconnectingCount: FAILURE_COUNT * 2, 25 | reconnectCount: 2, 26 | reconnectFailedCount: (FAILURE_COUNT - 1) * 2 27 | }) 28 | t.end() 29 | done() 30 | }) 31 | .connect() 32 | } 33 | -------------------------------------------------------------------------------- /test/connection/success-without-failure.js: -------------------------------------------------------------------------------- 1 | const setup = require('../fixture/setup') 2 | 3 | const { bot, planCount, assertSpies, done } = setup() 4 | 5 | module.exports = function (t) { 6 | t.plan(planCount()) 7 | 8 | bot 9 | // .on('socket.connecting', () => { 10 | // console.log(Object.values(spies).map(s => s.callCount)) 11 | // }) 12 | .on('ready', function () { 13 | // Assertion 14 | assertSpies(t, { connectCount: 2, connectingCount: 2 }) 15 | t.end() 16 | done() 17 | }) 18 | .connect() 19 | } 20 | -------------------------------------------------------------------------------- /test/events/cq-event.test.js: -------------------------------------------------------------------------------- 1 | const EMIT_DELAY = 100 2 | 3 | // stuffs of stubbing 4 | const { stub, spy } = require('sinon') 5 | 6 | const { CQWebSocketAPI: { CQWebSocket, CQAt, APITimeoutError } } = require('../fixture/connect-success')() 7 | const test = require('ava').default 8 | 9 | const MSG_OBJ = { 10 | post_type: 'message', 11 | message_type: 'private', 12 | message: 'Test' 13 | } 14 | 15 | function emitMessage (t) { 16 | setTimeout(function () { 17 | t.context.sock.onMessage(JSON.stringify(MSG_OBJ)) 18 | }, EMIT_DELAY) 19 | } 20 | 21 | test.beforeEach.cb(function (t) { 22 | t.context.bot = new CQWebSocket() 23 | .on('ready', function () { 24 | t.context.sock = t.context.bot._eventSock 25 | t.end() 26 | }) 27 | .connect() 28 | t.context.callSpy = spy(t.context.bot._eventBus, '_bot') 29 | }) 30 | 31 | test.cb('CQEvent: return string in message event listener', function (t) { 32 | t.plan(2) 33 | 34 | t.context.bot 35 | .on('message.private', function () { 36 | return 'ok!' 37 | }) 38 | .on('message.private', function () { 39 | return 'nothing...' 40 | }) 41 | .on('message', function () { 42 | return 'failed...' 43 | }) 44 | .on('api.send.post', function () { 45 | t.is(t.context.callSpy.firstCall.args[0], 'send_msg') 46 | t.is(t.context.callSpy.firstCall.args[1].message, 'ok!') 47 | t.end() 48 | }) 49 | 50 | emitMessage(t) 51 | }) 52 | 53 | test.cb('CQEvent: return a list of CQTag/CQHTTPMessage objects in message event listener', function (t) { 54 | t.plan(2) 55 | 56 | t.context.bot 57 | .on('message.private', function () { 58 | return [ 59 | { 60 | type: 'text', 61 | data: { 62 | text: 'ok!' 63 | } 64 | }, 65 | new CQAt(123456789) 66 | ] 67 | }) 68 | .on('message.private', function () { 69 | return [ 70 | { 71 | type: 'text', 72 | data: { 73 | text: 'nothing...' 74 | } 75 | }, 76 | new CQAt(-1) 77 | ] 78 | }) 79 | .on('message', function () { 80 | return [ 81 | { 82 | type: 'text', 83 | data: { 84 | text: 'failed...' 85 | } 86 | }, 87 | new CQAt(-2) 88 | ] 89 | }) 90 | .on('api.send.post', function () { 91 | t.is(t.context.callSpy.firstCall.args[0], 'send_msg') 92 | t.deepEqual(t.context.callSpy.firstCall.args[1].message, [ 93 | { 94 | type: 'text', 95 | data: { 96 | text: 'ok!' 97 | } 98 | }, 99 | { 100 | type: 'at', 101 | data: { 102 | qq: '123456789' 103 | } 104 | } 105 | ]) 106 | t.end() 107 | }) 108 | 109 | emitMessage(t) 110 | }) 111 | 112 | test.cb('CQEvent: can decide which type of messages to append on according to the format', function (t) { 113 | t.plan(2) 114 | 115 | t.context.bot 116 | .on('message.private', function (e) { 117 | e.setMessage([ new CQAt(1), ' ' ]) 118 | }) 119 | .on('message.private', function (e) { 120 | const msg = e.messageFormat === 'array' 121 | ? { type: 'text', data: { text: 'hello' } } 122 | : 'hello' 123 | e.appendMessage(msg) 124 | }) 125 | .on('message.private', function (e) { 126 | e.appendMessage(' world') 127 | }) 128 | .on('api.send.post', function () { 129 | t.is(t.context.callSpy.firstCall.args[0], 'send_msg') 130 | t.deepEqual(t.context.callSpy.firstCall.args[1].message, [ 131 | { 132 | type: 'at', 133 | data: { 134 | qq: '1' 135 | } 136 | }, 137 | { 138 | type: 'text', 139 | data: { 140 | text: ' ' 141 | } 142 | }, 143 | { 144 | type: 'text', 145 | data: { 146 | text: 'hello' 147 | } 148 | }, 149 | { 150 | type: 'text', 151 | data: { 152 | text: ' world' 153 | } 154 | } 155 | ]) 156 | t.end() 157 | }) 158 | 159 | emitMessage(t) 160 | }) 161 | 162 | test.cb('CQEvent: modify the response message on the CQEvent across multiple listeners', function (t) { 163 | t.plan(3) 164 | 165 | t.context.bot 166 | .on('message.private', function (e) { 167 | e.setMessage('o') 168 | }) 169 | .on('message.private', function (e) { 170 | if (e.hasMessage()) { 171 | e.setMessage(e.getMessage() + 'k') 172 | } 173 | }) 174 | .on('message', function (e) { 175 | e.appendMessage('!') 176 | t.is(e.messageFormat, 'string') 177 | }) 178 | .on('api.send.post', function () { 179 | t.is(t.context.callSpy.firstCall.args[0], 'send_msg') 180 | t.is(t.context.callSpy.firstCall.args[1].message, 'ok!') 181 | t.end() 182 | }) 183 | 184 | emitMessage(t) 185 | }) 186 | 187 | test.cb('CQEvent: gain the right of making a response via #stopPropagation()', function (t) { 188 | t.plan(2) 189 | 190 | t.context.bot 191 | .on('message.private', function (e) { 192 | e.setMessage('ok!') 193 | e.stopPropagation() 194 | }) 195 | .on('message.private', function (e) { 196 | e.setMessage('error!') 197 | }) 198 | .on('api.send.post', function () { 199 | t.is(t.context.callSpy.firstCall.args[0], 'send_msg') 200 | t.is(t.context.callSpy.firstCall.args[1].message, 'ok!') 201 | t.end() 202 | }) 203 | 204 | emitMessage(t) 205 | }) 206 | 207 | test.cb('CQEvent: listen for response result on the CQEvent', function (t) { 208 | t.plan(1) 209 | 210 | const stubSend = stub(t.context.bot._apiSock, 'send') 211 | stubSend.callsFake(function (data) { 212 | const { echo } = JSON.parse(data) 213 | this.onMessage(JSON.stringify({ 214 | retcode: 0, 215 | echo 216 | })) 217 | }) 218 | 219 | t.context.bot 220 | .on('message.private', function (e) { 221 | e.onResponse(function (ctxt) { 222 | t.is(ctxt.retcode, 0) 223 | t.end() 224 | stubSend.restore() 225 | }, 5000) // timeout: 5 sec 226 | 227 | return 'some messages' 228 | }) 229 | emitMessage(t) 230 | }) 231 | 232 | test.cb('CQEvent: can work without response handler', function (t) { 233 | t.plan(1) 234 | 235 | const stubSend = stub(t.context.bot._apiSock, 'send') 236 | stubSend.callsFake(function (data) { 237 | const { echo, params: { message } } = JSON.parse(data) 238 | t.is(message, 'some messages') 239 | this.onMessage(JSON.stringify({ 240 | retcode: 0, 241 | echo 242 | })) 243 | }) 244 | 245 | t.context.bot 246 | .on('message.private', function () { 247 | setTimeout(() => { 248 | t.end() 249 | }, 1000) 250 | return 'some messages' 251 | }) 252 | emitMessage(t) 253 | }) 254 | 255 | test.cb('CQEvent: listen for response error on the CQEvent', function (t) { 256 | t.plan(2) 257 | 258 | const errorSpy = spy() 259 | 260 | t.context.bot 261 | .on('error', errorSpy) 262 | .on('message.private', function (e) { 263 | e.onResponse({ 264 | timeout: 1000 265 | }) 266 | 267 | e.onError(function (err) { 268 | t.true(err instanceof APITimeoutError) 269 | t.true(errorSpy.notCalled) 270 | t.end() 271 | }) 272 | 273 | return 'some messages' 274 | }) 275 | 276 | emitMessage(t) 277 | }) 278 | 279 | test.cb('CQEvent: response timeout without error handler', function (t) { 280 | t.plan(2) 281 | 282 | t.context.bot 283 | .on('error', err => { 284 | t.true(err instanceof APITimeoutError) 285 | t.end() 286 | }) 287 | .on('message.private', function (e) { 288 | e.onResponse({ 289 | timeout: 1000 290 | }) 291 | 292 | // no e.onError() is called 293 | t.falsy(e._errorHandler) 294 | 295 | return 'some messages' 296 | }) 297 | 298 | emitMessage(t) 299 | }) 300 | -------------------------------------------------------------------------------- /test/events/events.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | 'message.private', 3 | 'message.discuss', 4 | 'message.discuss.@.me', 5 | 'message.group', 6 | 'message.group.@.me', 7 | 8 | 'notice.group_upload', 9 | 'notice.group_admin.set', 10 | 'notice.group_admin.unset', 11 | 'notice.group_decrease.leave', 12 | 'notice.group_decrease.kick', 13 | 'notice.group_decrease.kick_me', 14 | 'notice.group_increase.approve', 15 | 'notice.group_increase.invite', 16 | 'notice.friend_add', 17 | 'notice.group_ban.lift_ban', 18 | 'notice.group_ban.ban', 19 | 20 | 'request.friend', 21 | 'request.group.add', 22 | 'request.group.invite', 23 | 24 | 'meta_event.lifecycle', 25 | 'meta_event.heartbeat' 26 | ] 27 | -------------------------------------------------------------------------------- /test/events/events.test.js: -------------------------------------------------------------------------------- 1 | const EMIT_DELAY = 100 2 | 3 | // stuffs of stubbing 4 | const { spy } = require('sinon') 5 | 6 | const { CQWebSocketAPI } = require('../fixture/connect-success')() 7 | const { CQEvent } = require('../../src/event-bus') 8 | const test = require('ava').default 9 | 10 | function emitEvent (t, msgObj = {}) { 11 | setTimeout(function () { 12 | t.context.sock.onMessage(JSON.stringify(msgObj)) 13 | }, EMIT_DELAY) 14 | } 15 | 16 | function macro (t, event, { rawMessage } = {}) { 17 | let postfix = '' 18 | const matched = event.match(/^(message\.(discuss|group))(\.@\.me)$/) 19 | if (matched) { 20 | event = matched[1] 21 | postfix = matched[3] 22 | } 23 | 24 | const arrEvent = event.split('.') 25 | const [ majorType, minorType, subType ] = arrEvent 26 | 27 | if (!minorType) { 28 | t.fail('Invalid minor type') 29 | t.end() 30 | return 31 | } 32 | 33 | const msgObj = { 34 | post_type: majorType 35 | } 36 | 37 | if (subType) { 38 | msgObj.sub_type = subType 39 | } 40 | 41 | switch (majorType) { 42 | case 'message': 43 | msgObj.message_type = minorType 44 | msgObj.message = rawMessage || `${postfix === '.@.me' ? `[CQ:at,qq=${t.context.bot._qq}]` : ''} test` 45 | break 46 | case 'notice': 47 | msgObj.notice_type = minorType 48 | break 49 | case 'request': 50 | msgObj.request_type = minorType 51 | break 52 | case 'meta_event': 53 | msgObj.meta_event_type = minorType 54 | break 55 | default: 56 | t.fail('Invalid major type') 57 | t.end() 58 | return 59 | } 60 | 61 | /** 62 | * @type {sinon.SinonSpy[]} 63 | */ 64 | let spies = [] 65 | for (let i = 0; i < arrEvent.length; i++) { 66 | const _spy = spy() 67 | spies.push(_spy) 68 | 69 | const prefix = i > 0 ? arrEvent[i - 1] + '.' : '' 70 | arrEvent[i] = prefix + arrEvent[i] 71 | t.context.bot.on(arrEvent[i], _spy) 72 | } 73 | 74 | if (postfix === '.@.me') { 75 | const _spy1 = spy() 76 | const _spy2 = spy() 77 | spies.push(_spy1) 78 | spies.push(_spy2) 79 | t.context.bot.on(arrEvent[arrEvent.length - 1] + '.@', _spy1) 80 | t.context.bot.on(arrEvent[arrEvent.length - 1] + '.@.me', _spy2) 81 | } 82 | 83 | t.plan(spies.length * (majorType === 'message' ? 4 : 3) - 1) 84 | 85 | // Assertion after root event has been emitted 86 | t.context.bot.on(arrEvent[0], function () { 87 | // 相關母子事件均被觸發過 88 | spies.forEach((_spy, i) => { 89 | t.true(_spy.calledOnce) 90 | }) 91 | 92 | // 觸發參數 93 | if (majorType === 'message') { 94 | spies.forEach((_spy, i) => { 95 | if (_spy.firstCall === null) { 96 | // console.log(i) 97 | } 98 | t.true(_spy.firstCall.args[0] instanceof CQEvent) 99 | t.deepEqual(_spy.firstCall.args[1], msgObj) 100 | }) 101 | } else { 102 | spies.forEach(_spy => { 103 | t.deepEqual(_spy.firstCall.args[0], msgObj) 104 | }) 105 | } 106 | 107 | // 觸發順序 108 | spies.forEach((_spy, i) => { 109 | if (i < spies.length - 1) { 110 | t.true(_spy.calledAfter(spies[i + 1])) 111 | } 112 | }) 113 | 114 | t.end() 115 | }) 116 | 117 | emitEvent(t, msgObj) 118 | } 119 | 120 | test.beforeEach.cb(function (t) { 121 | t.context.bot = new CQWebSocketAPI.CQWebSocket() 122 | .on('ready', function () { 123 | t.context.sock = t.context.bot._eventSock 124 | t.end() 125 | }) 126 | .connect() 127 | }) 128 | 129 | /** 130 | * @type {string[]} 131 | */ 132 | const eventlist = require('./events') 133 | eventlist.forEach(function (event) { 134 | test.cb(`Event [${event}]`, macro, event) 135 | }) 136 | 137 | const extraMsgEvents = [ 'message.discuss.@', 'message.group.@' ] 138 | extraMsgEvents.forEach(function (event) { 139 | test.cb(`Event [${event}]: someone @-ed but not bot`, macro, event, { rawMessage: '[CQ:at,qq=987654321]' }) 140 | }) 141 | 142 | function invalidEventMacro (t, msgObj) { 143 | t.plan(4) 144 | 145 | const _spy = spy() 146 | t.context.bot 147 | .on('error', _spy) 148 | .on('error', (err) => { 149 | t.true(err instanceof CQWebSocketAPI.UnexpectedContextError) 150 | t.true(_spy.calledOnce) 151 | t.true(typeof err.context === 'object') 152 | t.true(typeof err.reason === 'string') 153 | t.end() 154 | }) 155 | 156 | emitEvent(t, msgObj) 157 | } 158 | 159 | test.cb(`Event [fake event]`, invalidEventMacro, { 160 | post_type: 'fake' 161 | }) 162 | 163 | test.cb(`Event [invalid message]`, invalidEventMacro, { 164 | post_type: 'message', 165 | message_type: 'fake' 166 | }) 167 | 168 | test.cb(`Event [invalid notice]`, invalidEventMacro, { 169 | post_type: 'notice', 170 | notice_type: 'fake' 171 | }) 172 | 173 | test.cb(`Event [invalid notice:group_admin]`, invalidEventMacro, { 174 | post_type: 'notice', 175 | notice_type: 'group_admin' 176 | }) 177 | 178 | test.cb(`Event [invalid notice:group_increase]`, invalidEventMacro, { 179 | post_type: 'notice', 180 | notice_type: 'group_increase' 181 | }) 182 | 183 | test.cb(`Event [invalid notice:group_decrease]`, invalidEventMacro, { 184 | post_type: 'notice', 185 | notice_type: 'group_decrease' 186 | }) 187 | 188 | test.cb(`Event [invalid request]`, invalidEventMacro, { 189 | post_type: 'request', 190 | request_type: 'fake' 191 | }) 192 | 193 | test.cb(`Event [invalid request:group]`, invalidEventMacro, { 194 | post_type: 'request', 195 | request_type: 'group' 196 | }) 197 | 198 | test.cb(`Event [invalid meta_event]`, invalidEventMacro, { 199 | post_type: 'meta_event', 200 | meta_event_type: 'fake' 201 | }) 202 | -------------------------------------------------------------------------------- /test/events/invalid-context.test.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon') 2 | const test = require('ava').default 3 | const { CQWebSocketAPI: { CQWebSocket } } = require('../fixture/connect-success')() 4 | const { InvalidContextError } = require('../../src/errors') 5 | 6 | test.cb('InvalidContextError', function (t) { 7 | t.plan(2) 8 | 9 | const bot = new CQWebSocket() 10 | .on('ready', function () { 11 | const stubSend = stub(bot._apiSock, 'send') 12 | stubSend.callsFake(function () { 13 | this.onMessage('{ "fake": json,,, }') 14 | }) 15 | 16 | bot('test', { 17 | foo: 'bar' 18 | }) 19 | }) 20 | .on('error', err => { 21 | t.true(err instanceof InvalidContextError) 22 | t.is(err.data, '{ "fake": json,,, }') 23 | t.end() 24 | }) 25 | .connect() 26 | }) 27 | -------------------------------------------------------------------------------- /test/features/auto-fetch-qq.test.js: -------------------------------------------------------------------------------- 1 | const { CQWebSocketAPI: { CQWebSocket, APITimeoutError } } = require('../fixture/connect-success')() 2 | const { stub } = require('sinon') 3 | const test = require('ava').default 4 | 5 | test.cb('Auto-fetch if no QQ account provided.', function (t) { 6 | t.plan(2) 7 | 8 | const bot = new CQWebSocket() 9 | t.is(bot._qq, -1) 10 | 11 | let stubSend 12 | bot 13 | .on('ready', function () { 14 | stubSend = stub(bot._apiSock, 'send') 15 | stubSend.callsFake(function (data) { 16 | const { echo } = JSON.parse(data) 17 | this.onMessage(JSON.stringify({ 18 | data: { 19 | nickname: 'fake-QQ', 20 | user_id: 123456789 21 | }, 22 | retcode: 0, 23 | status: 'ok', 24 | echo 25 | })) 26 | }) 27 | }) 28 | .on('api.response', () => { 29 | // when emitting api.response, the call to '/get_login_info' has actually been resolved 30 | // but the Promise state changes at the next tick 31 | setImmediate(() => { 32 | t.is(bot._qq, 123456789) 33 | t.end() 34 | }) 35 | }) 36 | .connect() 37 | }) 38 | 39 | test.cb('Auto-fetch failure due to gloabal request timeout', function (t) { 40 | t.plan(2) 41 | 42 | const bot = new CQWebSocket({ requestOptions: { timeout: 2000 } }) 43 | 44 | bot 45 | .on('error', err => { 46 | t.true(err instanceof APITimeoutError) 47 | t.is(err.req.action, 'get_login_info') 48 | t.end() 49 | }) 50 | .connect() 51 | }) 52 | -------------------------------------------------------------------------------- /test/fixture/CQFakeTag.js: -------------------------------------------------------------------------------- 1 | const shortid = require('shortid') 2 | const CQTag = require('../../src/message/CQTag') 3 | 4 | module.exports = class CQFakeTag extends CQTag { 5 | constructor () { 6 | super('fake', { id: shortid() }) 7 | } 8 | 9 | get id () { 10 | return this.data.id 11 | } 12 | 13 | coerce () { 14 | this.data.id = String(this.data.id) 15 | return this 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/fixture/FakeWebSocket.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events') 2 | 3 | class FakeWebSocket extends EventEmitter { 4 | constructor ({ 5 | connectBehavior = true, 6 | CONNECT_DELAY = 500, 7 | CLOSE_DELAY = 500, 8 | MSG_DELAY = 500, 9 | ERROR_DELAY = 500, 10 | } = {}) { 11 | super() 12 | 13 | this.options = { 14 | CONNECT_DELAY, 15 | CLOSE_DELAY, 16 | MSG_DELAY, 17 | ERROR_DELAY 18 | } 19 | 20 | if (connectBehavior) { 21 | this.onOpen() 22 | } else { 23 | this.onError(true) 24 | } 25 | } 26 | 27 | addEventListener (event, listener, { once = false } = {}) { 28 | return once ? this.once(event, listener) : this.on(event, listener) 29 | } 30 | 31 | removeEventListener (event, listener) { 32 | return this.removeListener(event, listener) 33 | } 34 | 35 | send (data) { 36 | 37 | } 38 | 39 | close (code = 1000, reason = 'Normal connection closure') { 40 | setTimeout (() => { 41 | this.emit('close', { code, reason }) 42 | }, this.options.CLOSE_DELAY) 43 | } 44 | 45 | onMessage (data) { 46 | setTimeout(() => { 47 | this.emit('message', { data }) 48 | }, this.options.MSG_DELAY) 49 | } 50 | 51 | onError (wsClean) { 52 | setTimeout(() => { 53 | this.emit('error', { type: 'error' }) 54 | if (!wsClean) { 55 | this.close(5000, 'Closed since the fake error.') 56 | } 57 | }, this.options.ERROR_DELAY) 58 | } 59 | 60 | onOpen () { 61 | setTimeout(() => { 62 | this.emit('open') 63 | }, this.options.CONNECT_DELAY) 64 | } 65 | 66 | static getSeries (failureCount = 0, options) { 67 | const it = series(failureCount, options) 68 | return function fakeWebSocket () { 69 | return it.next().value 70 | } 71 | } 72 | } 73 | 74 | function* series (failureCount = 0, options = {}) { 75 | for (let i = 0; i < failureCount; i++) { 76 | yield new FakeWebSocket({ ...options, connectBehavior: false }) 77 | } 78 | 79 | while (true) { 80 | yield new FakeWebSocket() 81 | } 82 | } 83 | 84 | module.exports = FakeWebSocket 85 | -------------------------------------------------------------------------------- /test/fixture/connect-success.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon') 2 | const proxyquire = require('proxyquire').noCallThru() 3 | 4 | const FakeWebSocket = require('./FakeWebSocket') 5 | const fws = FakeWebSocket.getSeries() 6 | 7 | module.exports = function () { 8 | const wsStub = stub() 9 | wsStub.callsFake(fws) 10 | 11 | const CQWebSocketAPI = proxyquire('../..', { 12 | websocket: { 13 | w3cwebsocket: wsStub 14 | } 15 | }) 16 | 17 | return { 18 | wsStub, 19 | CQWebSocketAPI 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/fixture/setup.js: -------------------------------------------------------------------------------- 1 | const { spy } = require('sinon') 2 | 3 | const connectSuccess = require('./connect-success') 4 | 5 | module.exports = function (options) { 6 | const { wsStub, CQWebSocketAPI: { CQWebSocket, WebSocketType } } = connectSuccess() 7 | 8 | const bot = new CQWebSocket(options) 9 | const spies = { 10 | connecting: spy(), 11 | connect: spy(), 12 | failed: spy(), 13 | reconnecting: spy(), 14 | reconnect: spy(), 15 | reconnect_failed: spy(), 16 | closing: spy(), 17 | close: spy(), 18 | error: spy() 19 | } 20 | 21 | bot 22 | .on('socket.connecting', spies.connecting) 23 | .on('socket.connect', spies.connect) 24 | .on('socket.reconnecting', spies.reconnecting) 25 | .on('socket.reconnect', spies.reconnect) 26 | .on('socket.reconnect_failed', spies.reconnect_failed) 27 | .on('socket.closing', spies.closing) 28 | .on('socket.close', spies.close) 29 | .on('socket.failed', spies.failed) 30 | .on('socket.error', spies.error) 31 | 32 | return { 33 | bot, 34 | spies, 35 | wsStub, 36 | 37 | onMessage (wsType, data) { 38 | if (!wsType || wsType === WebSocketType.EVENT) { 39 | bot._eventSock.onMessage(data) 40 | } 41 | if (!wsType || wsType === WebSocketType.API) { 42 | bot._apiSock.onMessage(data) 43 | } 44 | }, 45 | 46 | onError (wsType) { 47 | if (!wsType || wsType === WebSocketType.EVENT) { 48 | bot._eventSock.onError() 49 | } 50 | if (!wsType || wsType === WebSocketType.API) { 51 | bot._apiSock.onError() 52 | } 53 | }, 54 | 55 | planCount () { 56 | return 9 57 | }, 58 | 59 | assertSpies ( 60 | t, 61 | { 62 | connectingCount = 0, 63 | connectCount = 0, 64 | failedCount = 0, 65 | reconnectingCount = 0, 66 | reconnectCount = 0, 67 | reconnectFailedCount = 0, 68 | closingCount = 0, 69 | closeCount = 0, 70 | errorCount = 0 71 | } = {} 72 | ) { 73 | t.is(spies.connecting.callCount, connectingCount) 74 | t.is(spies.connect.callCount, connectCount) 75 | t.is(spies.closing.callCount, closingCount) 76 | t.is(spies.close.callCount, closeCount) 77 | t.is(spies.error.callCount, errorCount) 78 | t.is(spies.failed.callCount, failedCount) 79 | t.is(spies.reconnecting.callCount, reconnectingCount) 80 | t.is(spies.reconnect.callCount, reconnectCount) 81 | t.is(spies.reconnect_failed.callCount, reconnectFailedCount) 82 | }, 83 | 84 | done () { 85 | wsStub.reset() 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/message/CQTag.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default 2 | 3 | const CQTag = require('../../src/message/CQTag') 4 | 5 | test('CQTag #toString(): fields with undefined values will not be serialized.', t => { 6 | t.plan(4) 7 | 8 | const tag = new CQTag('at', { qq: 123456789, none: undefined }) 9 | const ans = '[CQ:at,qq=123456789]' 10 | t.is(tag.toString(), ans) 11 | 12 | // all of the following tests call #toString() internally 13 | t.is(tag.valueOf(), ans) 14 | t.is(tag + '', ans) 15 | 16 | tag.modifier.mode = 0 17 | tag.modifier.none = undefined 18 | t.is(tag.toString(), '[CQ:at,qq=123456789,mode=0]') 19 | }) 20 | 21 | test('CQTag #toJSON(): fields with undefined values will not include into JSON.', t => { 22 | t.plan(2) 23 | 24 | const tag = new CQTag('at', { qq: 123456789, none: undefined }) 25 | 26 | t.deepEqual(tag.toJSON(), { 27 | type: 'at', 28 | data: { 29 | qq: '123456789' 30 | } 31 | }) 32 | 33 | // JSON.stringify() calls #toJSON() on each object internally 34 | t.is(JSON.stringify(tag), JSON.stringify({ 35 | type: 'at', 36 | data: { 37 | qq: '123456789' 38 | } 39 | })) 40 | }) 41 | 42 | test('does not equal to any non CQTag instances', t => { 43 | t.plan(1) 44 | const tag = new CQTag('record') 45 | t.false(tag.equals({ 46 | type: 'record', 47 | data: null 48 | })) 49 | }) 50 | 51 | test('custom modifiers', t => { 52 | t.plan(2) 53 | 54 | const tag = new CQTag('record') 55 | t.is(tag.toString(), '[CQ:record]') 56 | 57 | tag.modifier = { 58 | magic: true 59 | } 60 | 61 | t.is(tag.toString(), '[CQ:record,magic=true]') 62 | }) 63 | -------------------------------------------------------------------------------- /test/message/models.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default 2 | const fs = require('fs') 3 | const path = require('path') 4 | const parse = require('../../src/message/parse') 5 | 6 | const MODELS = fs.readdirSync(path.join(__dirname, 'models')) 7 | .map(modelFile => require(`./models/${modelFile}`)) 8 | 9 | MODELS.forEach(function ({ 10 | name, 11 | equals, 12 | toString, 13 | toJSON, 14 | coerce, 15 | extra = [] 16 | }) { 17 | /** 18 | * #equals() 19 | */ 20 | { 21 | const { target, equal, inequal } = equals 22 | test(`${name} #equals()`, t => { 23 | t.plan(2 * (equal.length + inequal.length)) 24 | 25 | equal.forEach(eq => { 26 | t.not(target, eq) 27 | t.true(target.equals(eq)) 28 | }) 29 | 30 | inequal.forEach(ineq => { 31 | t.not(target, ineq) 32 | t.false(target.equals(ineq)) 33 | }) 34 | }) 35 | } 36 | 37 | /** 38 | * #toString() 39 | */ 40 | test(`${name} #toString()`, t => { 41 | t.plan(toString.length) 42 | toString.forEach(({ target, string }) => { 43 | t.is(target.toString(), string) 44 | }) 45 | }) 46 | 47 | /** 48 | * #toJSON() 49 | */ 50 | test(`${name} #toJSON()`, t => { 51 | t.plan(toJSON.length) 52 | toJSON.forEach(({ target, json }) => { 53 | t.deepEqual(target.toJSON(), json) 54 | }) 55 | }) 56 | 57 | /** 58 | * #coerce() 59 | */ 60 | // coerce is mainly used in #parse() 61 | // if the spec does not contain a "coerce" section, 62 | // it indicates that the tag may not be received from CoolQ 63 | if (coerce) { 64 | test(`${name} #coerce()`, t => { 65 | t.plan( 66 | coerce 67 | .map(({ spec }) => Object.keys(spec).length) 68 | .reduce((acc, len) => acc + len, 0) 69 | ) 70 | 71 | coerce.forEach(({ target, spec }) => { 72 | const entries = Object.entries(spec) 73 | const parsed = parse(target)[0] 74 | for (let [ k, v ] of entries) { 75 | t.is(parsed[k], v) 76 | } 77 | }) 78 | }) 79 | } 80 | 81 | /** 82 | * Extra tests 83 | */ 84 | extra.forEach(fn => { 85 | fn(test) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/message/models/CQAnonymous.js: -------------------------------------------------------------------------------- 1 | const { CQAnonymous } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQAnonymous', 6 | equals: { 7 | target: new CQAnonymous(), 8 | equal: [ 9 | new CQAnonymous(), 10 | new CQAnonymous(true) 11 | ], 12 | inequal: [ 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQAnonymous(true), 18 | string: '[CQ:anonymous,ignore=true]' 19 | }], 20 | toJSON: [{ 21 | target: new CQAnonymous(true), 22 | json: { 23 | type: 'anonymous', 24 | data: { 25 | ignore: 'true' 26 | } 27 | } 28 | }], 29 | extra: [ 30 | function (test) { 31 | test('CQAnonymous #ignore', t => { 32 | t.plan(2) 33 | const tag = new CQAnonymous() 34 | t.false(tag.ignore) 35 | tag.ignore = true 36 | t.true(tag.ignore) 37 | }) 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /test/message/models/CQAt.js: -------------------------------------------------------------------------------- 1 | const { CQAt } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQAt', 6 | equals: { 7 | target: new CQAt(123456789), 8 | equal: [ 9 | new CQAt(123456789) 10 | ], 11 | inequal: [ 12 | new CQAt(987654321), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQAt(123456789), 18 | string: '[CQ:at,qq=123456789]' 19 | }], 20 | toJSON: [{ 21 | target: new CQAt(123456789), 22 | json: { 23 | type: 'at', 24 | data: { 25 | qq: '123456789' 26 | } 27 | } 28 | }], 29 | coerce: [{ 30 | target: '[CQ:at,qq=123456789]', 31 | spec: { 32 | qq: 123456789 33 | } 34 | }] 35 | } 36 | -------------------------------------------------------------------------------- /test/message/models/CQBFace.js: -------------------------------------------------------------------------------- 1 | const { CQBFace } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQBFace', 6 | equals: { 7 | target: new CQBFace(1, 'p'), 8 | equal: [ 9 | new CQBFace(1) 10 | ], 11 | inequal: [ 12 | new CQBFace(2, 'p'), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQBFace(1, 'p'), 18 | string: '[CQ:bface,id=1,p=p]' 19 | }], 20 | toJSON: [{ 21 | target: new CQBFace(1, 'p'), 22 | json: { 23 | type: 'bface', 24 | data: { 25 | id: '1', 26 | p: 'p' 27 | } 28 | } 29 | }], 30 | coerce: [{ 31 | target: '[CQ:bface,id=1]', 32 | spec: { 33 | id: 1 34 | } 35 | }] 36 | } 37 | -------------------------------------------------------------------------------- /test/message/models/CQCustomMusic.js: -------------------------------------------------------------------------------- 1 | const { CQCustomMusic } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQCustomMusic', 6 | equals: { 7 | target: new CQCustomMusic('url', 'audio', 'title', 'content', 'image'), 8 | equal: [ 9 | new CQCustomMusic('url', 'audio', 'title', 'content', 'image') 10 | ], 11 | inequal: [ 12 | new CQCustomMusic('another_url', 'audio', 'title', 'content', 'image'), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQCustomMusic('url', 'audio', 'title', 'content', 'image'), 18 | string: '[CQ:music,type=custom,url=url,audio=audio,title=title,content=content,image=image]' 19 | }], 20 | toJSON: [{ 21 | target: new CQCustomMusic('url', 'audio', 'title', 'content', 'image'), 22 | json: { 23 | type: 'music', 24 | data: { 25 | type: 'custom', 26 | url: 'url', 27 | audio: 'audio', 28 | title: 'title', 29 | content: 'content', 30 | image: 'image' 31 | } 32 | } 33 | }], 34 | coerce: [{ 35 | target: '[CQ:music,type=custom,url=url,audio=audio,title=title,content=content,image=image]', 36 | spec: { 37 | type: 'custom', 38 | url: 'url', 39 | audio: 'audio', 40 | title: 'title', 41 | content: 'content', 42 | image: 'image' 43 | } 44 | }] 45 | } 46 | -------------------------------------------------------------------------------- /test/message/models/CQDice.js: -------------------------------------------------------------------------------- 1 | const { CQDice } = require('../../..') 2 | const parse = require('../../../src/message/parse') 3 | const CQFakeTag = require('../../fixture/CQFakeTag') 4 | 5 | module.exports = { 6 | name: 'CQDice', 7 | equals: { 8 | target: new CQDice(), 9 | equal: [ 10 | new CQDice() 11 | ], 12 | inequal: [ 13 | parse('[CQ:dice,type=1]')[0], 14 | new CQFakeTag() 15 | ] 16 | }, 17 | toString: [{ 18 | target: new CQDice(), 19 | string: '[CQ:dice]' 20 | }], 21 | toJSON: [{ 22 | target: new CQDice(), 23 | json: { 24 | type: 'dice', 25 | data: null 26 | } 27 | }], 28 | coerce: [{ 29 | target: '[CQ:dice,type=1]', 30 | spec: { 31 | type: 1 32 | } 33 | }] 34 | } 35 | -------------------------------------------------------------------------------- /test/message/models/CQEmoji.js: -------------------------------------------------------------------------------- 1 | const { CQEmoji } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQEmoji', 6 | equals: { 7 | target: new CQEmoji(128251), 8 | equal: [ 9 | new CQEmoji(128251) 10 | ], 11 | inequal: [ 12 | new CQEmoji(127912), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQEmoji(128251), 18 | string: '[CQ:emoji,id=128251]' 19 | }], 20 | toJSON: [{ 21 | target: new CQEmoji(128251), 22 | json: { 23 | type: 'emoji', 24 | data: { 25 | id: '128251' 26 | } 27 | } 28 | }], 29 | coerce: [{ 30 | target: '[CQ:emoji,id=128251]', 31 | spec: { 32 | id: 128251 33 | } 34 | }] 35 | } 36 | -------------------------------------------------------------------------------- /test/message/models/CQFace.js: -------------------------------------------------------------------------------- 1 | const { CQFace } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQFace', 6 | equals: { 7 | target: new CQFace(1), 8 | equal: [ 9 | new CQFace(1) 10 | ], 11 | inequal: [ 12 | new CQFace(2), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQFace(1), 18 | string: '[CQ:face,id=1]' 19 | }], 20 | toJSON: [{ 21 | target: new CQFace(1), 22 | json: { 23 | type: 'face', 24 | data: { 25 | id: '1' 26 | } 27 | } 28 | }], 29 | coerce: [{ 30 | target: '[CQ:face,id=1]', 31 | spec: { 32 | id: 1 33 | } 34 | }] 35 | } 36 | -------------------------------------------------------------------------------- /test/message/models/CQImage.js: -------------------------------------------------------------------------------- 1 | const { CQImage } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQImage', 6 | equals: { 7 | target: new CQImage('file'), 8 | equal: [ 9 | new CQImage('file', true), 10 | new CQImage('file', false) 11 | ], 12 | inequal: [ 13 | new CQImage('file2'), 14 | new CQFakeTag() 15 | ] 16 | }, 17 | toString: [{ 18 | target: new CQImage('file'), 19 | string: '[CQ:image,file=file]' 20 | }], 21 | toJSON: [{ 22 | target: new CQImage('file'), 23 | json: { 24 | type: 'image', 25 | data: { 26 | file: 'file' 27 | } 28 | } 29 | }], 30 | coerce: [{ 31 | target: '[CQ:image,file=file,url=url]', 32 | spec: { 33 | file: 'file', 34 | url: 'url' 35 | } 36 | }], 37 | extra: [ 38 | function (test) { 39 | test('CQImage #cache', t => { 40 | t.plan(2) 41 | const tag = new CQImage('file', false) 42 | t.is(tag.cache, 0) 43 | tag.cache = true 44 | t.is(tag.cache, undefined) 45 | }) 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /test/message/models/CQMusic.js: -------------------------------------------------------------------------------- 1 | const { CQMusic } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQMusic', 6 | equals: { 7 | target: new CQMusic('qq', 1), 8 | equal: [ 9 | new CQMusic('qq', 1) 10 | ], 11 | inequal: [ 12 | new CQMusic('qq', 2), 13 | new CQMusic('xiami', 1), 14 | new CQFakeTag() 15 | ] 16 | }, 17 | toString: [{ 18 | target: new CQMusic('qq', 1), 19 | string: '[CQ:music,type=qq,id=1]' 20 | }], 21 | toJSON: [{ 22 | target: new CQMusic('qq', 1), 23 | json: { 24 | type: 'music', 25 | data: { 26 | type: 'qq', 27 | id: '1' 28 | } 29 | } 30 | }], 31 | coerce: [{ 32 | target: '[CQ:music,type=qq,id=1]', 33 | spec: { 34 | type: 'qq', 35 | id: 1 36 | } 37 | }] 38 | } 39 | -------------------------------------------------------------------------------- /test/message/models/CQRPS.js: -------------------------------------------------------------------------------- 1 | const { CQRPS } = require('../../..') 2 | const parse = require('../../../src/message/parse') 3 | const CQFakeTag = require('../../fixture/CQFakeTag') 4 | 5 | module.exports = { 6 | name: 'CQRPS', 7 | equals: { 8 | target: new CQRPS(), 9 | equal: [ 10 | new CQRPS() 11 | ], 12 | inequal: [ 13 | parse('[CQ:rps,type=1]')[0], 14 | new CQFakeTag() 15 | ] 16 | }, 17 | toString: [{ 18 | target: new CQRPS(), 19 | string: '[CQ:rps]' 20 | }], 21 | toJSON: [{ 22 | target: new CQRPS(), 23 | json: { 24 | type: 'rps', 25 | data: null 26 | } 27 | }], 28 | coerce: [{ 29 | target: '[CQ:rps,type=1]', 30 | spec: { 31 | type: 1 32 | } 33 | }] 34 | } 35 | -------------------------------------------------------------------------------- /test/message/models/CQRecord.js: -------------------------------------------------------------------------------- 1 | const { CQRecord } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQRecord', 6 | equals: { 7 | target: new CQRecord('file'), 8 | equal: [ 9 | new CQRecord('file', true), 10 | new CQRecord('file', false) 11 | ], 12 | inequal: [ 13 | new CQRecord('file2'), 14 | new CQFakeTag() 15 | ] 16 | }, 17 | toString: [{ 18 | target: new CQRecord('file', true), 19 | string: '[CQ:record,file=file,magic=true]' 20 | }, { 21 | target: new CQRecord('file'), 22 | string: '[CQ:record,file=file]' 23 | }], 24 | toJSON: [{ 25 | target: new CQRecord('file', true), 26 | json: { 27 | type: 'record', 28 | data: { 29 | file: 'file', 30 | magic: 'true' 31 | } 32 | } 33 | }], 34 | coerce: [{ 35 | target: '[CQ:record,file=file]', 36 | spec: { 37 | file: 'file' 38 | } 39 | }], 40 | extra: [ 41 | function (test) { 42 | test('CQRecord #magic', t => { 43 | t.plan(3) 44 | const tag = new CQRecord('file', false) 45 | t.is(tag.magic, undefined) 46 | tag.magic = true 47 | t.true(tag.magic) 48 | t.true(tag.hasMagic()) 49 | }) 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /test/message/models/CQSFace.js: -------------------------------------------------------------------------------- 1 | const { CQSFace } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQSFace', 6 | equals: { 7 | target: new CQSFace(1), 8 | equal: [ 9 | new CQSFace(1) 10 | ], 11 | inequal: [ 12 | new CQSFace(2), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQSFace(1), 18 | string: '[CQ:sface,id=1]' 19 | }], 20 | toJSON: [{ 21 | target: new CQSFace(1), 22 | json: { 23 | type: 'sface', 24 | data: { 25 | id: '1' 26 | } 27 | } 28 | }], 29 | coerce: [{ 30 | target: '[CQ:sface,id=1]', 31 | spec: { 32 | id: 1 33 | } 34 | }] 35 | } 36 | -------------------------------------------------------------------------------- /test/message/models/CQShake.js: -------------------------------------------------------------------------------- 1 | const { CQShake } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQShake', 6 | equals: { 7 | target: new CQShake(), 8 | equal: [ 9 | new CQShake() 10 | ], 11 | inequal: [ 12 | new CQFakeTag() 13 | ] 14 | }, 15 | toString: [{ 16 | target: new CQShake(), 17 | string: '[CQ:shake]' 18 | }], 19 | toJSON: [{ 20 | target: new CQShake(), 21 | json: { 22 | type: 'shake', 23 | data: null 24 | } 25 | }], 26 | coerce: [{ 27 | target: '[CQ:shake]', 28 | spec: { } 29 | }] 30 | } 31 | -------------------------------------------------------------------------------- /test/message/models/CQShare.js: -------------------------------------------------------------------------------- 1 | const { CQShare } = require('../../..') 2 | const CQFakeTag = require('../../fixture/CQFakeTag') 3 | 4 | module.exports = { 5 | name: 'CQShare', 6 | equals: { 7 | target: new CQShare('url', 'title', 'content', 'image'), 8 | equal: [ 9 | new CQShare('url', 'title', 'content', 'image') 10 | ], 11 | inequal: [ 12 | new CQShare('another_url', 'title', 'content', 'image'), 13 | new CQFakeTag() 14 | ] 15 | }, 16 | toString: [{ 17 | target: new CQShare('url', 'title', 'content', 'image'), 18 | string: '[CQ:share,url=url,title=title,content=content,image=image]' 19 | }], 20 | toJSON: [{ 21 | target: new CQShare('url', 'title', 'content', 'image'), 22 | json: { 23 | type: 'share', 24 | data: { 25 | url: 'url', 26 | title: 'title', 27 | content: 'content', 28 | image: 'image' 29 | } 30 | } 31 | }], 32 | coerce: [{ 33 | target: '[CQ:share,url=url,title=title,content=content,image=image]', 34 | spec: { 35 | url: 'url', 36 | title: 'title', 37 | content: 'content', 38 | image: 'image' 39 | } 40 | }] 41 | } 42 | -------------------------------------------------------------------------------- /test/message/models/CQText.js: -------------------------------------------------------------------------------- 1 | const { CQText } = require('../../..') 2 | const parse = require('../../../src/message/parse') 3 | const CQFakeTag = require('../../fixture/CQFakeTag') 4 | 5 | module.exports = { 6 | name: 'CQText', 7 | equals: { 8 | target: new CQText('text'), 9 | equal: [ 10 | new CQText('text'), 11 | parse('text')[0], 12 | parse([{ 13 | type: 'text', 14 | data: { 15 | text: 'text' 16 | } 17 | }])[0] 18 | ], 19 | inequal: [ 20 | new CQFakeTag() 21 | ] 22 | }, 23 | toString: [{ 24 | target: new CQText('text'), 25 | string: 'text' 26 | }], 27 | toJSON: [{ 28 | target: new CQText('text'), 29 | json: { 30 | type: 'text', 31 | data: { 32 | text: 'text' 33 | } 34 | } 35 | }], 36 | coerce: [{ 37 | target: 'text', 38 | spec: { 39 | text: 'text' 40 | } 41 | }] 42 | } 43 | -------------------------------------------------------------------------------- /test/message/parse.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').default 2 | const { spy } = require('sinon') 3 | 4 | const { 5 | CQAnonymous, 6 | CQAt, 7 | CQBFace, 8 | CQCustomMusic, 9 | CQDice, 10 | CQEmoji, 11 | CQFace, 12 | CQImage, 13 | CQMusic, 14 | CQRecord, 15 | CQRPS, 16 | CQSFace, 17 | CQShake, 18 | CQShare, 19 | CQText 20 | } = require('../..') 21 | const parse = require('../../src/message/parse') 22 | 23 | test('parse(string_msg)', t => { 24 | t.plan(2) 25 | const msg = 'Hey, [[CQ:at,qq=123456789]] this is a \n[[CQ:emoji,id=128251]] test [[CQ:invalid,any=prop]]' 26 | const tags = parse(msg) 27 | t.deepEqual(tags, [ 28 | new CQText('Hey, ['), 29 | new CQAt(123456789), 30 | new CQText('] this is a \n['), 31 | new CQEmoji(128251), 32 | new CQText('] test [[CQ:invalid,any=prop]]') 33 | ]) 34 | 35 | // Array.prototype.join() call #toString() on each item internally 36 | t.is(tags.join(''), msg) 37 | }) 38 | 39 | test('parse(array_msg)', t => { 40 | t.plan(1) 41 | const msg = [{ 42 | type: 'text', 43 | data: { 44 | text: 'Hey, [' 45 | } 46 | }, { 47 | type: 'at', 48 | data: { 49 | qq: '123456789' 50 | } 51 | }, { 52 | type: 'text', 53 | data: { 54 | text: '] this is a \n[' 55 | } 56 | }, { 57 | type: 'emoji', 58 | data: { 59 | id: 128251 60 | } 61 | }, { 62 | type: 'text', 63 | data: { 64 | text: '] test [[CQ:invalid,any=prop]]' 65 | } 66 | }] 67 | 68 | const tags = parse(msg) 69 | t.deepEqual(tags, [ 70 | new CQText('Hey, ['), 71 | new CQAt(123456789), 72 | new CQText('] this is a \n['), 73 | new CQEmoji(128251), 74 | new CQText('] test [[CQ:invalid,any=prop]]') 75 | ]) 76 | }) 77 | 78 | const TAGS = [ 79 | { text: '[CQ:anonymous]', prototype: CQAnonymous }, 80 | { text: '[CQ:at,qq=123456789]', prototype: CQAt }, 81 | { text: '[CQ:bface,id=1]', prototype: CQBFace }, 82 | { text: '[CQ:music,type=custom,url=url,title=title,audio=audio,content=content,image=image]', prototype: CQCustomMusic }, 83 | { text: '[CQ:dice,type=4]', prototype: CQDice }, 84 | { text: '[CQ:emoji,id=128251]', prototype: CQEmoji }, 85 | { text: '[CQ:face,id=1]', prototype: CQFace }, 86 | { text: '[CQ:image,file=file,url=url]', prototype: CQImage }, 87 | { text: '[CQ:music,type=qq,id=1]', prototype: CQMusic }, 88 | { text: '[CQ:record,file=file]', prototype: CQRecord }, 89 | { text: '[CQ:rps,type=1]', prototype: CQRPS }, 90 | { text: '[CQ:sface,id=1]', prototype: CQSFace }, 91 | { text: '[CQ:shake]', prototype: CQShake }, 92 | { text: '[CQ:share,url=url,title=title,content=content,image=image]', prototype: CQShare } 93 | ] 94 | 95 | TAGS.forEach(TAG => { 96 | test(TAG.text, macro, TAG.text, TAG.prototype) 97 | }) 98 | 99 | function macro (t, text, clazz) { 100 | t.plan(3) 101 | 102 | const coerce = spy(clazz.prototype, 'coerce') 103 | 104 | const tags = parse(text) 105 | 106 | t.is(tags.length, 1) 107 | t.true(tags[0] instanceof clazz) 108 | 109 | // ensure that the #coerce() of each CQTag successor class has been called when a tag is parsed 110 | // then we can test against that 111 | // the #coerce() of each CQTag successor class coerces the data to the correct types 112 | t.true(coerce.calledOnce) 113 | } 114 | 115 | test('parse(): unsupported tags will be parsed into CQText', t => { 116 | t.plan(3) 117 | const tag = parse('[CQ:unknown,key=value]')[0] 118 | t.true(tag instanceof CQText) 119 | t.is(tag.text, '[CQ:unknown,key=value]') 120 | t.is(tag.toString(), '[CQ:unknown,key=value]') 121 | }) 122 | -------------------------------------------------------------------------------- /test/unit/CQWebsocket.test.js: -------------------------------------------------------------------------------- 1 | const { CQWebSocket, WebSocketState } = require('../..') 2 | 3 | const test = require('ava').default 4 | 5 | test('new CQWebSocket() with default options', function (t) { 6 | t.plan(8) 7 | 8 | const bot = new CQWebSocket() 9 | 10 | t.is(bot._monitor.EVENT.state, WebSocketState.INIT) 11 | t.is(bot._monitor.API.state, WebSocketState.INIT) 12 | t.is(bot._baseUrl, 'ws://127.0.0.1:6700') 13 | t.is(bot._qq, -1) 14 | t.is(bot._token, '') 15 | t.deepEqual(bot._reconnectOptions, { 16 | reconnection: true, 17 | reconnectionAttempts: Infinity, 18 | reconnectionDelay: 1000 19 | }) 20 | t.deepEqual(bot._wsOptions, { 21 | fragmentOutgoingMessages: false 22 | }) 23 | t.deepEqual(bot._requestOptions, { }) 24 | }) 25 | 26 | test('new CQWebSocket() with custom options', function (t) { 27 | t.plan(8) 28 | 29 | const bot = new CQWebSocket({ 30 | enableAPI: false, 31 | enableEvent: true, 32 | baseUrl: '8.8.8.8:8888/ws', 33 | qq: 123456789, 34 | accessToken: 'qwerasdf', 35 | reconnection: true, 36 | reconnectionAttempts: 10, 37 | reconnectionDelay: 5000, 38 | fragmentOutgoingMessages: true, 39 | fragmentationThreshold: 0x4000, 40 | requestOptions: { 41 | timeout: 2000 42 | } 43 | }) 44 | 45 | t.is(bot._monitor.EVENT.state, WebSocketState.INIT) 46 | t.is(bot._monitor.API.state, WebSocketState.DISABLED) 47 | t.is(bot._baseUrl, 'ws://8.8.8.8:8888/ws') 48 | t.is(bot._qq, 123456789) 49 | t.is(bot._token, 'qwerasdf') 50 | t.deepEqual(bot._requestOptions, { 51 | timeout: 2000 52 | }) 53 | t.deepEqual(bot._reconnectOptions, { 54 | reconnection: true, 55 | reconnectionAttempts: 10, 56 | reconnectionDelay: 5000 57 | }) 58 | t.deepEqual(bot._wsOptions, { 59 | fragmentOutgoingMessages: true, 60 | fragmentationThreshold: 0x4000 61 | }) 62 | }) 63 | 64 | test('new Websocket(): protocol', function (t) { 65 | t.plan(2) 66 | 67 | const bot1 = new CQWebSocket({ 68 | protocol: 'HTTP' 69 | }) 70 | 71 | t.is(bot1._baseUrl, 'http://127.0.0.1:6700') 72 | 73 | const bot2 = new CQWebSocket({ 74 | protocol: 'wss:', 75 | port: 23456 76 | }) 77 | 78 | t.is(bot2._baseUrl, 'wss://127.0.0.1:23456') 79 | }) 80 | 81 | test('new Websocket(): base url', function (t) { 82 | t.plan(2) 83 | 84 | const bot1 = new CQWebSocket({ 85 | baseUrl: '127.0.0.1:22222' 86 | }) 87 | 88 | t.is(bot1._baseUrl, 'ws://127.0.0.1:22222') 89 | 90 | const bot2 = new CQWebSocket({ 91 | baseUrl: 'wss://my.dns/bot' 92 | }) 93 | 94 | t.is(bot2._baseUrl, 'wss://my.dns/bot') 95 | }) 96 | -------------------------------------------------------------------------------- /test/unit/call.test.js: -------------------------------------------------------------------------------- 1 | // stuffs of stubbing 2 | const { stub, spy } = require('sinon') 3 | 4 | const test = require('ava').default 5 | const { CQWebSocketAPI: { CQWebSocket, APITimeoutError } } = require('../fixture/connect-success')() 6 | 7 | test.cb('#__call__(method, params)', function (t) { 8 | t.plan(11) 9 | 10 | const preSpy = spy() 11 | const postSpy = spy() 12 | const apiResponseSpy = spy() 13 | // provide qq to avoid invoking `_apiSock.send` 14 | const bot = new CQWebSocket({ qq: 123456789 }) 15 | .on('api.send.pre', preSpy) 16 | .on('api.send.post', postSpy) 17 | .on('api.response', apiResponseSpy) 18 | .on('ready', function () { 19 | const stubSend = stub(bot._apiSock, 'send') 20 | stubSend.callsFake(function (data) { 21 | const { echo } = JSON.parse(data) 22 | this.onMessage(JSON.stringify({ retcode: 0, status: 'ok', echo })) 23 | }) 24 | 25 | t.is(bot._responseHandlers.size, 0) 26 | 27 | const ret = bot('test', { 28 | foo: 'bar' 29 | }) 30 | 31 | let reqid 32 | for (let k of bot._responseHandlers.keys()) { 33 | reqid = k 34 | break 35 | } 36 | 37 | // assert returned value 38 | t.true(ret instanceof Promise) 39 | 40 | // assert events 41 | t.true(preSpy.calledOnce) 42 | t.true(postSpy.calledOnce) 43 | 44 | // assert side effects 45 | t.is(bot._responseHandlers.size, 1) 46 | t.true(stubSend.calledWith(JSON.stringify({ 47 | action: 'test', 48 | params: { foo: 'bar' }, 49 | echo: { reqid } 50 | }))) 51 | t.true(stubSend.calledAfter(preSpy)) 52 | t.true(postSpy.calledAfter(stubSend)) 53 | 54 | // assert result of returned Promise 55 | ret.then(ctxt => { 56 | t.true(apiResponseSpy.calledOnce) 57 | t.true(apiResponseSpy.calledAfter(postSpy)) 58 | t.deepEqual(ctxt, { retcode: 0, status: 'ok' }) 59 | t.end() 60 | }) 61 | }) 62 | .connect() 63 | }) 64 | 65 | test('#__call__() while disconnected.', async function (t) { 66 | t.plan(1) 67 | 68 | const bot = new CQWebSocket() 69 | 70 | let thrown = false 71 | try { 72 | await bot('test', { 73 | foo: 'bar' 74 | }) 75 | } catch (e) { 76 | thrown = true 77 | } 78 | 79 | t.true(thrown) 80 | }) 81 | 82 | test.cb('#__call__(method, params, options) with timeout option', function (t) { 83 | t.plan(5) 84 | 85 | // provide qq to avoid invoking `_apiSock.send` 86 | const bot = new CQWebSocket({ qq: 123456789 }) 87 | .on('ready', function () { 88 | t.is(bot._responseHandlers.size, 0) 89 | 90 | const ret = bot('test', { 91 | foo: 'bar' 92 | }, { 93 | timeout: 1000 94 | }) 95 | 96 | t.is(bot._responseHandlers.size, 1) 97 | 98 | ret.catch(err => { 99 | t.true(err instanceof APITimeoutError) 100 | t.deepEqual(err.req, { 101 | action: 'test', 102 | params: { 103 | foo: 'bar' 104 | } 105 | }) 106 | // response handler should be reset due to timeout 107 | t.is(bot._responseHandlers.size, 0) 108 | t.end() 109 | }) 110 | }) 111 | .connect() 112 | }) 113 | 114 | test.cb('#__call__(method) use global request options if options is omitted', function (t) { 115 | t.plan(2) 116 | 117 | // provide qq to avoid invoking `_apiSock.send` 118 | const bot = new CQWebSocket({ qq: 123456789, requestOptions: { timeout: 2000 } }) 119 | 120 | let start 121 | bot 122 | .on('ready', function () { 123 | start = Date.now() 124 | bot('test').catch(err => { 125 | t.true(err instanceof APITimeoutError) 126 | // Not sure if this assertion is stable (?) 127 | t.is(Math.round((Date.now() - start) / 1000), 2) 128 | t.end() 129 | }) 130 | }) 131 | .connect() 132 | }) 133 | -------------------------------------------------------------------------------- /test/unit/connect.test.js: -------------------------------------------------------------------------------- 1 | // stuffs of stubbing 2 | const { stub, spy } = require('sinon') 3 | const { client } = require('websocket') 4 | const fakeConnect = stub(client.prototype, 'connect') 5 | 6 | const test = require('ava').default 7 | const { CQWebSocket, WebSocketType, WebSocketState } = require('../..') 8 | 9 | test.after.always(function () { 10 | fakeConnect.restore() 11 | }) 12 | 13 | test('#connect() returns the bot itself', function (t) { 14 | t.plan(1) 15 | 16 | const bot = new CQWebSocket() 17 | t.is(bot.connect(), bot) 18 | }) 19 | 20 | test('#connect()', function (t) { 21 | t.plan(3) 22 | 23 | const _spy = spy() 24 | const bot = new CQWebSocket() 25 | .on('socket.connecting', _spy) 26 | .connect() 27 | t.is(bot._monitor.EVENT.state, WebSocketState.CONNECTING) 28 | t.is(bot._monitor.API.state, WebSocketState.CONNECTING) 29 | t.true(_spy.calledTwice) 30 | }) 31 | 32 | test('#connect(wsType="/event")', function (t) { 33 | t.plan(3) 34 | 35 | const _spy = spy() 36 | const bot = new CQWebSocket() 37 | .on('socket.connecting', _spy) 38 | .connect(WebSocketType.EVENT) 39 | t.is(bot._monitor.EVENT.state, WebSocketState.CONNECTING) 40 | t.is(bot._monitor.API.state, WebSocketState.INIT) 41 | t.true(_spy.calledOnce) 42 | }) 43 | 44 | test('#connect(wsType="/api")', function (t) { 45 | t.plan(3) 46 | 47 | const _spy = spy() 48 | const bot = new CQWebSocket() 49 | .on('socket.connecting', _spy) 50 | .connect(WebSocketType.API) 51 | t.is(bot._monitor.EVENT.state, WebSocketState.INIT) 52 | t.is(bot._monitor.API.state, WebSocketState.CONNECTING) 53 | t.true(_spy.calledOnce) 54 | }) 55 | 56 | test('#connect() while bot has host, port and accessToken options', function (t) { 57 | t.plan(2) 58 | 59 | const bot = new CQWebSocket({ accessToken: '123456789', host: 'localhost', port: 12345 }) 60 | bot.connect() 61 | t.true(fakeConnect.calledWith('ws://localhost:12345/event?access_token=123456789')) 62 | t.true(fakeConnect.calledWith('ws://localhost:12345/api?access_token=123456789')) 63 | }) 64 | 65 | test('#connect() while bot has baseUrl and accessToken options', function (t) { 66 | t.plan(2) 67 | 68 | const bot = new CQWebSocket({ accessToken: '123456789', baseUrl: 'localhost:12345/ws' }) 69 | bot.connect() 70 | t.true(fakeConnect.calledWith('ws://localhost:12345/ws/event?access_token=123456789')) 71 | t.true(fakeConnect.calledWith('ws://localhost:12345/ws/api?access_token=123456789')) 72 | }) 73 | -------------------------------------------------------------------------------- /test/unit/disconnect.test.js: -------------------------------------------------------------------------------- 1 | // stuffs of stubbing 2 | const { spy } = require('sinon') 3 | 4 | const test = require('ava').default 5 | const { CQWebSocketAPI } = require('../fixture/connect-success')() 6 | const { WebSocketType, WebSocketState, CQWebSocket } = CQWebSocketAPI 7 | 8 | test.cb('#disconnect() returns the bot itself', function (t) { 9 | t.plan(1) 10 | 11 | const bot = new CQWebSocket() 12 | .on('ready', function () { 13 | t.is(bot.disconnect(), bot) 14 | t.end() 15 | }) 16 | .connect() 17 | }) 18 | 19 | test.cb('#disconnect()', function (t) { 20 | t.plan(3) 21 | 22 | const _spy = spy() 23 | const bot = new CQWebSocket() 24 | .on('socket.closing', _spy) 25 | .on('ready', function () { 26 | bot.disconnect() 27 | t.is(bot._monitor.EVENT.state, WebSocketState.CLOSING) 28 | t.is(bot._monitor.API.state, WebSocketState.CLOSING) 29 | t.true(_spy.calledTwice) 30 | t.end() 31 | }) 32 | .connect() 33 | }) 34 | 35 | test.cb('#disconnect(wsType="/event")', function (t) { 36 | t.plan(3) 37 | 38 | const _spy = spy() 39 | const bot = new CQWebSocket() 40 | .on('socket.closing', _spy) 41 | .on('ready', function () { 42 | bot.disconnect(WebSocketType.EVENT) 43 | t.is(bot._monitor.EVENT.state, WebSocketState.CLOSING) 44 | t.is(bot._monitor.API.state, WebSocketState.CONNECTED) 45 | t.true(_spy.calledOnce) 46 | t.end() 47 | }) 48 | .connect() 49 | }) 50 | 51 | test.cb('#disconnect(wsType="/api")', function (t) { 52 | t.plan(3) 53 | 54 | const _spy = spy() 55 | const bot = new CQWebSocket() 56 | .on('socket.closing', _spy) 57 | .on('ready', function () { 58 | bot.disconnect(WebSocketType.API) 59 | t.is(bot._monitor.EVENT.state, WebSocketState.CONNECTED) 60 | t.is(bot._monitor.API.state, WebSocketState.CLOSING) 61 | t.true(_spy.calledOnce) 62 | t.end() 63 | }) 64 | .connect() 65 | }) 66 | -------------------------------------------------------------------------------- /test/unit/isReady.test.js: -------------------------------------------------------------------------------- 1 | // stuffs of stubbing 2 | const { stub } = require('sinon') 3 | 4 | const test = require('ava').default 5 | const { CQWebSocketAPI: { WebSocketType, CQWebSocket } } = require('../fixture/connect-success')() 6 | 7 | test.cb('#isReady(): event-enabled, api-enabled, event-connected, api-connected', function (t) { 8 | t.plan(3) 9 | 10 | const _stub = stub() 11 | _stub.onCall(0).callsFake(function () { 12 | t.false(bot.isReady()) 13 | }) 14 | _stub.onCall(1).callsFake(function () { 15 | t.true(bot.isReady()) 16 | t.end() 17 | }) 18 | 19 | const bot = new CQWebSocket() 20 | .on('socket.connect', _stub) 21 | .connect() 22 | 23 | t.false(bot.isReady()) 24 | }) 25 | 26 | test.cb('#isReady(): event-enabled, api-enabled, api-connected', function (t) { 27 | t.plan(2) 28 | 29 | const _stub = stub() 30 | _stub.onCall(0).callsFake(function () { 31 | t.false(bot.isReady()) 32 | t.end() 33 | }) 34 | 35 | const bot = new CQWebSocket() 36 | .on('socket.connect', _stub) 37 | .connect(WebSocketType.API) 38 | 39 | t.false(bot.isReady()) 40 | }) 41 | 42 | test('#isReady(): event-disabled, api-disabled', function (t) { 43 | t.plan(1) 44 | 45 | const bot = new CQWebSocket({ enableEvent: false, enableAPI: false }) 46 | 47 | t.true(bot.isReady()) 48 | }) 49 | 50 | test.cb('#isReady(): event-enabled, api-disabled, event-connected', function (t) { 51 | t.plan(2) 52 | 53 | const bot = new CQWebSocket({ enableAPI: false }) 54 | .on('socket.connect', function () { 55 | t.true(bot.isReady()) 56 | t.end() 57 | }) 58 | .connect() 59 | 60 | t.false(bot.isReady()) 61 | }) 62 | -------------------------------------------------------------------------------- /test/unit/isSocketConnected.test.js: -------------------------------------------------------------------------------- 1 | // stuffs of stubbing 2 | const { spy } = require('sinon') 3 | 4 | const test = require('ava').default 5 | const { CQWebSocketAPI: { CQWebSocket, WebSocketType } } = require('../fixture/connect-success')() 6 | 7 | test.cb('#isSockConnected(wsType=/event)', function (t) { 8 | t.plan(2) 9 | 10 | const bot = new CQWebSocket() 11 | bot 12 | .on('socket.connect', function () { 13 | t.true(bot.isSockConnected(WebSocketType.EVENT)) 14 | t.end() 15 | }) 16 | .connect(WebSocketType.EVENT) 17 | 18 | t.false(bot.isSockConnected(WebSocketType.EVENT)) 19 | }) 20 | 21 | test.cb('#isSockConnected(wsType=/api)', function (t) { 22 | t.plan(2) 23 | 24 | const bot = new CQWebSocket() 25 | bot 26 | .on('socket.connect', function () { 27 | t.true(bot.isSockConnected(WebSocketType.API)) 28 | t.end() 29 | }) 30 | .connect(WebSocketType.API) 31 | 32 | t.false(bot.isSockConnected(WebSocketType.API)) 33 | }) 34 | 35 | test('#isSockConnected(): should have thrown', function (t) { 36 | t.plan(2) 37 | 38 | const bot = new CQWebSocket() 39 | spy(bot, 'isSockConnected') 40 | 41 | let res 42 | try { 43 | res = bot.isSockConnected() 44 | } catch (e) { } 45 | 46 | t.true(bot.isSockConnected.threw()) 47 | t.is(typeof res, 'undefined') 48 | }) 49 | -------------------------------------------------------------------------------- /test/unit/off.test.js: -------------------------------------------------------------------------------- 1 | const { CQWebSocket } = require('../..') 2 | const traverse = require('../../src/util/traverse') 3 | const test = require('ava').default 4 | 5 | const NOOP1 = function () {} 6 | const NOOP2 = function () {} 7 | 8 | function countListeners (bot) { 9 | let listenerCount = 0 10 | traverse(bot._eventBus._EventMap, (v) => { 11 | if (Array.isArray(v)) { 12 | listenerCount += v.length 13 | } 14 | }) 15 | return listenerCount 16 | } 17 | 18 | test('#off(): remove all listeners', function (t) { 19 | t.plan(4) 20 | 21 | const bot = new CQWebSocket() 22 | bot 23 | .on('socket.connect', NOOP1) 24 | .on('socket.connect', NOOP2) 25 | .on('message.group.@me', NOOP1) 26 | .on('request.group.invite', NOOP1) 27 | 28 | const total1 = bot._eventBus.count('socket.connect') + 29 | bot._eventBus.count('message.group.@.me') + 30 | bot._eventBus.count('request.group.invite') 31 | 32 | t.is(total1, 4) 33 | t.is(bot._eventBus.count('socket.error'), 1) 34 | 35 | bot.off() 36 | 37 | const total2 = bot._eventBus.count('socket.connect') + 38 | bot._eventBus.count('message.group.@.me') + 39 | bot._eventBus.count('request.group.invite') 40 | 41 | t.is(total2, 0) 42 | t.is(bot._eventBus.count('socket.error'), 1) 43 | }) 44 | 45 | test('#off(event): remove all listeners of the specified event', function (t) { 46 | t.plan(2) 47 | 48 | const bot = new CQWebSocket() 49 | bot 50 | .on('socket.connect', NOOP1) 51 | .on('socket.connect', NOOP2) 52 | .on('message.group.@me', NOOP1) 53 | .on('request.group.invite', NOOP1) 54 | 55 | const total1 = bot._eventBus.count('socket.connect') + 56 | bot._eventBus.count('message.group.@.me') + 57 | bot._eventBus.count('request.group.invite') 58 | 59 | t.is(total1, 4) 60 | 61 | bot.off('socket.connect') 62 | 63 | const total2 = bot._eventBus.count('socket.connect') + 64 | bot._eventBus.count('message.group.@.me') + 65 | bot._eventBus.count('request.group.invite') 66 | 67 | t.is(total2, 2) 68 | }) 69 | 70 | test('#off(event, listener): remove a specific listener', function (t) { 71 | t.plan(3) 72 | 73 | const bot = new CQWebSocket() 74 | bot 75 | .on('socket.connect', NOOP1) 76 | .on('socket.connect', NOOP2) 77 | .on('message.group.@me', NOOP1) 78 | .on('request.group.invite', NOOP1) 79 | 80 | const total1 = bot._eventBus.count('socket.connect') + 81 | bot._eventBus.count('message.group.@.me') + 82 | bot._eventBus.count('request.group.invite') 83 | 84 | t.is(total1, 4) 85 | 86 | bot.off('socket.connect', NOOP1) 87 | 88 | const total2 = bot._eventBus.count('socket.connect') + 89 | bot._eventBus.count('message.group.@.me') + 90 | bot._eventBus.count('request.group.invite') 91 | 92 | t.is(total2, 3) 93 | t.is(bot._eventBus._getHandlerQueue('socket.connect')[0], NOOP2) 94 | }) 95 | 96 | test('#off(event, listener): if a listener is registered via multiple #on()\'s, it should also be removed via multiple #off()\'s.', function (t) { 97 | t.plan(5) 98 | 99 | const bot = new CQWebSocket() 100 | bot 101 | .on('socket.connect', NOOP1) 102 | .on('socket.connect', NOOP1) 103 | .on('socket.connect', NOOP1) 104 | .on('socket.connect', NOOP1) 105 | 106 | t.is(bot._eventBus.count('socket.connect'), 4) 107 | 108 | bot.off('socket.connect', NOOP1) 109 | t.is(bot._eventBus.count('socket.connect'), 3) 110 | 111 | bot.off('socket.connect', NOOP1) 112 | t.is(bot._eventBus.count('socket.connect'), 2) 113 | 114 | bot.off('socket.connect', NOOP1) 115 | t.is(bot._eventBus.count('socket.connect'), 1) 116 | 117 | bot.off('socket.connect', NOOP1) 118 | t.is(bot._eventBus.count('socket.connect'), 0) 119 | }) 120 | 121 | test('#off(event, onceListener): should be able to remove once listeners', function (t) { 122 | t.plan(2) 123 | 124 | const bot = new CQWebSocket() 125 | .once('message', console.log) 126 | t.is(bot._eventBus.count('message'), 1) 127 | 128 | bot.off('message', console.log) 129 | t.is(bot._eventBus.count('message'), 0) 130 | }) 131 | 132 | test('#off(socket.error): remove all socket.error listeners', function (t) { 133 | t.plan(2) 134 | 135 | const func1 = function () {} 136 | 137 | const bot = new CQWebSocket() 138 | .once('socket.error', console.error) 139 | .on('socket.error', func1) 140 | .on('socket.error', console.error) 141 | 142 | t.is(bot._eventBus.count('socket.error'), 3) 143 | 144 | bot.off('socket.error') 145 | t.is(bot._eventBus.count('socket.error'), 1) 146 | }) 147 | 148 | test('#off(socket.error, listener): remove specified socket.error listener', function (t) { 149 | t.plan(7) 150 | 151 | const func1 = function () {} 152 | 153 | const bot = new CQWebSocket() 154 | .once('socket.error', console.error) 155 | .on('socket.error', func1) 156 | .on('socket.error', console.error) 157 | 158 | t.is(bot._eventBus.count('socket.error'), 3) 159 | 160 | bot.off('socket.error', func1) 161 | t.is(bot._eventBus.count('socket.error'), 2) 162 | 163 | const queue = bot._eventBus._getHandlerQueue('socket.error') 164 | t.not(queue[0], queue[1]) // not the same since queue[0] is a once listener which wraps console.error 165 | t.is(queue[0], bot._eventBus._onceListeners.get(console.error)) 166 | 167 | bot.off('socket.error', console.error) // the once listener is removed since it is registered earlier 168 | t.is(bot._eventBus.count('socket.error'), 1) 169 | t.is(bot._eventBus._getHandlerQueue('socket.error')[0], console.error) 170 | 171 | bot.off('socket.error', console.error) 172 | t.is(bot._eventBus.count('socket.error'), 1) // default error handler 173 | }) 174 | 175 | test('#off(invalidEvent)', function (t) { 176 | t.plan(3) 177 | 178 | const bot = new CQWebSocket() 179 | 180 | t.is(countListeners(bot), 1) // default socket.error 181 | 182 | t.is(bot.off('invalid.event'), bot) 183 | 184 | t.is(countListeners(bot), 1) // default socket.error 185 | }) 186 | 187 | test('#off(event, not_a_listener)', function (t) { 188 | t.plan(3) 189 | 190 | const bot = new CQWebSocket() 191 | .on('message', NOOP1) 192 | 193 | t.is(countListeners(bot), 2) // default socket.error + NOOP1 194 | 195 | t.is(bot.off('message', NOOP2), bot) 196 | 197 | t.is(countListeners(bot), 2) // default socket.error + NOOP1 198 | }) 199 | -------------------------------------------------------------------------------- /test/unit/on.test.js: -------------------------------------------------------------------------------- 1 | const { CQWebSocket } = require('../..') 2 | const test = require('ava').default 3 | const { spy, stub } = require('sinon') 4 | 5 | test('#on(): valid event', function (t) { 6 | t.plan(3) 7 | 8 | const _spy = spy() 9 | const bot = new CQWebSocket() 10 | .on('message.private', _spy) 11 | 12 | const queue = bot._eventBus._getHandlerQueue('message.private') 13 | t.true(Array.isArray(queue)) 14 | t.is(queue.length, 1) 15 | 16 | bot._eventBus.emit('message.private') 17 | t.true(_spy.calledOnce) 18 | }) 19 | 20 | test('#on(): invalid event', function (t) { 21 | t.plan(2) 22 | 23 | const _spy = spy() 24 | const bot = new CQWebSocket() 25 | .on('invalid.event', _spy) 26 | 27 | const queue = bot._eventBus._getHandlerQueue('invalid.event') 28 | t.false(Array.isArray(queue)) 29 | 30 | bot._eventBus.emit('invalid.event') 31 | t.true(_spy.notCalled) 32 | }) 33 | 34 | test('#on(): socket.error', async function (t) { 35 | t.plan(7) 36 | 37 | const bot = new CQWebSocket() 38 | 39 | const queue = bot._eventBus._getHandlerQueue('socket.error') 40 | t.true(Array.isArray(queue)) 41 | t.is(queue.length, 1) 42 | 43 | // disable warning message 44 | stub(console, 'error') 45 | try { 46 | await bot._eventBus.emit('socket.error', 'fake-sock', new Error('Fake socket error')) 47 | } catch (err) { 48 | t.true(err instanceof Error) 49 | t.is(err.which, 'fake-sock') 50 | } 51 | console.error.restore() 52 | 53 | const _defErrorHandler = queue[0] 54 | const _spy = spy() 55 | bot.on('socket.error', _spy) 56 | 57 | t.is(queue.length, 1) 58 | t.not(queue[0], _defErrorHandler) 59 | 60 | bot._eventBus.emit('socket.error', 'fake-sock', new Error('Fake socket error')) 61 | t.true(_spy.calledOnce) 62 | }) 63 | -------------------------------------------------------------------------------- /test/unit/once.test.js: -------------------------------------------------------------------------------- 1 | const { CQWebSocket } = require('../..') 2 | const test = require('ava').default 3 | const { stub } = require('sinon') 4 | 5 | test('#once(): handler not returning false', async function (t) { 6 | t.plan(3) 7 | 8 | const _stub = stub() 9 | _stub.returns(undefined) 10 | 11 | const bot = new CQWebSocket() 12 | bot.once('message.private', _stub) 13 | 14 | const queue = bot._eventBus._getHandlerQueue('message.private') 15 | t.true(Array.isArray(queue)) 16 | t.is(queue.length, 1) 17 | 18 | await bot._eventBus.emit('message.private') 19 | t.is(queue.length, 0) 20 | }) 21 | 22 | test('#once(): handler returning false', async function (t) { 23 | t.plan(6) 24 | 25 | const _stub = stub() 26 | _stub.onCall(0).returns(false) 27 | _stub.onCall(1).returns(undefined) 28 | 29 | const bot = new CQWebSocket() 30 | bot.once('message.private', _stub) 31 | 32 | const queue = bot._eventBus._getHandlerQueue('message.private') 33 | t.true(Array.isArray(queue)) 34 | t.is(queue.length, 1) 35 | 36 | await bot._eventBus.emit('message.private') 37 | t.true(_stub.calledOnce) 38 | t.is(queue.length, 1) 39 | 40 | await bot._eventBus.emit('message.private') 41 | t.true(_stub.calledTwice) 42 | t.is(queue.length, 0) 43 | }) 44 | -------------------------------------------------------------------------------- /test/unit/reconnect.test.js: -------------------------------------------------------------------------------- 1 | // stuffs of stubbing 2 | const { spy } = require('sinon') 3 | 4 | const test = require('ava').default 5 | const { CQWebSocketAPI: { CQWebSocket } } = require('../fixture/connect-success')() 6 | 7 | test.cb('#reconnect() returns the bot itself', function (t) { 8 | t.plan(1) 9 | 10 | const bot = new CQWebSocket() 11 | .on('ready', function () { 12 | t.end() 13 | }) 14 | t.is(bot.reconnect(), bot) 15 | }) 16 | 17 | test.cb('#reconnect()', function (t) { 18 | t.plan(3) 19 | 20 | const _spy = spy() 21 | const bot = new CQWebSocket() 22 | .on('socket.reconnecting', _spy) 23 | .on('ready', function () { 24 | t.end() 25 | }) 26 | .reconnect() 27 | 28 | t.true(bot._monitor.API.reconnecting) 29 | t.true(bot._monitor.EVENT.reconnecting) 30 | t.true(_spy.calledTwice) 31 | }) 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const KaomojifyWebpackPlugin = require('kaomojify-webpack-plugin') 3 | 4 | const COMMON_CONFIG = { 5 | mode: 'production', 6 | entry: path.join(__dirname, 'src', 'index.js'), 7 | output: { 8 | library: { 9 | root: 'CQWebSocketSDK', 10 | amd: 'cq-websocket', 11 | commonjs: 'cq-websocket' 12 | }, 13 | libraryTarget: 'umd', 14 | libraryExport: '', 15 | path: path.join(__dirname, 'dist') 16 | } 17 | } 18 | 19 | module.exports = [ 20 | { // minified bundle 21 | ...COMMON_CONFIG, 22 | output: { 23 | ...COMMON_CONFIG.output, 24 | filename: 'cq-websocket.min.js' 25 | } 26 | }, 27 | { // kaomojified bundle (x100 in size) (*´∇`*)/ 28 | ...COMMON_CONFIG, 29 | output: { 30 | ...COMMON_CONFIG.output, 31 | filename: 'cq-websocket.kaomojified.js' 32 | }, 33 | plugins: [ 34 | new KaomojifyWebpackPlugin() 35 | ] 36 | } 37 | ] 38 | --------------------------------------------------------------------------------