├── docs └── images │ ├── 越狱.png │ ├── 一键越狱.png │ ├── 刷机越狱.png │ ├── 设备精灵.png │ └── ioscat.jpeg ├── src ├── index.ts ├── pure-function-helper │ ├── split-name.ts │ ├── xml-to-json.ts │ ├── xml-to-json.spec.ts │ ├── split-name-spec.ts │ ├── is-type.ts │ ├── message-type.ts │ ├── ioscat-event.ts │ ├── room-event-topic-message-parser.ts │ ├── room-event-leave-message-parser.ts │ ├── room-event-xml-message-parser.ts │ ├── message-raw-parser.ts │ ├── room-event-xml-message-parser.spec.ts │ └── room-event-join-message-parser.ts ├── puppet-ioscat.spec.ts ├── config.ts ├── im-sink.ts ├── ioscat-schemas.ts ├── ioscat-manager.ts └── puppet-ioscat.ts ├── .markdownlintrc ├── .editorconfig ├── tests └── fixtures │ └── smoke-testing.ts ├── scripts ├── generate.sh ├── development-release.ts ├── package-publish-config-tag-next.ts ├── npm-pack-testing.sh └── pre-push.sh ├── tslint.json ├── tsconfig.json ├── .vscode └── settings.json ├── README.md ├── .gitignore ├── examples ├── keyword-join-room.ts └── test.ts ├── .travis.yml ├── package.json └── LICENSE /docs/images/越狱.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linyimin0812/wechaty-puppet-ioscat/HEAD/docs/images/越狱.png -------------------------------------------------------------------------------- /docs/images/一键越狱.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linyimin0812/wechaty-puppet-ioscat/HEAD/docs/images/一键越狱.png -------------------------------------------------------------------------------- /docs/images/刷机越狱.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linyimin0812/wechaty-puppet-ioscat/HEAD/docs/images/刷机越狱.png -------------------------------------------------------------------------------- /docs/images/设备精灵.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linyimin0812/wechaty-puppet-ioscat/HEAD/docs/images/设备精灵.png -------------------------------------------------------------------------------- /docs/images/ioscat.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linyimin0812/wechaty-puppet-ioscat/HEAD/docs/images/ioscat.jpeg -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { log } from './config' 2 | 3 | import { PuppetIoscat } from './puppet-ioscat' 4 | 5 | export { PuppetIoscat } 6 | export default PuppetIoscat 7 | -------------------------------------------------------------------------------- /.markdownlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "no-trailing-punctuation": { 4 | "punctuation": ".,;:!" 5 | }, 6 | "MD013": false, 7 | "MD033": false, 8 | "first-line-h1": false, 9 | "no-hard-tabs": true, 10 | "no-trailing-spaces": { 11 | "br_spaces": 2 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /src/pure-function-helper/split-name.ts: -------------------------------------------------------------------------------- 1 | export function splitChineseNameList (nameListText: string): string[] { 2 | // 李卓桓、李佳芮、桔小秘 3 | return nameListText.split('、') 4 | } 5 | 6 | export function splitEnglishNameList (nameListText: string): string[] { 7 | // Zhuohuan, 太阁_传话助手, 桔小秘 8 | return nameListText.split(', ') 9 | } 10 | -------------------------------------------------------------------------------- /src/pure-function-helper/xml-to-json.ts: -------------------------------------------------------------------------------- 1 | import { parseString } from 'xml2js' 2 | 3 | export async function xmlToJson (xml: string): Promise { 4 | return new Promise((resolve, reject) => { 5 | parseString(xml, { explicitArray: false }, (err, result) => { 6 | if (err) { 7 | return reject(err) 8 | } 9 | return resolve(result) 10 | }) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/fixtures/smoke-testing.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-console 4 | 5 | import { 6 | PuppetIoscat, 7 | } from 'wechaty-puppet-ioscat' 8 | 9 | async function main () { 10 | const puppet = new PuppetIoscat() 11 | console.log(`Puppet v${puppet.version()} smoke testing passed.`) 12 | return 0 13 | } 14 | 15 | main() 16 | .then(process.exit) 17 | .catch(e => { 18 | console.error(e) 19 | process.exit(1) 20 | }) 21 | -------------------------------------------------------------------------------- /scripts/generate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | docker run --rm -v \ 5 | ${PWD}:/local \ 6 | swaggerapi/swagger-codegen-cli:v2.3.1 \ 7 | generate \ 8 | -i /local/swagger/ioscat.yaml \ 9 | -l typescript-node \ 10 | -o /local/generated 11 | 12 | sudo chown -R $(id -un) generated/ 13 | 14 | tsc \ 15 | --target esnext \ 16 | --module commonjs \ 17 | --declaration \ 18 | --declarationMap \ 19 | generated/api.ts 20 | 21 | rm -f generated/api.ts 22 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-standard"], 3 | "cliOptions": { 4 | "exclude": [ 5 | "src/api.ts" 6 | ] 7 | }, 8 | "rules": { 9 | "interface-name": [true, "never-prefix"], 10 | "trailing-comma": true, 11 | "import-spacing": false, 12 | "no-multi-spaces": false, 13 | "member-ordering": false, 14 | "typedef-whitespace": false, 15 | "await-promise": false, 16 | "arrow-parens": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/development-release.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-console 4 | // tslint:disable:no-var-requires 5 | 6 | import { minor } from 'semver' 7 | 8 | const { version } = require('../package.json') 9 | 10 | if (minor(version) % 2 === 0) { // production release 11 | console.log(`${version} is production release`) 12 | process.exit(1) // exit 1 for not development 13 | } 14 | 15 | // development release 16 | console.log(`${version} is development release`) 17 | process.exit(0) 18 | -------------------------------------------------------------------------------- /scripts/package-publish-config-tag-next.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-console 4 | // tslint:disable:no-var-requires 5 | 6 | import * as fs from 'fs' 7 | import * as path from 'path' 8 | 9 | const PACKAGE_JSON = path.join(__dirname, '../package.json') 10 | 11 | const pkg = require(PACKAGE_JSON) 12 | 13 | pkg.publishConfig = { 14 | access: 'public', 15 | ...pkg.publishConfig, 16 | tag: 'next', 17 | } 18 | 19 | fs.writeFileSync(PACKAGE_JSON, JSON.stringify(pkg, null, 2)) 20 | // console.log(JSON.stringify(pkg, null, 2)) 21 | 22 | console.log('set package.json:publicConfig.tag to next.') 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6" 4 | , "module": "commonjs" 5 | , "outDir": "dist" 6 | , "declaration": true 7 | , "sourceMap": true 8 | , "strict": true 9 | , "traceResolution": false 10 | , "esModuleInterop" : true 11 | , "resolveJsonModule": true 12 | , "lib": [ 13 | "esnext" 14 | ] 15 | } 16 | , "exclude": [ 17 | "node_modules/" 18 | , "dist/" 19 | ] 20 | , "include": [ 21 | "bin/*.ts" 22 | , "scripts/**/*.ts" 23 | , "examples/**/*.ts" 24 | , "src/**/*.ts" 25 | , "tests/**/*.spec.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/pure-function-helper/xml-to-json.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-shadowed-variable 4 | import test from 'blue-tape' 5 | 6 | import { 7 | xmlToJson, 8 | } from './xml-to-json' 9 | 10 | test('xml2json()', async t => { 11 | const TEXT = '42' 12 | const EXPECTED_OBJ = { mol: '42' } 13 | 14 | const json = await xmlToJson(TEXT) 15 | t.deepEqual(json, EXPECTED_OBJ, 'should parse xml to json right') 16 | }) 17 | 18 | test('xml2json() $', async t => { 19 | const TEXT = '17' 20 | const EXPECTED_OBJ = { mol: { $: { meaning: '42' }, life: '17' } } 21 | 22 | const json = await xmlToJson(TEXT) 23 | t.deepEqual(json, EXPECTED_OBJ, 'should parse xml to json right') 24 | }) 25 | -------------------------------------------------------------------------------- /src/pure-function-helper/split-name-spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-shadowed-variable 4 | import test from 'blue-tape' 5 | 6 | import { 7 | splitChineseNameList, 8 | splitEnglishNameList, 9 | } from './split-name' 10 | 11 | test('splitChineseNameList()', async t => { 12 | const TEXT = '李卓桓、李佳芮、桔小秘' 13 | const EXPECTED_LIST = ['李卓桓', '李佳芮', '桔小秘'] 14 | 15 | const list = splitChineseNameList(TEXT) 16 | t.deepEqual(list, EXPECTED_LIST, 'should split chinese name list') 17 | }) 18 | 19 | test('splitEnglihshNameList()', async t => { 20 | const TEXT = 'Zhuohuan, 李佳芮, 太阁_传话助手' 21 | const EXPECTED_LIST = ['Zhuohuan', '李佳芮', '太阁_传话助手'] 22 | 23 | const list = splitEnglishNameList(TEXT) 24 | t.deepEqual(list, EXPECTED_LIST, 'should split english name list') 25 | }) 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "files.exclude": { 4 | "dist/": true 5 | , "doc/": false 6 | , "node_modules/": false 7 | , "package/": true 8 | } 9 | , "alignment": { 10 | "operatorPadding": "right" 11 | , "indentBase": "firstline" 12 | , "surroundSpace": { 13 | "colon": [1, 1], // The first number specify how much space to add to the left, can be negative. The second number is how much space to the right, can be negative. 14 | "assignment": [1, 1], // The same as above. 15 | "arrow": [1, 1], // The same as above. 16 | "comment": 2 // Special how much space to add between the trailing comment and the code. 17 | // If this value is negative, it means don't align the trailing comment. 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/npm-pack-testing.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | NPM_TAG=latest 5 | if [ ./development-release.ts ]; then 6 | NPM_TAG=next 7 | fi 8 | 9 | npm run dist 10 | npm run pack 11 | 12 | TMPDIR="/tmp/npm-pack-testing.$$" 13 | mkdir "$TMPDIR" 14 | mv *-*.*.*.tgz "$TMPDIR" 15 | cp tests/fixtures/smoke-testing.ts "$TMPDIR" 16 | 17 | cd $TMPDIR 18 | npm init -y 19 | npm install *-*.*.*.tgz \ 20 | @types/quick-lru \ 21 | @types/lru-cache \ 22 | @types/node \ 23 | @types/normalize-package-data \ 24 | brolog \ 25 | file-box \ 26 | hot-import \ 27 | lru-cache \ 28 | memory-card \ 29 | normalize-package-data \ 30 | state-switch \ 31 | typescript \ 32 | "wechaty-puppet@$NPM_TAG" \ 33 | watchdog \ 34 | 35 | ./node_modules/.bin/tsc \ 36 | --esModuleInterop \ 37 | --lib esnext \ 38 | --noEmitOnError \ 39 | --noImplicitAny \ 40 | --target es6 \ 41 | --module commonjs \ 42 | smoke-testing.ts 43 | 44 | node smoke-testing.js 45 | -------------------------------------------------------------------------------- /src/puppet-ioscat.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-shadowed-variable 4 | import test from 'blue-tape' 5 | import sinon from 'sinon' 6 | 7 | import { IosCatManager } from './ioscat-manager' 8 | import { PuppetIoscat } from './puppet-ioscat' 9 | 10 | class PuppetMockTest extends PuppetIoscat { 11 | } 12 | 13 | test('PuppetMock restart without problem', async (t) => { 14 | const puppet = new PuppetMockTest({ 15 | token: 'wxid_tdax1huk5hgs12', 16 | }) 17 | 18 | const sandbox = sinon.createSandbox() 19 | const iosCatManager: IosCatManager = (puppet as any).iosCatManager 20 | sandbox.stub(iosCatManager, 'syncContactsAndRooms').resolves() 21 | 22 | try { 23 | for (let i = 0; i < 3; i++) { 24 | await puppet.start() 25 | await puppet.stop() 26 | t.pass('start/stop-ed at #' + i) 27 | } 28 | t.pass('PuppetMock() start/restart successed.') 29 | } catch (e) { 30 | // tslint:disable:no-console 31 | console.error(e) 32 | t.fail(e) 33 | } finally { 34 | sandbox.restore() 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PUPPET-IOSCAT 2 | 3 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-blue.svg)](https://github.com/chatie/wechaty) 4 | [![NPM Version](https://badge.fury.io/js/wechaty-puppet-ioscat.svg)](https://badge.fury.io/js/wechaty-puppet-ioscat) 5 | [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-blue.svg)](https://www.typescriptlang.org/) 6 | [![Linux/Mac Build Status](https://travis-ci.com/linyimin-bupt/wechaty-puppet-ioscat.svg?branch=master)](https://travis-ci.com/linyimin-bupt/wechaty-puppet-ioscat) 7 | 8 | ![wechaty puppet ioscat](https://linyimin-bupt.github.io/wechaty-puppet-ioscat/images/ioscat.jpeg) 9 | 10 | ## USAGE 11 | 12 | ```ts 13 | import { PuppetIoscat } from 'wechaty-puppet-ioscat' 14 | import { Wechaty } from 'wechaty' 15 | 16 | const puppet = new PuppetIoscat() 17 | const wechaty = new Wechaty({ puppet }) 18 | ``` 19 | 20 | ## AUTHOR 21 | 22 | Yimin LIN \ 23 | 24 | ## COPYRIGHT & LICENSE 25 | 26 | * Code & Docs © 2018 Yimin LIN \ 27 | * Code released under the Apache-2.0 License 28 | * Docs released under Creative Commons 29 | -------------------------------------------------------------------------------- /src/pure-function-helper/is-type.ts: -------------------------------------------------------------------------------- 1 | export function isRoomId (id?: string): boolean { 2 | if (!id) { 3 | // throw new Error('no id') 4 | return false 5 | } 6 | return /@chatroom$/.test(id) 7 | } 8 | 9 | export function isContactId (id?: string): boolean { 10 | if (!id) { 11 | return false 12 | // throw new Error('no id') 13 | } 14 | return !isRoomId(id) 15 | } 16 | 17 | export function isContactOfficialId (id?: string): boolean { 18 | if (!id) { 19 | return false 20 | // throw new Error('no id') 21 | } 22 | return /^gh_/i.test(id) 23 | } 24 | 25 | export function isStrangerV1 (strangerId?: string): boolean { 26 | if (!strangerId) { 27 | return false 28 | // throw new Error('no id') 29 | } 30 | return /^v1_/i.test(strangerId) 31 | } 32 | 33 | export function isStrangerV2 (strangerId?: string): boolean { 34 | if (!strangerId) { 35 | return false 36 | // throw new Error('no id') 37 | } 38 | return /^v2_/i.test(strangerId) 39 | } 40 | 41 | export function isPayload (payload: object): boolean { 42 | if (payload 43 | && Object.keys(payload).length > 0 44 | ) { 45 | return true 46 | } 47 | return false 48 | } 49 | -------------------------------------------------------------------------------- /.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 (https://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 | # next.js build output 61 | .next 62 | /dist/ 63 | /package-lock.json 64 | .DS_Store 65 | 66 | /generated/ 67 | -------------------------------------------------------------------------------- /examples/keyword-join-room.ts: -------------------------------------------------------------------------------- 1 | import { PuppetIoscat } from '../src/' 2 | import { log } from '../src/config' 3 | 4 | const puppet = new PuppetIoscat({ 5 | token: 'wxid_tdax1huk5hgs12', 6 | }) 7 | puppet.on('login', async (userId: string) => { 8 | log.silly(`login: ${userId}`) 9 | }) 10 | .on('message', async (messageId: string) => { 11 | const messagePayload = await puppet.messagePayload(messageId) 12 | const selfId = puppet.selfId() 13 | const fromId = messagePayload.fromId 14 | if (selfId !== fromId && fromId) { 15 | const content = messagePayload.text 16 | if (content === '入群') { 17 | const roomIdList = await puppet.roomSearch({ topic: '直播一群' }) 18 | if (roomIdList.length) { 19 | await puppet.roomAdd(roomIdList[0], fromId) 20 | } 21 | } 22 | } 23 | }) 24 | .on('error', (err: string) => { 25 | log.error('error', err) 26 | }) 27 | .on('room-join', async (roomId: string, inviteeIdList: string[], inviterId: string) => { 28 | log.info('room-join', 'roomId:%s, inviteeIdList=%s, inviterId=%s', 29 | roomId, 30 | JSON.stringify(inviteeIdList), 31 | inviterId, 32 | ) 33 | }) 34 | 35 | puppet.start() 36 | .catch(e => log.error('Puppet', 'start rejectino: %s', e)) 37 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { FileBox } from 'file-box' 2 | import qrImage from 'qr-image' 3 | 4 | export function qrCodeForChatie (): FileBox { 5 | const CHATIE_OFFICIAL_ACCOUNT_QRCODE = 'http://weixin.qq.com/r/qymXj7DEO_1ErfTs93y5' 6 | const name = 'qrcode-for-chatie.png' 7 | const type = 'png' 8 | 9 | const qrStream = qrImage.image(CHATIE_OFFICIAL_ACCOUNT_QRCODE, { type }) 10 | return FileBox.fromStream(qrStream, name) 11 | } 12 | 13 | export const CONSTANT = { 14 | G2G : 2, 15 | LIMIT : 1000, 16 | MESSAGE : `periodic_message`, 17 | NAN : 0, 18 | NULL : '', 19 | P2P : 1, 20 | serviceID : 13, 21 | } 22 | 23 | /** 24 | * 关系状态 1 运营号请求, 2 联系人请求, 3 通过好友, 4 删除好友 25 | */ 26 | export const enum STATUS { 27 | OPERATOR_REUQEST = 1, 28 | CONTACT_REQUEST = 2, 29 | FRIENDS_ACCEPTED = 3, 30 | FRIENDS_DELETED = 4 31 | 32 | } 33 | 34 | import { 35 | // Brolog, 36 | log, 37 | } from 'brolog' 38 | 39 | // export const log = new Brolog() 40 | export { 41 | log, 42 | } 43 | 44 | export function ioscatToken (): string { 45 | if (process.env.WECHATY_PUPPET_IOSCAT_TOKEN) { 46 | return process.env.WECHATY_PUPPET_IOSCAT_TOKEN 47 | } 48 | 49 | throw new Error('Please set it in environment variable WECHATY_PUPPET_IOSCAT_TOKEN') 50 | } 51 | -------------------------------------------------------------------------------- /src/pure-function-helper/message-type.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageType, 3 | } from 'wechaty-puppet' 4 | 5 | import { 6 | IosCatMessage, 7 | IoscatMessageRawPayload, 8 | } from '../ioscat-schemas' 9 | 10 | import equals from 'deep-equal' 11 | 12 | export function messageType ( 13 | rawPayload: IoscatMessageRawPayload, 14 | ): MessageType { 15 | 16 | const payload = rawPayload.payload 17 | const payloadMessageType = payload.messageType 18 | const payloadPlatformMsgType = payload.platformMsgType 19 | const payloadType = { 20 | messageType: payloadMessageType, 21 | platformMsgType: payloadPlatformMsgType, 22 | } 23 | 24 | if (equals(IosCatMessage.Text, payloadType)) { 25 | return MessageType.Text 26 | } 27 | 28 | if (equals(IosCatMessage.Image, payloadType)) { 29 | return MessageType.Image 30 | } 31 | 32 | if (equals(IosCatMessage.Voice, payloadType)) { 33 | return MessageType.Audio 34 | } 35 | 36 | if (equals(IosCatMessage.Video, payloadType)) { 37 | return MessageType.Video 38 | } 39 | 40 | if (equals(IosCatMessage.ShareCard, payloadType)) { 41 | return MessageType.Contact 42 | } 43 | 44 | if (equals(IosCatMessage.File, payloadType)) { 45 | return MessageType.Attachment 46 | } 47 | return MessageType.Unknown 48 | } 49 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | services: 3 | - docker 4 | 5 | language: node_js 6 | node_js: 7 | - "10" 8 | 9 | os: 10 | - linux 11 | 12 | cache: 13 | directories: 14 | - node_modules 15 | 16 | stages: 17 | - test 18 | - pack 19 | - name: deploy 20 | if: (type = push) AND branch =~ ^(master|v\d+\.\d+)$ 21 | 22 | jobs: 23 | include: 24 | - stage: test 25 | script: 26 | - node --version 27 | - npm --version 28 | - echo "Testing Started ..." 29 | - npm run generate 30 | - npm test 31 | - echo "Testing Finished." 32 | 33 | - stage: pack 34 | script: 35 | - echo "NPM Pack Testing Started ..." 36 | - npm version 37 | - npm run generate 38 | - npm run test:pack 39 | - echo "NPM Pack Testing Finished." 40 | 41 | - stage: deploy 42 | script: 43 | - echo "NPM Deploying Started ..." 44 | - npm version 45 | - if ./scripts/development-release.ts; then ./scripts/package-publish-config-tag-next.ts; fi 46 | - npm run generate 47 | - npm run dist 48 | - echo "NPM Building Finished." 49 | 50 | deploy: 51 | provider: npm 52 | email: zixia@zixia.net 53 | api_key: "$NPM_TOKEN" 54 | skip_cleanup: true 55 | on: 56 | all_branches: true 57 | 58 | notifications: 59 | email: 60 | on_success: change 61 | on_failure: change 62 | -------------------------------------------------------------------------------- /src/pure-function-helper/ioscat-event.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | const IOSCAT_EVENT = { 4 | 'broken': 'lym', 5 | 'heartbeat': 'lym', 6 | 'room-create': 'lym', 7 | 'sync-contacts-and-room': 'lym' 8 | } 9 | 10 | type IosCatEventNmae = keyof typeof IOSCAT_EVENT 11 | class Event extends EventEmitter { 12 | public emit (event: 'heartbeat', food: string) : boolean 13 | public emit (event: 'broken', reason?: string) : boolean 14 | public emit (event: 'room-create', roomId: string, topic: string) : boolean 15 | public emit (event: 'sync-contacts-and-room') : boolean 16 | public emit (event: IosCatEventNmae, ...args: any[]) : boolean { 17 | return super.emit(event, ...args) 18 | 19 | } 20 | 21 | public once (event: 'room-create', listener: (roomId: string, topic: string) => void) : this 22 | public once (event: 'room-create', listener: (...args: any[]) => void) : this { 23 | super.once(event, listener) 24 | return this 25 | } 26 | 27 | public on (event: 'broken', listener: (reason?: string) => void) : this 28 | public on (event: 'heartbeat', listener: (food: string) => void) : this 29 | public on (event: 'sync-contacts-and-room', listener: () => void) : this 30 | public on (event: IosCatEventNmae, listener: (...args: any[]) => void) : this { 31 | super.on(event, listener) 32 | return this 33 | } 34 | } 35 | 36 | export const IosCatEvent: Event = new Event() 37 | -------------------------------------------------------------------------------- /scripts/pre-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | set -e 10 | 11 | [ -n "$NO_HOOK" ] && exit 0 12 | 13 | [ -n "$HUAN_INNER_PRE_HOOK" ] && { 14 | # http://stackoverflow.com/a/21334985/1123955 15 | exit 0 16 | } 17 | 18 | npm run lint 19 | 20 | [ -z "$CYGWIN" ] && { 21 | # git rebase 22 | rm -f package-lock.json 23 | npm version patch --no-package-lock 24 | HUAN_INNER_PRE_HOOK=1 git push 25 | 26 | cat <<'_STR_' 27 | ____ _ _ ____ _ 28 | / ___(_) |_ | _ \ _ _ ___| |__ 29 | | | _| | __| | |_) | | | / __| '_ \ 30 | | |_| | | |_ | __/| |_| \__ \ | | | 31 | \____|_|\__| |_| \__,_|___/_| |_| 32 | 33 | ____ _ _ 34 | / ___| _ _ ___ ___ ___ ___ __| | | 35 | \___ \| | | |/ __/ __/ _ \/ _ \/ _` | | 36 | ___) | |_| | (_| (_| __/ __/ (_| |_| 37 | |____/ \__,_|\___\___\___|\___|\__,_(_) 38 | 39 | _STR_ 40 | 41 | echo 42 | echo 43 | echo 44 | echo " ### Npm verion bumped and pushed by inner push inside hook pre-push ###" 45 | echo " ------- vvvvvvv outer push will be canceled, never mind vvvvvvv -------" 46 | echo 47 | echo 48 | echo 49 | exit 127 50 | } 51 | 52 | # must run this after the above `test` ([ -z ...]), 53 | # or will whow a error: error: failed to push some refs to 'git@github.com:Chatie/wechaty.git' 54 | echo "PRE-PUSH HOOK PASSED" 55 | echo 56 | 57 | -------------------------------------------------------------------------------- /examples/test.ts: -------------------------------------------------------------------------------- 1 | import { PuppetIoscat } from '../src/' 2 | 3 | import { log } from '../src/config' 4 | 5 | const puppet = new PuppetIoscat({ 6 | token: 'wxid_tdax1huk5hgs12', 7 | }) 8 | 9 | puppet.on('login', async (user) => { 10 | log.silly(`login: ${user}`) 11 | }) 12 | .on('message', async (messageId) => { 13 | const payload = await puppet.messagePayload(messageId) 14 | log.info('message', JSON.stringify(payload)) 15 | }) 16 | .on('error', err => { 17 | log.error('error', err) 18 | }) 19 | .on('room-join', async (roomId, inviteeIdList, inviterId) => { 20 | log.info('room-join', 'roomId:%s, inviteeIdList=%s, inviterId=%s', 21 | roomId, 22 | JSON.stringify(inviteeIdList), 23 | inviterId, 24 | ) 25 | }) 26 | .on('room-leave', async (roomId, leaverIdList) => { 27 | log.info('room-leave', 'roomId:%s, leaverIdList=%s', 28 | roomId, 29 | JSON.stringify(leaverIdList), 30 | ) 31 | }) 32 | .on('room-topic', async (roomId, newTopic, oldTopic, changerId) => { 33 | log.info('room-topic', 'roomId:%s, newTopic=%s, oldName=%s, changerId=%s', 34 | roomId, 35 | newTopic, 36 | oldTopic, 37 | changerId, 38 | ) 39 | }) 40 | 41 | async function start () { 42 | await puppet.start() 43 | const contactIdList = await puppet.contactSearch({ name: '林贻民' }) 44 | if (contactIdList.length) { 45 | // log.silly('发语音') 46 | // contact.say('hello') 47 | } else { 48 | log.silly('null') 49 | } 50 | 51 | const roomIdList = await puppet.roomSearch({ topic: '直播一群' }) 52 | if (roomIdList.length) { 53 | log.silly('room 发消息') 54 | // room.say('hello') 55 | } else { 56 | log.silly('没有找到群') 57 | } 58 | 59 | } 60 | 61 | start().then(() => { return }).catch(() => { return }) 62 | -------------------------------------------------------------------------------- /src/pure-function-helper/room-event-topic-message-parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PuppetRoomTopicEvent, 3 | YOU, 4 | } from 'wechaty-puppet' 5 | 6 | import { 7 | IoscatMessageRawPayload, 8 | } from '../ioscat-schemas' 9 | 10 | import { 11 | isPayload, 12 | isRoomId, 13 | } from './is-type' 14 | 15 | import { log } from '../config' 16 | import { tranferXmlToText } from './room-event-xml-message-parser' 17 | 18 | /** 19 | * 20 | * 3. Room Topic Event 21 | * 22 | */ 23 | const ROOM_TOPIC_OTHER_REGEX_LIST = [ 24 | /^"(.+)" changed the group name to "(.+)"$/, 25 | /^"(.+)"修改群名为“(.+)”$/, 26 | ] 27 | 28 | const ROOM_TOPIC_YOU_REGEX_LIST = [ 29 | /^(You) changed the group name to "(.+)"$/, 30 | /^(你)修改群名为“(.+)”$/, 31 | ] 32 | 33 | export async function roomTopicEventMessageParser ( 34 | rawPayload: IoscatMessageRawPayload, 35 | ): Promise { 36 | 37 | if (!isPayload(rawPayload)) { 38 | return null 39 | } 40 | 41 | const roomId = rawPayload.payload.platformGid 42 | let content = rawPayload.payload.content 43 | if (content.indexOf('') > 0) { 44 | content = await tranferXmlToText(content) 45 | } 46 | log.silly('roomTopicEventMessageParser', 'content = %s', content) 47 | if (!roomId) { 48 | throw new Error('roomId is not exist') 49 | } 50 | 51 | if (!isRoomId(roomId)) { 52 | return null 53 | } 54 | 55 | let matchesForOther: null | string[] = [] 56 | let matchesForYou: null | string[] = [] 57 | 58 | ROOM_TOPIC_OTHER_REGEX_LIST .some(regex => !!(matchesForOther = content.match(regex))) 59 | ROOM_TOPIC_YOU_REGEX_LIST .some(regex => !!(matchesForYou = content.match(regex))) 60 | 61 | const matches: Array = matchesForOther || matchesForYou 62 | if (!matches) { 63 | return null 64 | } 65 | 66 | let changerName = matches[1] 67 | const topic = matches[2] as string 68 | 69 | if (matchesForYou && changerName === '你' || changerName === 'You') { 70 | changerName = YOU 71 | } 72 | 73 | const roomTopicEvent: PuppetRoomTopicEvent = { 74 | changerName, 75 | roomId, 76 | topic, 77 | } 78 | 79 | return roomTopicEvent 80 | } 81 | -------------------------------------------------------------------------------- /src/im-sink.ts: -------------------------------------------------------------------------------- 1 | import * as amqp from 'amqplib' 2 | import { EventEmitter } from 'events' 3 | import { log } from './config' 4 | class IMSink { 5 | private static connection : amqp.Connection 6 | private static channel : amqp.Channel 7 | public static event = new EventEmitter() 8 | public static async getConnection (): Promise { 9 | log.silly('IMSink', 'getConnection()') 10 | try { 11 | IMSink.connection = await amqp.connect('amqp://admin:qwertyuiop@39.108.78.208:5672') 12 | } catch (err) { 13 | throw new Error(`IMSink getConn() error: ${JSON.stringify(err)}`) 14 | } 15 | } 16 | public static async getChannel (): Promise { 17 | log.silly('IMSink', 'getChannel()') 18 | try { 19 | await IMSink.getConnection() 20 | IMSink.channel = await IMSink.connection.createChannel() 21 | return 22 | } catch (err) { 23 | throw new Error(`IMSink getChannel() error: ${JSON.stringify(err)}`) 24 | } 25 | } 26 | public static async start (topic: string) { 27 | log.silly('IMSink', 'subscribe(%s)', topic) 28 | try { 29 | await IMSink.getChannel() 30 | await IMSink.channel.assertExchange('micro', 'topic', { durable: false }) 31 | const assertQueue = await IMSink.channel.assertQueue('test2', { durable: false }) 32 | await IMSink.channel.bindQueue(assertQueue.queue, 'micro', topic) 33 | IMSink.channel.consume(assertQueue.queue, (msg: any) => { 34 | const obj = JSON.parse(msg.content.toString()) 35 | this.event.emit('MESSAGE', obj) 36 | }, { noAck: true }) 37 | } catch (err) { 38 | throw new Error(`subscribe message error: ${err}`) 39 | } 40 | } 41 | 42 | public static async close () { 43 | await new Promise((r) => setTimeout(r, 1 * 1000)) 44 | if (!IMSink.channel) { 45 | log.error('IMSink','close() %s', 'channel is null, dont need to close') 46 | } else { 47 | await IMSink.channel.close() 48 | } 49 | if (!IMSink.connection) { 50 | return log.error('IMSink','close() %s', 'connection is null, dont need to close') 51 | } 52 | await IMSink.connection.close() 53 | // remove allListener 54 | IMSink.event.removeAllListeners() 55 | } 56 | } 57 | 58 | export default IMSink 59 | -------------------------------------------------------------------------------- /src/pure-function-helper/room-event-leave-message-parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PuppetRoomLeaveEvent, 3 | YOU, 4 | } from 'wechaty-puppet' 5 | 6 | import { 7 | IoscatMessageRawPayload, 8 | } from '../ioscat-schemas' 9 | 10 | import { 11 | isPayload, 12 | isRoomId, 13 | } from './is-type' 14 | 15 | import { tranferXmlToText } from './room-event-xml-message-parser' 16 | 17 | import { log } from '../config' 18 | 19 | /** 20 | * 21 | * 2. Room Leave Event 22 | * 23 | * 24 | * try to find 'leave' event for Room 25 | * 26 | * 1. 27 | * You removed "李卓桓" from the group chat 28 | * You were removed from the group chat by "李卓桓" 29 | * 2. 30 | * 你将"Huan LI++"移出了群聊 31 | * 你被"李卓桓"移出群聊 32 | */ 33 | 34 | const ROOM_LEAVE_OTHER_REGEX_LIST = [ 35 | /^(You) removed "(.+)" from the group chat/, 36 | /^(你)将"(.+)"移出了群聊/, 37 | ] 38 | 39 | const ROOM_LEAVE_BOT_REGEX_LIST = [ 40 | /^(You) were removed from the group chat by "([^"]+)"/, 41 | /^(你)被"([^"]+?)"移出群聊/, 42 | ] 43 | 44 | export async function roomLeaveEventMessageParser ( 45 | rawPayload: IoscatMessageRawPayload, 46 | ): Promise { 47 | 48 | if (!isPayload(rawPayload)) { 49 | return null 50 | } 51 | 52 | const roomId = rawPayload.payload.platformGid 53 | let content = rawPayload.payload.content 54 | content = await tranferXmlToText(content) 55 | 56 | log.silly('roomLeaveEventMessageParser', 'content = %s', content) 57 | 58 | if (!roomId) { 59 | throw new Error('roomId is not exsit') 60 | } 61 | 62 | if (!isRoomId(roomId)) { 63 | return null 64 | } 65 | 66 | let matchesForOther: null | string[] = [] 67 | ROOM_LEAVE_OTHER_REGEX_LIST.some( 68 | regex => !!( 69 | matchesForOther = content.match(regex) 70 | ), 71 | ) 72 | 73 | let matchesForBot: null | string[] = [] 74 | ROOM_LEAVE_BOT_REGEX_LIST.some( 75 | re => !!( 76 | matchesForBot = content.match(re) 77 | ), 78 | ) 79 | 80 | const matches = matchesForOther || matchesForBot 81 | if (!matches) { 82 | return null 83 | } 84 | 85 | let leaverName : undefined | string | YOU 86 | let removerName : undefined | string | YOU 87 | 88 | if (matchesForOther) { 89 | removerName = YOU 90 | leaverName = matchesForOther[2] 91 | } else if (matchesForBot) { 92 | removerName = matchesForBot[2] 93 | leaverName = YOU 94 | } else { 95 | throw new Error('for typescript type checking, will never go here') 96 | } 97 | 98 | const roomLeaveEvent: PuppetRoomLeaveEvent = { 99 | leaverNameList : [leaverName], 100 | removerName, 101 | roomId, 102 | } 103 | return roomLeaveEvent 104 | } 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechaty-puppet-ioscat", 3 | "version": "0.5.22", 4 | "description": "Puppet Ios Cat for Wechaty", 5 | "main": "dist/src/index.js", 6 | "directories": { 7 | "doc": "docs", 8 | "test": "tests" 9 | }, 10 | "typings": "dist/src/index.d.ts", 11 | "engines": { 12 | "wechaty": ">=0.17.46" 13 | }, 14 | "scripts": { 15 | "clean": "shx rm -fr dist/* generated/*", 16 | "dist": "tsc && cp -Rav generated/ dist/", 17 | "example": "ts-node examples/test.ts", 18 | "generate": "bash -x scripts/generate.sh", 19 | "pack": "npm pack", 20 | "lint": "npm run lint:ts && npm run lint:md", 21 | "lint:md": "markdownlint README.md", 22 | "lint:ts": "tslint --project tsconfig.json && tsc --noEmit", 23 | "test": "npm run lint && npm run test:unit", 24 | "test:pack": "bash -x scripts/npm-pack-testing.sh", 25 | "test:unit": "blue-tape -r ts-node/register \"src/**/*.spec.ts\" \"src/*.spec.ts\" \"tests/*.spec.ts\" \"tests/**/*.spec.ts\"" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/lym152898/wechaty-puppet-ioscat.git" 30 | }, 31 | "keywords": [ 32 | "chatie", 33 | "wechaty", 34 | "wechat", 35 | "chatbot", 36 | "sdk", 37 | "bot" 38 | ], 39 | "author": "Yimin LIN ", 40 | "license": "Apache-2.0", 41 | "bugs": { 42 | "url": "https://github.com/lym152898/wechaty-puppet-ioscat/issues" 43 | }, 44 | "homepage": "https://github.com/lym152898/wechaty-puppet-ioscat#readme", 45 | "devDependencies": { 46 | "@types/amqplib": "^0.5.8", 47 | "@types/blue-tape": "^0.1.31", 48 | "@types/commander": "^2.12.2", 49 | "@types/deep-equal": "^1.0.1", 50 | "@types/fs-extra": "^5.0.4", 51 | "@types/lru-cache": "^4.1.1", 52 | "@types/node": "^10.5.5", 53 | "@types/normalize-package-data": "^2.4.0", 54 | "@types/qr-image": "^3.2.1", 55 | "@types/quick-lru": "^1.1.0", 56 | "@types/raven": "^2.5.1", 57 | "@types/read-pkg-up": "^3.0.1", 58 | "@types/request": "^2.47.1", 59 | "@types/semver": "^5.5.0", 60 | "@types/sinon": "^5.0.2", 61 | "@types/xml2js": "^0.4.3", 62 | "blue-tape": "^1.0.0", 63 | "brolog": "^1.6.5", 64 | "file-box": "^0.8.22", 65 | "git-scripts": "^0.2.1", 66 | "hot-import": "^0.2.1", 67 | "lru-cache": "^4.1.3", 68 | "markdownlint-cli": "^0.10.0", 69 | "memory-card": "^0.6.7", 70 | "normalize-package-data": "^2.4.0", 71 | "read-pkg-up": "^4.0.0", 72 | "semver": "^5.5.0", 73 | "shx": "^0.3.1", 74 | "sinon": "^6.1.5", 75 | "state-switch": "^0.6.2", 76 | "ts-node": "^7.0.0", 77 | "tslint": "^5.10.0", 78 | "tslint-config-standard": "^7.1.0", 79 | "typescript": "^3.0.1", 80 | "watchdog": "^0.8.10", 81 | "wechaty-puppet": "^0.14.1" 82 | }, 83 | "git": { 84 | "scripts": { 85 | "pre-push": "./scripts/pre-push.sh" 86 | } 87 | }, 88 | "peerDependencies": { 89 | "file-box": "^0.8.22", 90 | "wechaty-puppet": "^0.14.1" 91 | }, 92 | "dependencies": { 93 | "amqp": "^0.2.7", 94 | "amqplib": "^0.5.2", 95 | "array-flatten": "^2.1.1", 96 | "commander": "^2.17.1", 97 | "cuid": "^2.1.3", 98 | "deep-equal": "^1.0.1", 99 | "flash-store": "^0.11.4", 100 | "fs-extra": "^6.0.1", 101 | "qr-image": "^3.2.0", 102 | "request": "^2.87.0", 103 | "xml2js": "^0.4.19" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/pure-function-helper/room-event-xml-message-parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | log, 3 | } from 'brolog' 4 | 5 | import { xmlToJson } from './xml-to-json' 6 | 7 | interface XmlMember { 8 | username: string, 9 | nickname: string, 10 | } 11 | interface XmlLink { 12 | $: { 13 | name : string, 14 | type : string, 15 | }, 16 | memberlist?: { 17 | member : XmlMember | XmlMember[] 18 | }, 19 | separator? : string, 20 | title? : string, 21 | usernamelist? : {username: string} | Array<{username: string}>, 22 | qrcode? : string, 23 | plain? : string, 24 | username : string, 25 | } 26 | 27 | // type TemplateType = keyof typeof TEMPLATE_TYPE 28 | interface XmlSchema { 29 | sysmsg: { 30 | $: { 31 | type: 'sysmsgtemplate', 32 | }, 33 | sysmsgtemplate: { 34 | content_template: { 35 | $: { 36 | type: string 37 | }, 38 | plain : '', 39 | template : string, 40 | link_list : { 41 | link: XmlLink | XmlLink[] 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * tranfer the xml message to the follow format: 50 | * "$adder$"通过扫描你分享的二维码加入群聊"$revoke$"' 51 | * "$username$"邀请"$names$"加入了群聊 52 | * "$username$"邀请你加入了群聊,群聊参与人还有:$others$' 53 | * '你邀请"$names$"加入了群聊 $revoke$]' 54 | * '你将"linyimin说"移出了群聊' 55 | * @param xml 56 | */ 57 | export async function tranferXmlToText (message: string): Promise { 58 | const xml = message.replace(/^[^\n]+\n/, '') 59 | log.silly(xml) 60 | const parse: XmlSchema = await xmlToJson(xml) 61 | let template = parse.sysmsg.sysmsgtemplate.content_template.template 62 | let links = parse.sysmsg.sysmsgtemplate.content_template.link_list.link 63 | log.silly('main', 'links: %s', JSON.stringify(links)) 64 | if (! Array.isArray(links)) { 65 | links = [links] 66 | } 67 | for (const link of links) { 68 | let content = '' 69 | const name = link.$.name 70 | if (link.memberlist) { 71 | if (! Array.isArray(link.memberlist.member)) { 72 | link.memberlist.member = [link.memberlist.member] 73 | } 74 | for (const member of link.memberlist.member) { 75 | content += member.nickname + (link.separator || '') 76 | } 77 | content = (link.separator && content[content.length - 1] === link.separator) ? 78 | content.slice(0, content.length - 1) : content 79 | template = template.replace(`$${name}$`, content) 80 | continue 81 | } 82 | if (link.username) { 83 | content = link.username 84 | template = template.replace(`$${name}$`, content) 85 | log.silly('result:', template) 86 | continue 87 | } 88 | if (link.usernamelist) { 89 | if (! Array.isArray(link.usernamelist)) { 90 | link.usernamelist = [link.usernamelist] 91 | } 92 | for (const user of link.usernamelist) { 93 | content += user.username + (link.separator || '') 94 | } 95 | content = (link.separator && content[content.length - 1] === link.separator) ? 96 | content.slice(0, content.length - 1) : content 97 | template = template.replace(`$${name}$`, content) 98 | log.silly('usernamelist', template) 99 | continue 100 | } 101 | if (link.plain) { 102 | content = link.plain 103 | template = template.replace(`$${name}$`, content) 104 | continue 105 | } 106 | } 107 | return template 108 | } 109 | -------------------------------------------------------------------------------- /src/pure-function-helper/message-raw-parser.ts: -------------------------------------------------------------------------------- 1 | // import { toJson } from 'xml2json' 2 | 3 | import { 4 | MessagePayload, 5 | MessageType, 6 | } from 'wechaty-puppet' 7 | 8 | import { 9 | IoscatMessageRawPayload, 10 | // PadchatMessageType, 11 | // PadchatContactPayload, 12 | } from '../ioscat-schemas' 13 | 14 | import { 15 | messageType, 16 | } from './message-type' 17 | 18 | export function messageRawPayloadParser ( 19 | rawPayload: IoscatMessageRawPayload, 20 | ): MessagePayload { 21 | 22 | // console.log('messageRawPayloadParser:', rawPayload) 23 | 24 | /** 25 | * 0. Set Message Type 26 | */ 27 | const type = messageType(rawPayload) 28 | 29 | const payloadBase = { 30 | id : rawPayload.id, 31 | timestamp : rawPayload.payload.sendTime, // iosCat message timestamp is seconds 32 | type, 33 | } as { 34 | id : string, 35 | timestamp : number, 36 | type : MessageType, 37 | filename? : string, 38 | } 39 | 40 | // TODO: not deal with file, just realise the text 41 | // if ( type === MessageType.Image 42 | // || type === MessageType.Audio 43 | // || type === MessageType.Video 44 | // || type === MessageType.Attachment 45 | // ) { 46 | // payloadBase.filename = messageFileName(rawPayload) || undefined 47 | // } 48 | 49 | let fromId : undefined | string 50 | let roomId : undefined | string 51 | let toId : undefined | string 52 | let text : undefined | string 53 | 54 | /** 55 | * sessionType = 1 : P2P 56 | * sessionType = 2 : G2G 57 | */ 58 | const sessionType = rawPayload.payload.sessionType 59 | 60 | /** 61 | * direction = 1 : operator recieve a message 62 | * direction = 2 : operator send a message 63 | */ 64 | const direction = rawPayload.payload.direction 65 | /** 66 | * 1. Set Room Id 67 | */ 68 | 69 | if (sessionType === 2) { 70 | roomId = rawPayload.payload.platformGid 71 | } else { 72 | roomId = undefined 73 | } 74 | 75 | /** 76 | * 2. Set To Contact Id 77 | */ 78 | if (sessionType === 1) { 79 | if (direction === 1) { 80 | toId = rawPayload.payload.profilePlatformUid 81 | } else { 82 | toId = rawPayload.payload.platformUid 83 | } 84 | } else { 85 | toId = undefined 86 | } 87 | 88 | /** 89 | * 3. Set From Contact Id 90 | */ 91 | if (direction === 1) { 92 | fromId = rawPayload.payload.platformUid 93 | } else { 94 | fromId = rawPayload.payload.profilePlatformUid 95 | } 96 | 97 | /** 98 | * 99 | * 4. Set Text 100 | */ 101 | // TODO: judge the type of content, may need some special processing 102 | text = rawPayload.payload.content 103 | 104 | /** 105 | * 5.1 Validate Room & From ID 106 | */ 107 | if (!roomId && !fromId) { 108 | throw Error('empty roomId and empty fromId!') 109 | } 110 | /** 111 | * 5.1 Validate Room & To ID 112 | */ 113 | if (!roomId && !toId) { 114 | throw Error('empty roomId and empty toId!') 115 | } 116 | 117 | /** 118 | * 6. Set Contact for ShareCard 119 | */ 120 | // if (type === MessageType.Contact) { 121 | // interface XmlSchema { 122 | // msg: { 123 | // username: string, 124 | // bigheadimgurl: string, 125 | // nickname: string, 126 | // province: string, 127 | // city: string, 128 | // sign: string, 129 | // sex: number, 130 | // antispamticket: string, 131 | // }, 132 | // t: PadchatContactPayload, 133 | // } 134 | // const jsonPayload = JSON.parse(toJson(text)) as XmlSchema 135 | 136 | // console.log('jsonPayload:', jsonPayload) 137 | // } 138 | 139 | let payload: MessagePayload 140 | 141 | // Two branch is the same code. 142 | // Only for making TypeScript happy 143 | if (fromId && toId) { 144 | payload = { 145 | ...payloadBase, 146 | fromId, 147 | roomId, 148 | text, 149 | toId, 150 | } 151 | } else if (roomId) { 152 | payload = { 153 | ...payloadBase, 154 | fromId, 155 | roomId, 156 | text, 157 | toId, 158 | } 159 | } else { 160 | throw new Error('neither toId nor roomId') 161 | } 162 | 163 | return payload 164 | } 165 | -------------------------------------------------------------------------------- /src/pure-function-helper/room-event-xml-message-parser.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | // tslint:disable:no-shadowed-variable 4 | import test from 'blue-tape' 5 | import { 6 | tranferXmlToText, 7 | } from './room-event-xml-message-parser' 8 | 9 | test('$adder$"通过扫描你分享的二维码加入群聊"$revoke$"', async t => { 10 | const TEXT = `12519001238@chatroom: 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | <![CDATA[撤销]]> 27 | 28 | 29 | 30 | 31 | 32 | 33 | ` 34 | const EXPECTED_TEXT = '"linyimin"通过扫描你分享的二维码加入群聊 wxid_nbwjlkw19lkw22' 35 | const result = await tranferXmlToText(TEXT) 36 | t.deepEqual(result, EXPECTED_TEXT, 'should parse xml to text right') 37 | 38 | }) 39 | 40 | test('$username$"邀请"$names$"加入了群聊', async t => { 41 | const TEXT = `5338472179@chatroom: 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ` 69 | const EXPECTED_TEXT = '"林贻民"邀请"linyimin"加入了群聊' 70 | const result = await tranferXmlToText(TEXT) 71 | t.deepEqual(result, EXPECTED_TEXT, 'should parse xml to text right') 72 | 73 | }) 74 | 75 | test('你邀请"$names$"加入了群聊 $revoke$', async t => { 76 | const TEXT = `12519001238@chatroom: 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | <![CDATA[撤销]]> 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ` 102 | const EXPECTED_TEXT = '你邀请"linyimin"加入了群聊 wxid_nbwjlkw19lkw22' 103 | const result = await tranferXmlToText(TEXT) 104 | t.deepEqual(result, EXPECTED_TEXT, 'should parse xml to text right') 105 | 106 | }) 107 | 108 | test('你将"$kickoutname$"移出了群聊', async t => { 109 | const TEXT = `12519001238@chatroom: 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | ` 128 | const EXPECTED_TEXT = '你将"linyimin"移出了群聊' 129 | const result = await tranferXmlToText(TEXT) 130 | t.deepEqual(result, EXPECTED_TEXT, 'should parse xml to text right') 131 | }) 132 | -------------------------------------------------------------------------------- /src/pure-function-helper/room-event-join-message-parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PuppetRoomJoinEvent, 3 | YOU, 4 | } from 'wechaty-puppet' 5 | 6 | import { 7 | IoscatMessageRawPayload, 8 | } from '../ioscat-schemas' 9 | 10 | import { 11 | isPayload, 12 | isRoomId, 13 | } from './is-type' 14 | 15 | import { 16 | splitChineseNameList, 17 | splitEnglishNameList, 18 | } from './split-name' 19 | 20 | import { tranferXmlToText } from './room-event-xml-message-parser' 21 | 22 | import { log } from '../config' 23 | /** 24 | * 25 | * 1. Room Join Event 26 | * 27 | * 28 | * try to find 'join' event for Room 29 | * 30 | * 1. 31 | * 李卓桓 invited Huan to the group chat 32 | * 李卓桓 invited 李佳芮, Huan to the group chat 33 | * 李卓桓 invited you to a group chat with 34 | * 李卓桓 invited you and Huan to the group chat 35 | * 2. 36 | * "李卓桓"邀请"Huan LI++"加入了群聊 37 | * "李佳芮"邀请你加入了群聊,群聊参与人还有:小桔、桔小秘、小小桔、wuli舞哩客服、舒米 38 | * "李卓桓"邀请你和"Huan LI++"加入了群聊 39 | */ 40 | 41 | const ROOM_JOIN_BOT_INVITE_OTHER_REGEX_LIST_ZH = [ 42 | /^你邀请"(.+)"加入了群聊/, 43 | /^" ?(.+)"通过扫描你分享的二维码加入群聊/, 44 | ] 45 | 46 | const ROOM_JOIN_BOT_INVITE_OTHER_REGEX_LIST_EN = [ 47 | /^You invited (.+) to the group chat/, 48 | /^" ?(.+)" joined group chat via the QR code you shared/, 49 | ] 50 | 51 | //////////////////////////////////////////////////// 52 | 53 | const ROOM_JOIN_OTHER_INVITE_BOT_REGEX_LIST_ZH = [ 54 | /^"([^"]+?)"邀请你加入了群聊/, 55 | /^"([^"]+?)"邀请你和"(.+)"加入了群聊/, 56 | ] 57 | 58 | const ROOM_JOIN_OTHER_INVITE_BOT_REGEX_LIST_EN = [ 59 | /^(.+) invited you to a group chat/, 60 | /^(.+) invited you and (.+) to the group chat/, 61 | ] 62 | 63 | //////////////////////////////////////////////////// 64 | 65 | const ROOM_JOIN_OTHER_INVITE_OTHER_REGEX_LIST_ZH = [ 66 | /^"(.+)"邀请"(.+)"加入了群聊/, 67 | ] 68 | 69 | const ROOM_JOIN_OTHER_INVITE_OTHER_REGEX_LIST_EN = [ 70 | /^(.+?) invited (.+?) to (the|a) group chat/, 71 | ] 72 | 73 | //////////////////////////////////////////////////// 74 | 75 | const ROOM_JOIN_OTHER_INVITE_OTHER_QRCODE_REGEX_LIST_ZH = [ 76 | /^" (.+)"通过扫描"(.+)"分享的二维码加入群聊/, 77 | ] 78 | 79 | const ROOM_JOIN_OTHER_INVITE_OTHER_QRCODE_REGEX_LIST_EN = [ 80 | /^"(.+)" joined the group chat via the QR Code shared by "(.+)"/, 81 | ] 82 | 83 | export async function roomJoinEventMessageParser ( 84 | rawPayload: IoscatMessageRawPayload, 85 | ): Promise { 86 | 87 | if (!isPayload(rawPayload)) { 88 | return null 89 | } 90 | 91 | const roomId = rawPayload.payload.platformGid 92 | if (!roomId || !isRoomId(roomId)) { 93 | return null 94 | } 95 | let content = rawPayload.payload.content 96 | content = await tranferXmlToText(content) 97 | 98 | log.silly('roomJoinEventMessageParser', 'content = %s', content) 99 | /** 100 | * when the message is a Recalled type, bot can undo the invitation 101 | */ 102 | let matchesForBotInviteOtherEn = null as null | string[] 103 | let matchesForOtherInviteBotEn = null as null | string[] 104 | let matchesForOtherInviteOtherEn = null as null | string[] 105 | let matchesForOtherInviteOtherQrcodeEn = null as null | string[] 106 | 107 | let matchesForBotInviteOtherZh = null as null | string[] 108 | let matchesForOtherInviteBotZh = null as null | string[] 109 | let matchesForOtherInviteOtherZh = null as null | string[] 110 | let matchesForOtherInviteOtherQrcodeZh = null as null | string[] 111 | 112 | ROOM_JOIN_BOT_INVITE_OTHER_REGEX_LIST_EN.some( 113 | regex => !!(matchesForBotInviteOtherEn = content.match(regex)), 114 | ) 115 | ROOM_JOIN_OTHER_INVITE_BOT_REGEX_LIST_EN.some( 116 | regex => !!(matchesForOtherInviteBotEn = content.match(regex)), 117 | ) 118 | ROOM_JOIN_OTHER_INVITE_OTHER_REGEX_LIST_EN.some( 119 | regex => !!(matchesForOtherInviteOtherEn = content.match(regex)), 120 | ) 121 | ROOM_JOIN_OTHER_INVITE_OTHER_QRCODE_REGEX_LIST_EN.some( 122 | regex => !!(matchesForOtherInviteOtherQrcodeEn = content.match(regex)) 123 | ) 124 | 125 | ROOM_JOIN_BOT_INVITE_OTHER_REGEX_LIST_ZH.some( 126 | regex => !!(matchesForBotInviteOtherZh = content.match(regex)), 127 | ) 128 | ROOM_JOIN_OTHER_INVITE_BOT_REGEX_LIST_ZH.some( 129 | regex => !!(matchesForOtherInviteBotZh = content.match(regex)), 130 | ) 131 | ROOM_JOIN_OTHER_INVITE_OTHER_REGEX_LIST_ZH.some( 132 | regex => !!(matchesForOtherInviteOtherZh = content.match(regex)), 133 | ) 134 | ROOM_JOIN_OTHER_INVITE_OTHER_QRCODE_REGEX_LIST_ZH.some( 135 | regex => !!(matchesForOtherInviteOtherQrcodeZh = content.match(regex)), 136 | ) 137 | 138 | const matchesForBotInviteOther = matchesForBotInviteOtherEn || matchesForBotInviteOtherZh 139 | const matchesForOtherInviteBot = matchesForOtherInviteBotEn || matchesForOtherInviteBotZh 140 | const matchesForOtherInviteOther = matchesForOtherInviteOtherEn || matchesForOtherInviteOtherZh 141 | const matchesForOtherInviteOtherQrcode = matchesForOtherInviteOtherQrcodeEn || matchesForOtherInviteOtherQrcodeZh 142 | 143 | const languageEn = matchesForBotInviteOtherEn 144 | || matchesForOtherInviteBotEn 145 | || matchesForOtherInviteOtherEn 146 | || matchesForOtherInviteOtherQrcodeEn 147 | 148 | const languageZh = matchesForBotInviteOtherZh 149 | || matchesForOtherInviteBotZh 150 | || matchesForOtherInviteOtherZh 151 | || matchesForOtherInviteOtherQrcodeZh 152 | 153 | const matches = matchesForBotInviteOther 154 | || matchesForOtherInviteBot 155 | || matchesForOtherInviteOther 156 | || matchesForOtherInviteOtherQrcode 157 | 158 | if (!matches) { 159 | return null 160 | } 161 | 162 | /** 163 | * 164 | * Parse all Names From the Event Text 165 | * 166 | */ 167 | if (matchesForBotInviteOther) { 168 | /** 169 | * 1. Bot Invite Other to join the Room 170 | * (include invite via QrCode) 171 | */ 172 | const other = matches[1] 173 | 174 | let inviteeNameList 175 | if (languageEn) { 176 | inviteeNameList = splitEnglishNameList(other) 177 | } else if (languageZh) { 178 | inviteeNameList = splitChineseNameList(other) 179 | } else { 180 | throw new Error('make typescript happy') 181 | } 182 | 183 | const inviterName: string | YOU = YOU 184 | const joinEvent: PuppetRoomJoinEvent = { 185 | inviteeNameList, 186 | inviterName, 187 | roomId, 188 | } 189 | return joinEvent 190 | 191 | } else if (matchesForOtherInviteBot) { 192 | /** 193 | * 2. Other Invite Bot to join the Room 194 | */ 195 | // /^"([^"]+?)"邀请你加入了群聊/, 196 | // /^"([^"]+?)"邀请你和"(.+?)"加入了群聊/, 197 | const inviterName = matches[1] 198 | let inviteeNameList: Array = [YOU] 199 | if (matches[2]) { 200 | let nameList 201 | if (languageEn) { 202 | nameList = splitEnglishNameList(matches[2]) 203 | } else if (languageZh) { 204 | nameList = splitChineseNameList(matches[2]) 205 | } else { 206 | throw new Error('neither English nor Chinese') 207 | } 208 | inviteeNameList = inviteeNameList.concat(nameList) 209 | } 210 | 211 | const joinEvent: PuppetRoomJoinEvent = { 212 | inviteeNameList, 213 | inviterName, 214 | roomId, 215 | } 216 | return joinEvent 217 | 218 | } else if (matchesForOtherInviteOther) { 219 | /** 220 | * 3. Other Invite Other to a Room 221 | * (NOT include invite via Qrcode) 222 | */ 223 | // /^"([^"]+?)"邀请"([^"]+)"加入了群聊$/, 224 | // /^([^"]+?) invited ([^"]+?) to (the|a) group chat/, 225 | const inviterName = matches[1] 226 | 227 | let inviteeNameList: string[] 228 | 229 | const other = matches[2] 230 | 231 | if (languageEn) { 232 | inviteeNameList = splitEnglishNameList(other) 233 | } else if (languageZh) { 234 | inviteeNameList = splitChineseNameList(other) 235 | } else { 236 | throw new Error('neither English nor Chinese') 237 | } 238 | 239 | const joinEvent: PuppetRoomJoinEvent = { 240 | inviteeNameList, 241 | inviterName, 242 | roomId, 243 | } 244 | return joinEvent 245 | 246 | } else if (matchesForOtherInviteOtherQrcode) { 247 | /** 248 | * 4. Other Invite Other via Qrcode to join a Room 249 | * /^" (.+)"通过扫描"(.+)"分享的二维码加入群聊/, 250 | */ 251 | const inviterName = matches[2] 252 | 253 | let inviteeNameList: string[] 254 | 255 | const other = matches[1] 256 | 257 | if (languageEn) { 258 | inviteeNameList = splitEnglishNameList(other) 259 | } else if (languageZh) { 260 | inviteeNameList = splitChineseNameList(other) 261 | } else { 262 | throw new Error('neither English nor Chinese') 263 | } 264 | 265 | const joinEvent: PuppetRoomJoinEvent = { 266 | inviteeNameList, 267 | inviterName, 268 | roomId, 269 | } 270 | return joinEvent 271 | 272 | } else { 273 | throw new Error('who invite who?') 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/ioscat-schemas.ts: -------------------------------------------------------------------------------- 1 | export interface IosCatMessageType { 2 | [type: string]: { 3 | messageType : number, 4 | platformMsgType? : number, 5 | } 6 | } 7 | /** 8 | * type integer 9 | * 文本, 1 10 | * 语音, 2 11 | * 图片, 3 12 | * 视频, 4 13 | * 名片, 5 14 | * 链接, 6 15 | * 红包, 7 16 | * 转账, 8 17 | * 地址, 11 18 | * 好友请求, 12 19 | * 动画, 13 20 | * 语音聊天, 14 21 | * 视频聊天, 15 22 | * 模板消息, 18 23 | * 通知, 10000 24 | */ 25 | export const IosCatMessage: IosCatMessageType = { 26 | Animation : { messageType: 13, platformMsgType: 47 }, 27 | File : { messageType: 19, platformMsgType: 49 }, 28 | FriendRequest : { messageType: 12 }, 29 | Image : { messageType: 3, platformMsgType: 3 }, 30 | Link : { messageType: 17, platformMsgType: 49 }, 31 | Location : { messageType: 11, platformMsgType: 48 }, 32 | LuckyMoney : { messageType: 7 }, 33 | Notify : { messageType: 10000 }, 34 | RoomJoinOrLeave : { messageType: 17, platformMsgType: 10002 }, 35 | RoomTopic : { messageType: 10000, platformMsgType: 10000 }, 36 | ShareCard : { messageType: 5, platformMsgType: 42 }, 37 | Template : { messageType: 18 }, 38 | Text : { messageType: 1, platformMsgType: 1 }, 39 | Transfer : { messageType: 8 }, 40 | Video : { messageType: 4, platformMsgType: 43 }, 41 | VideoChat : { messageType: 14 }, 42 | Voice : { messageType: 2, platformMsgType: 34 }, 43 | VoiceChat : { messageType: 15 }, 44 | } 45 | export interface IoscatMessageRawPayload { 46 | 47 | /** p2p message structure */ 48 | 49 | // { payload: 50 | // { profilePlatformUid: , 51 | // profileCustomID: dancewuli, 52 | // platformUid: wxid_j76jk7muhgqz22, 53 | // customID: lymbupy, 54 | // direction: 2, 55 | // messageType: 1, 56 | // sessionType: 1, 57 | // platformMsgType: 1, 58 | // content: hello, 59 | // revoke: 2, 60 | // sendTime: 1531313602, 61 | // snapshot: hello, 62 | // serviceID: 13, 63 | // platformMsgID: 144, 64 | // deviceID: c08e89b931699b60c0551fa6d4a4343c55de183d }, 65 | // type: ON_IM_MESSAGE_RECEIVED } 66 | 67 | /** p2p message structure */ 68 | 69 | /** G2G message structure */ 70 | 71 | /********************recieve a message*******************************/ 72 | // { payload: 73 | // { profilePlatformUid: 'wxid_tdax1huk5hgs12', 74 | // profileCustomID: 'dancewuli', 75 | // platformUid: 'wxid_j76jk7muhgqz22', 76 | // customID: 'lymbupy', 77 | // platformGid: '12519001238@chatroom', 78 | // direction: 1, 79 | // messageType: 1, 80 | // sessionType: 2, 81 | // platformMsgType: 1, 82 | // content: 'hello', 83 | // revoke: 2, 84 | // sendTime: 1531578621, 85 | // snapshot: 'hello', 86 | // serviceID: 13, 87 | // platformMsgID: '107', 88 | // deviceID: 'c08e89b931699b60c0551fa6d4a4343c55de183d' }, 89 | // type: 'ON_IM_MESSAGE_RECEIVED' } 90 | 91 | /********************send a message *******************************/ 92 | // { payload: 93 | // { profilePlatformUid: 'wxid_tdax1huk5hgs12', 94 | // profileCustomID: 'dancewuli', 95 | // platformGid: '12519001238@chatroom', 96 | // direction: 2, 97 | // messageType: 1, 98 | // sessionType: 2, 99 | // platformMsgType: 1, 100 | // content: 'Hello', 101 | // revoke: 2, 102 | // sendTime: 1531578839, 103 | // snapshot: 'Hello', 104 | // serviceID: 13, 105 | // platformMsgID: '108', 106 | // deviceID: 'c08e89b931699b60c0551fa6d4a4343c55de183d' }, 107 | // type: 'ON_IM_MESSAGE_RECEIVED' } 108 | /** G2G message structure */ 109 | 110 | id : string, // 消息ID 111 | payload: { 112 | id? : string, // message id 113 | profileContactID? : string, // 运营号的contactID 114 | profilePlatformUid : string, // 运营号平台Uid 115 | profileCustomID : string, // 运营号平台自定义ID(微信号) 116 | platformUid? : string, // 联系人平台Uid 117 | customID? : string, // 联系人平台自定义ID(微信号) 118 | platformGid? : string, // 平台群ID 119 | direction : number, // 方向,相对于运营号 1 收到, 2 发出 120 | messageType : number, // 消息类型 121 | sessionType : number, // 会话类型 P2P=1, G2G=2 122 | platformMsgType : number, // 平台消息类型 123 | content : string, // 消息内容 124 | revoke : number, // 是否撤销 125 | sendTime : number, // 消息实际发送时间 126 | snapshot : string, // 快照 127 | serviceID : number, // 服务ID 128 | platformMsgID : string, // 平台消息ID 129 | deviceID : string // 设备唯一ID e.g UDID IMEI 130 | }, 131 | type: string, 132 | } 133 | 134 | export interface IoscatFriendshipMessageRawPayload { 135 | /** 136 | * { 137 | * "payload": { 138 | * "id": "4e6318168efe4a988a2276da280f909c", 139 | * "profileContactID": "b823dda88ee3462882d77bcec6df4263", 140 | * "profilePlatformUid": "wxid_tdax1huk5hgs12", 141 | * "profileCustomID": "dancewuli", 142 | * "contactID": "ca87af6ec6134ab184c2266ce5430d76", 143 | * "platformUid": "wxid_5zj4i5htp9ih22", 144 | * "customID": "huan-us", 145 | * "requestTime": 1533751897, 146 | * "requestDesc": "I'm Huan LI", 147 | * "status": 2, 148 | * "timelineBlockByAccount": 1, 149 | * "serviceID": 13, 150 | * "ctime": 1533751898 151 | * }, 152 | * "type": "ON_IM_RELATION_APPLY" 153 | * } 154 | */ 155 | id : string, // message id for save 156 | payload : { 157 | id : string, // friendship message id 158 | profileContactID : string, // operator contact id 159 | profilePlatformUid : string, // operator platform id 160 | profileCustomID : string, // operator custom id(wechaty number) 161 | contactID : string, // applicant Contact id 162 | platformUid : string, // applicant platform id 163 | customID : string, // applicant custom id 164 | requestTime : number, // applicant time 165 | requestDesc : string, // request description 166 | status : number, // ? 167 | timelineBlockByAccount : number, // ? 168 | serviceId : number, // wechaty service id is a constant, who's value is 13 169 | ctime : number, // database create time 170 | }, 171 | type : string, // message type, who's value is ON_IM_RELATION_APPLY 172 | } 173 | export interface IosCatContactRawPayload { 174 | // { 175 | // "code": 0, 176 | // "data": { 177 | // "id": 26425882054657, 178 | // "platformUid": "wxid_j76jk7muhgqz22", 179 | // "customID": "lymbupy", 180 | // "nickname": "林贻民", 181 | // "avatar": "http://wx.qlogo.cn/mmhead/ver_1/z54feSIReicBnbvLqbRkUlN6mFQjc/132", 182 | // "gender": 1, 183 | // "country": "中国", 184 | // "state": "海南", 185 | // "city": "海口", 186 | // "signature": "string", 187 | // "type": 1, 188 | // "serviceID": 13, 189 | // "extra": "string", 190 | // "ctime": 1522510040 191 | // }, 192 | // "msg": "" 193 | // } 194 | id : string, // 系统分配运营号ID 195 | platformUid : string, // 平台uid 196 | customID : string, // 联系人平台自定义ID(微信号) 197 | nickname : string, // 昵称 198 | avatar : string, // 头像 199 | gender : number, // 性别(0-未知, 1-男,2-女) 200 | country : string, // 国家 201 | state : string, // 省份 202 | city : string, // 城市 203 | signature : string, // 签名 204 | type? : number, // 类型 205 | serviceID : number, // 服务号 206 | extra : string, // 扩展字段 207 | ctime : number // 记录数据库时间 208 | tags : string[] // 标签 209 | 210 | } 211 | 212 | export interface IosCatRoomMemberRawPayload { 213 | 214 | // { 215 | // "id": 26916304982016, 216 | // "contactID": 26354400891429, 217 | // "platformUid": "wxid_1htfbf5cm7z322", 218 | // "gid": 26354400890881, 219 | // "platformGid": "8144046175@chatroom", 220 | // "source": "wxid_aeopz1eoj9jx22", 221 | // "serviceID": 13, 222 | // "loaded": 2, 223 | // "ctime": 1524380855 224 | // } 225 | id : number, // 系统分配ID 226 | contactID : number, // 系统分配联系人ID 227 | platformUid : string,// 平台uid 228 | customID? : string, // 平台自定义ID 229 | gid : number, // 系统分配群ID 230 | platformGid : string, // 平台群ID 231 | alias? : string, // 成员别名 232 | source? : string, // 成员来源 233 | serviceID : number, // 服务id 234 | loaded? : number, // 消息是否完整记录 235 | extra? : string, // 扩展字段 236 | tags? : string, // 标签 237 | ctime? : number, // 数据库记录创建时间 238 | } 239 | 240 | export interface IosCatRoomRawPayload { 241 | // { 242 | // "id": 26354400890881, 243 | // "ownerPlatformUid": "qq512436430", 244 | // "profilePlatformUid": "wxid_tdax1huk5hgs12", 245 | // "platformGid": "8144046175@chatroom", 246 | // "name": "舞哩团02群", 247 | // "avatar": "http://wx.qlogo.cn/mmcrhead/PiajxSqBRaEIDG5azH8ZXhft6vkKhMHS4IamVVjAw0mmuqZEWiaGNk/0", 248 | // "signature": "禁止任何广告,拒绝伸手党,否则一律踢", 249 | // "qrcode": "http://cloudbrain-pic.oss-cn-shenzhen.aliyuncs.com/82ffc9a46079fe6bb6867a10b49deff8", 250 | // "qrcodeGenerateTime": 1531313065, 251 | // "memberCount": 237, 252 | // "serviceID": 13 253 | // } 254 | id : string, // 系统分配群ID 255 | ownerContactID? : string, // 系统分配群主联系ID 256 | ownerPlatformUid : string, // 群主平台ID 257 | ownerCustomID? : string, // 群主自定义ID 258 | profileContactID? : number, // 运营号系统分配联系人ID 259 | profilePlatformUid : string, // 运营号平台Uid 260 | profileCustomID? : string, // 运营号平台自定义ID 261 | platformGid : string, // 群ID 262 | name : string, // 群名称 263 | avatar : string, // 群头像 264 | signature : string, // 群公告 265 | qrcode : string, // 群二维码 266 | qrcodeGenerateTime : number, // 二维码生成时间 267 | ctime : number, // 录入系统时间 268 | memberCount : number, // 群成员变量 269 | serviceID : number, // 服务id 270 | extra? : string, // 扩展字段 271 | tags? : string[], // 标签 272 | memberIdList? : string[] // 群成员的ID 273 | } 274 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/ioscat-manager.ts: -------------------------------------------------------------------------------- 1 | import { FlashStoreSync } from 'flash-store' 2 | 3 | import fs from 'fs-extra' 4 | 5 | import os from 'os' 6 | import path from 'path' 7 | 8 | import { 9 | IosCatContactRawPayload, 10 | IosCatRoomMemberRawPayload, 11 | IosCatRoomRawPayload, 12 | } from './ioscat-schemas' 13 | 14 | import { 15 | CONSTANT, 16 | ioscatToken, 17 | log, 18 | } from './config' 19 | 20 | import { 21 | ApiApi, 22 | ContactApi, 23 | GroupApi, 24 | GroupMemberApi, 25 | PBIMSendMessageReq, 26 | ProfileApi, 27 | } from '../generated/api' 28 | 29 | import { PuppetOptions, Receiver } from 'wechaty-puppet' 30 | 31 | export class IosCatManager { 32 | // persistent store 33 | private cacheContactRawPayload? : FlashStoreSync 34 | private cacheRoomMemberRawPayload? : FlashStoreSync 35 | private cacheRoomRawPayload? : FlashStoreSync 36 | 37 | // FIXME: Use id not use platformUid, and Use gid not use platformGid 38 | private contactIdMap: Map = new Map() 39 | private roomIdMap: Map = new Map() 40 | 41 | /** 42 | * swagger generator api 43 | */ 44 | private API : ApiApi = new ApiApi() 45 | private GROUP_API : GroupApi = new GroupApi() 46 | private GROUP_MEMBER_API : GroupMemberApi = new GroupMemberApi() 47 | private CONTACT_API : ContactApi = new ContactApi() 48 | private PROFILE_API : ProfileApi = new ProfileApi() 49 | public timer : NodeJS.Timer | undefined // `undefined` stands for the initail value 50 | constructor ( 51 | public options: PuppetOptions = {}, 52 | ) { 53 | } 54 | 55 | public async initCache ( 56 | token: string, 57 | ): Promise { 58 | log.verbose('PuppetIoscat', 'initCache(%s)', token) 59 | 60 | if (this.cacheContactRawPayload 61 | || this.cacheRoomMemberRawPayload 62 | || this.cacheRoomRawPayload 63 | ) { 64 | throw new Error('cache exists') 65 | } 66 | 67 | const baseDir = path.join( 68 | os.homedir(), 69 | path.sep, 70 | '.wechaty', 71 | 'puppet-ioscat-cache', 72 | path.sep, 73 | token, 74 | ) 75 | 76 | const baseDirExist = await fs.pathExists(baseDir) 77 | 78 | if (!baseDirExist) { 79 | await fs.mkdirp(baseDir) 80 | } 81 | 82 | try { 83 | this.cacheContactRawPayload = new FlashStoreSync(path.join(baseDir, 'contact-raw-payload')) 84 | this.cacheRoomMemberRawPayload = new FlashStoreSync(path.join(baseDir, 'room-member-raw-payload')) 85 | this.cacheRoomRawPayload = new FlashStoreSync(path.join(baseDir, 'room-raw-payload')) 86 | 87 | await Promise.all([ 88 | this.cacheContactRawPayload.ready(), 89 | this.cacheRoomMemberRawPayload.ready(), 90 | this.cacheRoomRawPayload.ready(), 91 | ]) 92 | } catch (err) { 93 | log.error('IoscatManager', 'initCache() failed: %s', JSON.stringify(err)) 94 | } 95 | 96 | if (! this.cacheRoomMemberRawPayload || !this.cacheContactRawPayload 97 | || !this.cacheRoomRawPayload) { 98 | throw new Error('cache not exist') 99 | } 100 | const roomMemberTotalNum = [...this.cacheRoomMemberRawPayload.values()].reduce( 101 | (accuVal, currVal) => { 102 | return accuVal + Object.keys(currVal).length 103 | }, 104 | 0, 105 | ) 106 | 107 | log.verbose('IosCatManager', 'initCache() inited %d Contacts, %d RoomMembers, %d Rooms, cachedir="%s"', 108 | this.cacheContactRawPayload.size, 109 | roomMemberTotalNum, 110 | this.cacheRoomRawPayload.size, 111 | baseDir, 112 | ) 113 | } 114 | 115 | public async releaseCache (): Promise { 116 | log.verbose('IosCatManager', 'releaseCache()') 117 | 118 | if (this.cacheContactRawPayload 119 | && this.cacheRoomMemberRawPayload 120 | && this.cacheRoomRawPayload 121 | ) { 122 | log.silly('IosCatManager', 'releaseCache() closing caches ...') 123 | 124 | await Promise.all([ 125 | this.cacheContactRawPayload.close(), 126 | this.cacheRoomMemberRawPayload.close(), 127 | this.cacheRoomRawPayload.close(), 128 | ]) 129 | 130 | this.cacheContactRawPayload = undefined 131 | this.cacheRoomMemberRawPayload = undefined 132 | this.cacheRoomRawPayload = undefined 133 | 134 | log.silly('IosCaManager', 'releaseCache() cache closed.') 135 | } else { 136 | log.verbose('IosCatManager', 'releaseCache() cache not exist.') 137 | } 138 | } 139 | 140 | /** 141 | * room member 142 | */ 143 | 144 | public async syncRoomMember ( 145 | roomId: string, 146 | ): Promise<{ [contactId: string]: IosCatRoomMemberRawPayload }> { 147 | log.silly('IosCatManager', 'syncRoomMember(%s)', roomId) 148 | 149 | // get memberIdList from infrastructure API with roomId 150 | const response = await this.GROUP_MEMBER_API.imGroupMemberListGet( 151 | CONSTANT.serviceID, 152 | roomId, CONSTANT.NAN, 153 | CONSTANT.NAN, 154 | CONSTANT.NAN, 155 | CONSTANT.LIMIT, 156 | ) 157 | const members = response.body.data 158 | // if the room is not exist, the members.content will be [] 159 | if (!members || members.content.length <= 0) { 160 | this.roomMemberRawPayloadDirty(roomId) 161 | this.roomRawPayloadDirty(roomId) 162 | return {} 163 | } 164 | const memberDict: { [contactId: string]: IosCatRoomMemberRawPayload } = {} 165 | 166 | for (const member of members.content) { 167 | // FIXME: should not use `as any` 168 | memberDict[member.platformUid] = member as any 169 | } 170 | 171 | log.silly('IosCatManager', 'syncRoomMember(%s) total %d members', 172 | roomId, 173 | Object.keys(memberDict).length, 174 | ) 175 | 176 | if (!this.cacheRoomMemberRawPayload) { 177 | throw new Error('cache not inited') 178 | } 179 | 180 | const oldMemberDict = this.cacheRoomMemberRawPayload.get(roomId) 181 | const newMemberDict = { 182 | ...oldMemberDict, 183 | ...memberDict, 184 | } 185 | this.cacheRoomMemberRawPayload.set(roomId, newMemberDict) 186 | return newMemberDict 187 | } 188 | 189 | /** 190 | * Contact and Room 191 | */ 192 | 193 | public async syncContactsAndRooms (): Promise { 194 | log.verbose('IosCatManager', `syncContactsAndRooms()`) 195 | 196 | if (!this.cacheContactRawPayload 197 | || !this.cacheRoomRawPayload 198 | ) { 199 | throw new Error('no cache') 200 | } 201 | 202 | const platformUid = this.options.token || ioscatToken() 203 | // if the user is logined 204 | if (platformUid) { 205 | log.silly('IosCatmanager', 'syncContactAndRooms()') 206 | 207 | /** 208 | * room 209 | */ 210 | try { 211 | const response = await this.GROUP_API.imGroupListGet( 212 | CONSTANT.serviceID, 213 | platformUid, 214 | CONSTANT.NULL, 215 | CONSTANT.NAN, 216 | CONSTANT.NAN, 217 | CONSTANT.NAN, 218 | CONSTANT.LIMIT, 219 | ) 220 | const roomList = response.body.data.content 221 | log.silly('IosCatManager', `syncRooms(), length %s`, roomList.length) 222 | // if not rooms exist, the result roomList will be [] 223 | if (roomList && roomList.length) { 224 | for (const room of roomList) { 225 | // FIXME: should not use `as any` 226 | const roomRawPayload: IosCatRoomRawPayload = room as any 227 | const roomId: string = roomRawPayload.platformGid 228 | if (!roomId) { 229 | continue 230 | } 231 | this.cacheRoomRawPayload.set(roomId, roomRawPayload) 232 | await this.roomMemberRawpayload(room.platformGid) 233 | this.roomIdMap.set(roomId, roomRawPayload.id) 234 | } 235 | } else { 236 | throw new Error(`${platformUid} has not room`) 237 | } 238 | } catch (err) { 239 | log.error('IoscatManager', 'syncContactsAndRooms() failed, %s ', JSON.stringify(err)) 240 | } 241 | 242 | /** 243 | * Contact 244 | */ 245 | const body = (await this.PROFILE_API.imProfileContactsGet(CONSTANT.serviceID, platformUid)).body 246 | if (body.code === 0) { 247 | for (const contact of body.data) { 248 | this.cacheContactRawPayload.set(contact.platformUid, contact as any) 249 | this.contactIdMap.set(contact.platformUid, contact.id + '') 250 | } 251 | } 252 | log.silly('IosCatManager', 'syncContactsAndRooms() syncing Contact(%d) & Room(%d) ...', 253 | this.cacheContactRawPayload.size, 254 | this.cacheRoomRawPayload.size, 255 | ) 256 | log.verbose('IosCatManager', 'syncContactsAndRooms() sync contact done!') 257 | 258 | } else { 259 | throw new Error('id is neither room nor contact') 260 | } 261 | } 262 | 263 | public contactRawPayloadDirty ( 264 | contactId: string, 265 | ): void { 266 | log.verbose('IosCatManager', 'contactRawPayloadDirty(%d)', contactId) 267 | if (!this.cacheContactRawPayload) { 268 | throw new Error('cache not inited') 269 | } 270 | this.cacheContactRawPayload.delete(contactId) 271 | } 272 | 273 | public roomMemberRawPayloadDirty ( 274 | roomId: string, 275 | ): void { 276 | log.verbose('IosCatManager', 'roomMemberRawPayloadDirty(%s)', roomId) 277 | if (!this.cacheRoomMemberRawPayload) { 278 | throw new Error('cache not inited') 279 | } 280 | this.cacheRoomMemberRawPayload.delete(roomId) 281 | } 282 | 283 | public roomRawPayloadDirty ( 284 | roomId: string, 285 | ): void { 286 | log.verbose('IosCatManager', 'roomRawPayloadDirty(%s)', roomId) 287 | if (!this.cacheRoomRawPayload) { 288 | throw new Error('cache not inited') 289 | } 290 | this.cacheRoomRawPayload.delete(roomId) 291 | } 292 | 293 | public async roomRawPayload (id: string): Promise { 294 | log.verbose('PuppetIosCatManager', 'roomRawPayload(%s)', id) 295 | if (!this.cacheRoomRawPayload) { 296 | throw new Error('no cache') 297 | } 298 | 299 | if (this.cacheRoomRawPayload.has(id)) { 300 | const roomRawPayload = this.cacheRoomRawPayload.get(id) 301 | if (roomRawPayload) { 302 | return roomRawPayload 303 | } 304 | } 305 | 306 | // room is not exist in cache, get it from infrastructure API 307 | const body = (await this.GROUP_MEMBER_API.imGroupMemberListGet(CONSTANT.serviceID, id)).body 308 | if (body.code === 0 && body.data && body.data.content.length > 0) { 309 | 310 | const gid = body.data.content[0].gid + '' 311 | const response = await this.GROUP_API.imGroupRetrieveGet(gid) 312 | // FIXME: should not use `as any` 313 | const rawPayload: IosCatRoomRawPayload = response.body.data as any 314 | 315 | // get memberIdList from infrastructure API with roomId 316 | const listMemberResponse = await this.GROUP_MEMBER_API.imGroupMemberListGet( 317 | CONSTANT.serviceID, 318 | id, 319 | CONSTANT.NAN, 320 | CONSTANT.NAN, 321 | CONSTANT.NAN, 322 | CONSTANT.LIMIT, 323 | ) 324 | const members = listMemberResponse.body.data 325 | // if the room of id is not exist, the result will not involved data filed 326 | if (rawPayload && (members && members.content.length > 0)) { 327 | const memberIdList = await members.content.map((value: any, index: any) => { 328 | return value.platformUid 329 | }) 330 | rawPayload.memberIdList = memberIdList 331 | this.cacheRoomRawPayload.set(id, rawPayload) 332 | return rawPayload 333 | } else { 334 | throw new Error(`room of id = ${id} is not exist`) 335 | } 336 | } else { 337 | throw new Error(`room of id = ${id} is not exist`) 338 | } 339 | } 340 | 341 | public async roomMemberRawpayload (roomId: string): Promise<{ [contactId: string]: IosCatRoomMemberRawPayload }> { 342 | log.verbose('IosCatManager', 'roomMemberRawPayload(%s)', roomId) 343 | if (!this.cacheRoomMemberRawPayload) { 344 | throw new Error('cache not init') 345 | } 346 | if (this.cacheRoomMemberRawPayload.has(roomId)) { 347 | const roomMemberPayload = this.cacheRoomMemberRawPayload.get(roomId) 348 | if (! roomMemberPayload) { 349 | throw new Error('room id not exists') 350 | } 351 | return roomMemberPayload 352 | } 353 | const response = await this.GROUP_MEMBER_API.imGroupMemberListGet( 354 | CONSTANT.serviceID, 355 | roomId, 356 | CONSTANT.NAN, 357 | CONSTANT.NAN, 358 | CONSTANT.NAN, 359 | CONSTANT.LIMIT 360 | ) 361 | if (response.body.code === 0 && response.body.data) { 362 | // FIXME: should not use `as any` 363 | const roomMembers = response.body.data.content 364 | const membersPayloads: {[key: string]: IosCatRoomMemberRawPayload} = {} as any 365 | for (const member of roomMembers) { 366 | membersPayloads[member.platformUid] = member 367 | } 368 | this.cacheRoomMemberRawPayload.set(roomId, membersPayloads) 369 | return membersPayloads 370 | } 371 | throw new Error('contact not exist') 372 | } 373 | 374 | public async contactRawPayload (contactId: string): Promise { 375 | if (!this.cacheContactRawPayload) { 376 | throw new Error('cache not init') 377 | } 378 | if (this.cacheContactRawPayload.has(contactId)) { 379 | const contactPayload = this.cacheContactRawPayload.get(contactId) 380 | if (! contactPayload) { 381 | throw new Error('contact id not exists') 382 | } 383 | return contactPayload 384 | } 385 | const platformUid = this.options.token || ioscatToken() 386 | if (contactId === platformUid) { 387 | const body = (await this.API.imApiProfileInfoGet(CONSTANT.serviceID, platformUid)).body 388 | if (body.code === 0 && body.data) { 389 | const result: IosCatContactRawPayload = { 390 | avatar : body.data.avatar, 391 | city : body.data.city, 392 | country : body.data.country, 393 | ctime : body.data.ctime, 394 | customID : body.data.customID, 395 | extra : body.data.extra, 396 | gender : body.data.gender, 397 | id : body.data.id + '', 398 | nickname : body.data.nickname, 399 | platformUid : body.data.platformUid, 400 | serviceID : body.data.serviceID, 401 | signature : body.data.signature, 402 | state : body.data.state, 403 | tags : [body.data.tags], 404 | } 405 | return result 406 | } else { 407 | throw new Error(`contact = ${contactId} not exist`) 408 | } 409 | } else { 410 | const id = this.contactIdMap.get(contactId) 411 | // FIXME: Use id ,not use platformUid 412 | const response = await this.CONTACT_API.imContactRetrieveGet(id) 413 | if (response.body.code === 0 && response.body.data) { 414 | // FIXME: should not use `as any` 415 | const rawPayload: IosCatContactRawPayload = response.body.data as any 416 | this.cacheContactRawPayload.set(contactId, rawPayload) 417 | return rawPayload 418 | } 419 | throw new Error(`contact = ${id} not exist`) 420 | } 421 | } 422 | 423 | public getContactList (): string[] { 424 | log.verbose('IosCatManager', 'getContactList()') 425 | if (!this.cacheContactRawPayload) { 426 | throw new Error('cache not init') 427 | } 428 | const contactIdList = [...this.cacheContactRawPayload.keys()] 429 | log.silly('IosCatManager', 'getContactIdList() = %d', contactIdList.length) 430 | return contactIdList 431 | } 432 | 433 | public getRoomIdList (): string[] { 434 | log.verbose('IosCatManager', 'getRoomIdList()') 435 | if (!this.cacheRoomRawPayload) { 436 | throw new Error('cache not inited') 437 | } 438 | const roomIdList = [...this.cacheRoomRawPayload.keys()] 439 | log.verbose('IosCatManager', 'getRoomIdList()=%d', roomIdList.length) 440 | return roomIdList 441 | } 442 | 443 | public async getRoomMemberIdList ( 444 | roomId: string, 445 | dirty = false, 446 | ): Promise { 447 | log.verbose('IoscatManager', 'getRoomMemberIdList(%s)', roomId) 448 | if (!this.cacheRoomMemberRawPayload) { 449 | throw new Error('cache not inited') 450 | } 451 | 452 | if (dirty) { 453 | this.roomMemberRawPayloadDirty(roomId) 454 | } 455 | 456 | const memberRawPayloadDict = this.cacheRoomMemberRawPayload.get(roomId) 457 | || await this.syncRoomMember(roomId) 458 | 459 | if (!memberRawPayloadDict) { 460 | // or return [] ? 461 | throw new Error('roomId not found: ' + roomId) 462 | } 463 | 464 | const memberIdList = Object.keys(memberRawPayloadDict) 465 | 466 | log.verbose('IoscatManager', 'getRoomMemberIdList(%s) length=%d', roomId, memberIdList.length) 467 | return memberIdList 468 | } 469 | 470 | public async sendMessage ( 471 | receiver : Receiver, 472 | message : string, 473 | messageType? : number, 474 | atMembers? : string[], 475 | ) : Promise { 476 | log.verbose('PuppetIoscat', 'sendMessage(%s, %s)', receiver, message) 477 | if (! this.cacheRoomMemberRawPayload) { 478 | throw new Error('cache no init') 479 | } 480 | const data: PBIMSendMessageReq = new PBIMSendMessageReq() 481 | data.serviceID = CONSTANT.serviceID 482 | data.fromCustomID = this.options.token || ioscatToken() // WECHATY_PUPPET_IOSCAT_TOKEN 483 | data.content = message 484 | if (messageType) { 485 | data.type = messageType 486 | } 487 | if (receiver.roomId) { 488 | // 1. 群聊 first 489 | data.sessionType = CONSTANT.G2G 490 | data.toCustomID = receiver.roomId 491 | // Notice: The member in the room whose alias may not exist, 492 | // if that, we use the contact's nickname 493 | if (atMembers) { 494 | let atMemberName = '' 495 | for (const memberId of atMembers) { 496 | const memberPayload = await this.roomMemberRawpayload(receiver.roomId) 497 | if (!memberPayload) { 498 | throw new Error('Room of roomId is no exist') 499 | } 500 | const memberInfo = memberPayload[memberId] 501 | if (memberInfo && memberInfo.alias) { 502 | atMemberName = '@' + memberInfo.alias + ' ' 503 | } else { 504 | const contactPayload = await this.contactRawPayload(memberId) 505 | if (!contactPayload) { 506 | throw new Error('The contact of id no exist') 507 | } 508 | atMemberName = '@' + contactPayload.nickname + ' ' 509 | } 510 | } 511 | data.content = atMemberName + data.content 512 | } 513 | } else if (receiver.contactId) { 514 | // 2. 私聊 second 515 | data.toCustomID = receiver.contactId 516 | data.sessionType = CONSTANT.P2P 517 | } else { 518 | throw new Error('接收人名称不能为空') 519 | } 520 | this.API.imApiSendMessagePost(data) 521 | } 522 | 523 | public async checkOnline () { 524 | /** 525 | * It's a vey bad behavior that the puppet will do something 526 | * that the user does not expect to 527 | * TODO: Find other ways to replace this functionality 528 | */ 529 | // log.silly('IoscatMnager', 'checkOnline()') 530 | // this.dog.on('feed', (food) => { 531 | // IosCatEvent.emit('heartbeat', food.data) 532 | // log.silly('checkOnline()', 'feed') 533 | // }) 534 | 535 | // this.dog.on('reset', () => { 536 | // // something wrong 537 | // IosCatEvent.emit('broken') 538 | // }) 539 | // // send a message periodic 540 | 541 | // const data: PBIMSendMessageReq = new PBIMSendMessageReq() 542 | // data.serviceID = CONSTANT.serviceID, 543 | // data.sessionType = CONSTANT.P2P 544 | // data.toCustomID = data.fromCustomID = this.options.token || ioscatToken() 545 | // data.content = CONSTANT.MESSAGE 546 | // this.API.imApiSendMessagePost(data) 547 | // this.timer = setInterval(async () => { 548 | // await this.API.imApiSendMessagePost(data) 549 | // }, 20 * 1000) 550 | log.silly('IosCatManager', 'checkOnline()') 551 | } 552 | } 553 | -------------------------------------------------------------------------------- /src/puppet-ioscat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import LRU from 'lru-cache' 20 | 21 | import { 22 | FileBox, 23 | } from 'file-box' 24 | 25 | import { 26 | ContactGender, 27 | ContactPayload, 28 | ContactType, 29 | 30 | FriendshipPayload, 31 | MessagePayload, 32 | MessageType, 33 | 34 | Puppet, 35 | PuppetOptions, 36 | 37 | Receiver, 38 | 39 | RoomMemberPayload, 40 | RoomPayload, 41 | UrlLinkPayload, 42 | } from 'wechaty-puppet' 43 | 44 | import { 45 | IosCatContactRawPayload, 46 | IoscatFriendshipMessageRawPayload, 47 | IosCatMessage, 48 | IoscatMessageRawPayload, 49 | IosCatRoomMemberRawPayload, 50 | IosCatRoomRawPayload, 51 | } from './ioscat-schemas' 52 | 53 | import { 54 | CONSTANT, 55 | ioscatToken, 56 | log, 57 | qrCodeForChatie 58 | } from './config' 59 | 60 | import { default as IMSink } from './im-sink' 61 | 62 | import cuid from 'cuid' 63 | 64 | import { 65 | ApiApi, 66 | PBIMAddFriendReq, 67 | PBIMAddGroupMembersReq, 68 | PBIMCreateGroupReq, 69 | PBIMDeleteGroupMembersReq, 70 | PBIMSetGroupDescReq, 71 | PBIMSetGroupNameReq, 72 | } from '../generated/api' 73 | 74 | import { IosCatManager } from './ioscat-manager' 75 | 76 | import { IosCatEvent } from './pure-function-helper/ioscat-event' 77 | import { messageRawPayloadParser } from './pure-function-helper/message-raw-parser' 78 | 79 | import { roomJoinEventMessageParser } from './pure-function-helper/room-event-join-message-parser' 80 | import { roomLeaveEventMessageParser } from './pure-function-helper/room-event-leave-message-parser' 81 | import { roomTopicEventMessageParser } from './pure-function-helper/room-event-topic-message-parser' 82 | 83 | import flatten from 'array-flatten' 84 | 85 | import equals from 'deep-equal' 86 | export interface MockRoomRawPayload { 87 | topic : string, 88 | memberList : string[], 89 | ownerId : string, 90 | } 91 | 92 | export class PuppetIoscat extends Puppet { 93 | 94 | // ios API class instance 95 | private API: ApiApi = new ApiApi() 96 | 97 | // cache odinary message 98 | private readonly cacheIoscatMessagePayload: LRU.Cache 99 | 100 | // cache friendship request message 101 | private readonly cacheIoscatFirendshipMessage: LRU.Cache 102 | 103 | private iosCatManager: IosCatManager 104 | 105 | constructor ( 106 | public options: PuppetOptions = {}, 107 | ) { 108 | super(options) 109 | 110 | const lruOptions: LRU.Options = { 111 | max: 10000, 112 | // length: function (n) { return n * 2}, 113 | dispose (key: string, val: any) { 114 | log.silly('PuppetIoscat', 'constructor() lruOptions.dispose(%s, %s)', key, JSON.stringify(val)) 115 | }, 116 | maxAge: 1000 * 60 * 60, 117 | } 118 | 119 | this.cacheIoscatMessagePayload = new LRU(lruOptions) 120 | this.cacheIoscatFirendshipMessage = new LRU(lruOptions) 121 | 122 | this.iosCatManager = new IosCatManager(options) 123 | 124 | } 125 | 126 | public async start (): Promise { 127 | log.verbose('PuppetIoscat', `start()`) 128 | 129 | this.state.on('pending') 130 | 131 | // await some tasks... 132 | const topic = `im.topic.13.${this.options.token || ioscatToken()}` 133 | 134 | log.silly('PuppetIoscat', 'start() listen topic: %s', topic) 135 | await IMSink.start(topic) 136 | 137 | this.state.on(true) 138 | 139 | this.id = this.options.token || ioscatToken() 140 | 141 | this.initEventHook() 142 | 143 | // init cache 144 | await this.iosCatManager.initCache(this.id) 145 | 146 | // const user = this.Contact.load(this.id) 147 | // emit contactId 148 | // TODO: 验证 149 | /** 150 | * 1. 确保在线 151 | * 2. 手机没电,关机了,或者猫大王那边的接口当掉了怎么办 152 | */ 153 | this.emit('login', this.id) 154 | // FIXME: should do this after login 155 | // sync roomMember, contact and room 156 | await this.iosCatManager.syncContactsAndRooms() 157 | // IosCatEvent.emit('sync-contacts-and-room') 158 | } 159 | 160 | private initEventHook () { 161 | IMSink.event.on('MESSAGE', async (msg: IoscatMessageRawPayload | IoscatFriendshipMessageRawPayload) => { 162 | /** 163 | * Discard messages when not loggedin 164 | */ 165 | if (!this.id) { 166 | log.warn('PuppetIoscat', 'onIoscatMessage(%s) discarded message because puppet is not logged-in', 167 | JSON.stringify(msg)) 168 | return 169 | } 170 | 171 | /** 172 | * Check for Diffirent Message Types 173 | */ 174 | if (msg.type === 'ON_IM_MESSAGE_RECEIVED') { 175 | // the msg type is IoscatMessageRawPayload 176 | msg = msg as IoscatMessageRawPayload 177 | log.silly( 178 | 'PuppetIoscat', 179 | 'initEventHook() receives a message: %s', msg.payload.content) 180 | /** 181 | * 1. Save message for future usage 182 | */ 183 | msg.id = cuid() 184 | this.cacheIoscatMessagePayload.set(msg.id, msg) 185 | const payloadType = { 186 | messageType : msg.payload.messageType, 187 | platformMsgType : msg.payload.platformMsgType, 188 | } 189 | 190 | /** 191 | * 2. Special event judge 192 | * 1. room-join 193 | * 2. room-leave 194 | * 3. room-topic 195 | */ 196 | if (equals(IosCatMessage.RoomJoinOrLeave, payloadType)) { 197 | await Promise.all([ 198 | this.onIosCatMessageRoomEventJoin(msg), 199 | this.onIoscatMessageRoomEventLeave(msg), 200 | this.onIoscatMessageRoomEventTopic(msg), 201 | ]) 202 | } else if (equals(IosCatMessage.RoomTopic, payloadType)) { 203 | await this.onIoscatMessageRoomEventTopic(msg) 204 | } else { 205 | /** 206 | * 3. emit ordinary message event 207 | */ 208 | this.emit('message', msg.id) 209 | } 210 | return 211 | } 212 | // 掉线信息 213 | if (msg.type === 'ON_DMS_HEARTBEAT_TIMEOUT') { 214 | msg = msg as IoscatMessageRawPayload 215 | // throw 一个error 216 | this.emit('error', new Error(msg.id)) 217 | return 218 | } 219 | // 添加好友信息 220 | if (msg.type === 'ON_IM_RELATION_APPLY') { 221 | // save friendship request message for future usage 222 | msg = msg as IoscatFriendshipMessageRawPayload 223 | log.silly( 224 | 'PuppetIoscat', 225 | 'initEventHook() receives a friend request message') 226 | msg.id = cuid() 227 | this.cacheIoscatFirendshipMessage.set(msg.id, msg) 228 | this.emit('friendship', msg.id) 229 | return 230 | } 231 | }) 232 | } 233 | 234 | public async stop (): Promise { 235 | log.verbose('PuppetIoscat', 'stop()') 236 | 237 | if (this.state.off()) { 238 | log.warn('PuppetIoscat', 'stop() is called on a OFF puppet. await ready(off) and return.') 239 | await this.state.ready('off') 240 | return 241 | } 242 | 243 | this.state.off('pending') 244 | 245 | // await some taks... 246 | // 关闭监听消息事件 247 | await IMSink.close() 248 | 249 | // remove allListener 250 | IosCatEvent.removeAllListeners() 251 | 252 | // remove all timer 253 | if (!this.iosCatManager.timer) { 254 | log.silly('PuppetIoscat', 'stop() ') 255 | } else { 256 | clearInterval(this.iosCatManager.timer) 257 | } 258 | 259 | await this.logout() 260 | this.state.off(true) 261 | } 262 | 263 | public async logout (): Promise { 264 | log.verbose('PuppetIoscat', 'logout()') 265 | 266 | if (!this.id) { 267 | throw new Error('logout before login?') 268 | } 269 | this.removeAllListeners() 270 | this.emit('logout', this.id) // becore we will throw above by logonoff() when this.user===undefined 271 | this.id = undefined 272 | 273 | // do the logout job --> release cache 274 | await this.iosCatManager.releaseCache() 275 | } 276 | 277 | /** 278 | * 279 | * Contact 280 | * 281 | */ 282 | public contactAlias (contactId: string): Promise 283 | public contactAlias (contactId: string, alias: string | null): Promise 284 | 285 | public async contactAlias (contactId: string, alias?: string | null): Promise { 286 | log.verbose('PuppetIoscat', 'contactAlias(%s, %s)', contactId, alias) 287 | 288 | if (typeof alias === 'undefined') { 289 | const contact = await this.contactPayload(contactId) 290 | return contact.alias || '' 291 | } 292 | throw new Error('not supported') 293 | } 294 | 295 | public async contactList (): Promise { 296 | log.verbose('PuppetIoscat', 'contactList()') 297 | if (!this.iosCatManager) { 298 | throw new Error('no ioscat manager') 299 | } 300 | const contactIDs = this.iosCatManager.getContactList() 301 | return contactIDs 302 | } 303 | 304 | public async contactQrcode (contactId: string): Promise { 305 | throw new Error('not supported') 306 | // return await this.bridge.WXqr 307 | } 308 | 309 | public async contactAvatar (contactId: string): Promise 310 | public async contactAvatar (contactId: string, file: FileBox): Promise 311 | 312 | public async contactAvatar (contactId: string, file?: FileBox): Promise { 313 | log.verbose('PuppetIoscat', 'contactAvatar(%s)', contactId) 314 | 315 | /** 316 | * 1. set 317 | */ 318 | if (file) { 319 | throw new Error('not support') 320 | } 321 | 322 | /** 323 | * 2. get 324 | */ 325 | const contact = await this.contactPayload(contactId) 326 | return FileBox.fromUrl(contact.avatar) 327 | } 328 | 329 | public async contactRawPayload (id: string): Promise { 330 | log.verbose('PuppetIoscat', 'contactRawPayload(%s)', id) 331 | const rawPayload: IosCatContactRawPayload = await this.iosCatManager.contactRawPayload(id) 332 | return rawPayload 333 | } 334 | 335 | public async contactRawPayloadParser (rawPayload: IosCatContactRawPayload): Promise { 336 | log.verbose('PuppetIoscat', 'contactRawPayloadParser()') 337 | 338 | let gender = ContactGender.Unknown 339 | if (rawPayload.gender === 1) { 340 | gender = ContactGender.Male 341 | } else if (rawPayload.gender === 2) { 342 | gender = ContactGender.Female 343 | } 344 | let contactType = ContactType.Unknown 345 | if (rawPayload.type === 1) { 346 | contactType = ContactType.Personal 347 | } else if (rawPayload.type === 2) { 348 | contactType = ContactType.Official 349 | } 350 | const payload: ContactPayload = { 351 | avatar : rawPayload.avatar, 352 | city : rawPayload.city, 353 | gender, 354 | id : rawPayload.platformUid, 355 | name : rawPayload.nickname, 356 | province : rawPayload.state, 357 | signature : rawPayload.signature, 358 | type : contactType, 359 | weixin : rawPayload.customID 360 | } 361 | return payload 362 | } 363 | 364 | /** 365 | * Overwrite the Puppet.contactPayload() 366 | */ 367 | public async contactPayload ( 368 | contactId : string, 369 | ) : Promise { 370 | 371 | try { 372 | const payload = await super.contactPayload(contactId) 373 | return payload 374 | } catch (e) { 375 | log.silly('PuppetIoscat', 'contactPayload(%s) exception: %s', contactId, e.message) 376 | log.silly( 377 | 'PuppetIoscat', 378 | 'contactPayload(%s) get failed for %s', 379 | 'try load from room member data source', 380 | contactId 381 | ) 382 | } 383 | 384 | const rawPayload = await this.contactRawPayload(contactId) 385 | 386 | /** 387 | * Issue #1397 388 | * https://github.com/Chatie/wechaty/issues/1397#issuecomment-400962638 389 | * 390 | * Try to use the contact information from the room 391 | * when it is not available directly 392 | */ 393 | if (!rawPayload || Object.keys(rawPayload).length <= 0) { 394 | log.silly('PuppetIoscat', 'contactPayload(%s) rawPayload not exist', contactId) 395 | 396 | const roomList = await this.contactRoomList(contactId) 397 | log.silly('PuppetIoscat', 'contactPayload(%s) found %d rooms', contactId, roomList.length) 398 | 399 | if (roomList.length > 0) { 400 | const roomId = roomList[0] 401 | const roomMemberPayload = await this.roomMemberPayload(roomId, contactId) 402 | if (roomMemberPayload) { 403 | 404 | const payload: ContactPayload = { 405 | avatar : roomMemberPayload.avatar, 406 | gender : ContactGender.Unknown, 407 | id : roomMemberPayload.id, 408 | name : roomMemberPayload.name, 409 | type : ContactType.Personal, 410 | } 411 | 412 | this.cacheContactPayload.set(contactId, payload) 413 | log.silly('PuppetIoscat', 'contactPayload(%s) cache SET', contactId) 414 | 415 | return payload 416 | } 417 | } 418 | throw new Error('no raw payload') 419 | } 420 | 421 | return this.contactRawPayloadParser(rawPayload) 422 | } 423 | 424 | /** 425 | * 426 | * Message 427 | * 428 | */ 429 | public async messageFile (id: string): Promise { 430 | throw new Error('Send file not supported yet') 431 | } 432 | 433 | public async messageRawPayload (id: string): Promise { 434 | log.verbose('PuppetIoscat', 'messageRawPayload(%s)', id) 435 | const rawPayload = this.cacheIoscatMessagePayload.get(id) 436 | if (rawPayload) { 437 | return rawPayload 438 | } 439 | throw new Error('message not exist') 440 | } 441 | 442 | public async messageRawPayloadParser (rawPayload: IoscatMessageRawPayload): Promise { 443 | log.verbose('PuppetIoscat', 'messagePayload(%s)', rawPayload.id) 444 | const payload = messageRawPayloadParser(rawPayload) 445 | return payload 446 | } 447 | 448 | public async messageSendText ( 449 | receiver : Receiver, 450 | text : string, 451 | ) : Promise { 452 | log.verbose('PuppetIoscat', 'messageSend(%s, %s)', receiver, text) 453 | await this.iosCatManager.sendMessage(receiver, text, IosCatMessage.Text.messageType) 454 | 455 | } 456 | 457 | public async messageSendFile ( 458 | receiver : Receiver, 459 | file : FileBox, 460 | ) : Promise { 461 | log.verbose('PuppetIoscat', 'messageSend(%s, %s)', receiver, file) 462 | throw new Error('Send file not supported yet') 463 | } 464 | 465 | public async messageSendContact ( 466 | receiver : Receiver, 467 | contactId : string, 468 | ) : Promise { 469 | log.verbose('PuppetIoscat', 'messageSend("%s", %s)', JSON.stringify(receiver), contactId) 470 | throw new Error('Send Contact not supported yet') 471 | } 472 | 473 | public async messageForward ( 474 | receiver : Receiver, 475 | messageId : string, 476 | ) : Promise { 477 | log.verbose('PuppetIoscat', 'messageForward(%s, %s)', 478 | receiver, 479 | messageId, 480 | ) 481 | log.verbose( 482 | 'PuppetPadchat', 483 | 'messageForward(%s, %s)', 484 | JSON.stringify(receiver), 485 | messageId, 486 | ) 487 | 488 | if (!this.iosCatManager) { 489 | throw new Error('no ioscat manager') 490 | } 491 | 492 | const payload = await this.messagePayload(messageId) 493 | const rawPayload = await this.messageRawPayload(messageId) 494 | 495 | const id = receiver.roomId || receiver.contactId 496 | if (!id) { 497 | throw Error( 498 | `Can not find the receiver id for forwarding voice message(${rawPayload.id}), 499 | forward voice message failed`) 500 | } 501 | 502 | if (payload.type === MessageType.Text) { 503 | if (!payload.text) { 504 | throw new Error('no text') 505 | } 506 | await this.messageSendText( 507 | receiver, 508 | payload.text, 509 | ) 510 | } else if (payload.type === MessageType.Audio) { 511 | await this.iosCatManager.sendMessage(receiver, rawPayload.payload.content, IosCatMessage.Video.messageType) 512 | } else if (payload.type === MessageType.Url) { 513 | await this.messageSendUrl( 514 | receiver, 515 | await this.messageUrl(messageId) 516 | ) 517 | } else if (payload.type === MessageType.Image) { 518 | // Forward Image 519 | await this.iosCatManager.sendMessage(receiver, rawPayload.payload.content, IosCatMessage.Image.messageType) 520 | } else if (payload.type === MessageType.Attachment) { 521 | await this.messageSendFile( 522 | receiver, 523 | await this.messageFile(messageId), 524 | ) 525 | } else { 526 | throw new Error('Unkown type message, forward failed') 527 | } 528 | } 529 | 530 | /** 531 | * 532 | * Room 533 | * 534 | */ 535 | public async roomRawPayload ( 536 | id : string, 537 | ) : Promise { 538 | log.verbose('PuppetIoscat', 'roomRawPayload(%s)', id) 539 | 540 | if (!this.iosCatManager) { 541 | throw new Error('no ioscat manager') 542 | } 543 | const rawPayload = await this.iosCatManager.roomRawPayload(id) 544 | return rawPayload 545 | } 546 | 547 | public async roomRawPayloadParser ( 548 | rawPayload : IosCatRoomRawPayload, 549 | ) : Promise { 550 | log.verbose('PuppetIoscat', 'roomRawPayloadParser(%s)', rawPayload.platformGid) 551 | 552 | // FIXME: should not use any 553 | const payload = { 554 | avatar : rawPayload.avatar, 555 | id : rawPayload.platformGid, 556 | memberIdList : rawPayload.memberIdList, 557 | ownerId : rawPayload.ownerPlatformUid, 558 | topic : rawPayload.name, 559 | } 560 | return payload as any 561 | } 562 | 563 | public async roomList (): Promise { 564 | log.verbose('PuppetIoscat', 'roomList()') 565 | const rooms = this.iosCatManager.getRoomIdList() 566 | return rooms 567 | } 568 | 569 | public async roomDel ( 570 | roomId : string, 571 | contactId : string, 572 | ) : Promise { 573 | log.verbose('PuppetIoscat', 'roomDel(%s, %s)', roomId, contactId) 574 | const requestBody: PBIMDeleteGroupMembersReq = { 575 | customID : this.options.token || ioscatToken(), 576 | memberCustomIDs : [contactId], 577 | platformGid : roomId, 578 | serviceID : CONSTANT.serviceID, 579 | } 580 | 581 | const body = (await this.API.imApiDeleteGroupMembersPost(requestBody)).body 582 | if (body.code === 0) { 583 | await Promise.all([ 584 | this.roomMemberPayloadDirty(roomId), 585 | this.roomPayloadDirty(roomId) 586 | ]) 587 | log.verbose('PuppetIosCat', 'roomDel() success') 588 | } else { 589 | log.error('PuppetIosCat', 'roomDel() failed, %s', body.msg) 590 | } 591 | } 592 | 593 | public async roomAvatar (roomId: string): Promise { 594 | log.verbose('PuppetIoscat', 'roomAvatar(%s)', roomId) 595 | 596 | const payload = await this.roomPayload(roomId) 597 | 598 | if (payload.avatar) { 599 | return FileBox.fromUrl(payload.avatar) 600 | } 601 | log.warn('PuppetIoscat', 'roomAvatar() avatar not found, use the chatie default.') 602 | return qrCodeForChatie() 603 | } 604 | 605 | public async roomAdd ( 606 | roomId : string, 607 | contactId : string, 608 | ) : Promise { 609 | log.verbose('PuppetIoscat', 'roomAdd(%s, %s)', roomId, contactId) 610 | const requestBody: PBIMAddGroupMembersReq = { 611 | customID : this.options.token || ioscatToken(), 612 | memberCustomIDs : [contactId], 613 | platformGid : roomId, 614 | serviceID : CONSTANT.serviceID 615 | } 616 | const body = (await this.API.imApiAddGroupMembersPost(requestBody)).body 617 | if (body.code === 0) { 618 | log.info('PuppetIosCat', 'roomAdd success') 619 | } else { 620 | log.error('PuppetIoscat', 'roomAdd(%s, %s) failed: %s', roomId, contactId, body.msg) 621 | } 622 | } 623 | 624 | public async roomTopic (roomId: string): Promise 625 | public async roomTopic (roomId: string, topic: string): Promise 626 | public async roomTopic ( 627 | roomId : string, 628 | topic? : string, 629 | ) : Promise { 630 | log.verbose('PuppetIoscat', 'roomTopic(%s, %s)', roomId, topic) 631 | 632 | // return the current room topic 633 | if (typeof topic === 'undefined') { 634 | const payload = await this.iosCatManager.roomRawPayload(roomId) 635 | return payload.name 636 | } 637 | // change the topic to the value of topic argument 638 | const requestBody: PBIMSetGroupNameReq = { 639 | customID : this.options.token || ioscatToken(), 640 | groupName : topic, 641 | platformGid : roomId, 642 | serviceID : CONSTANT.serviceID, 643 | } 644 | const body = (await this.API.imApiSetGroupNamePost(requestBody)).body 645 | if (body.code === 0) { 646 | await Promise.all([ 647 | this.roomPayloadDirty(roomId) 648 | ]) 649 | log.silly('PuppetIosCat', 'roomTopic(%s, %s)', roomId, topic) 650 | return 651 | } 652 | throw new Error('change room\'s topic error.') 653 | } 654 | public async roomCreate ( 655 | contactIdList : string[], 656 | topic : string, 657 | ) : Promise { 658 | log.verbose('PuppetIoscat', 'roomCreate(%s, %s)', JSON.stringify(contactIdList), topic) 659 | const requestBody: PBIMCreateGroupReq = { 660 | customID : this.options.token || ioscatToken(), 661 | groupName : topic, 662 | memberCustomIDs : contactIdList, 663 | serviceID : CONSTANT.serviceID, 664 | } 665 | const body = (await this.API.imApiCreateGroupPost(requestBody)).body 666 | let platformGid: string = '' 667 | if (body.code === 0) { 668 | IosCatEvent.once('room-create', (roomId, newTopic) => { 669 | if (topic === newTopic) { 670 | platformGid = roomId 671 | } 672 | }) 673 | 674 | /** 675 | * Give Server some time to the join message payload 676 | */ 677 | await new Promise((r) => setTimeout(r, 1000)) 678 | if (platformGid === '') { 679 | await new Promise((r) => setTimeout(r, 1000)) 680 | } 681 | await this.iosCatManager.roomRawPayload(platformGid) 682 | return platformGid 683 | } 684 | throw new Error('Server error') 685 | } 686 | 687 | // TODO: Conform whether support 688 | public async roomQuit (roomId: string): Promise { 689 | log.verbose('PuppetIoscat', 'roomQuit(%s)', roomId) 690 | throw new Error('not supported') 691 | 692 | } 693 | 694 | public async roomQrcode (roomId: string): Promise { 695 | log.silly('PuppetIoscat', 'roomQrcode(%s)', roomId) 696 | if (!this.iosCatManager) { 697 | throw new Error('no ioscat manager') 698 | } 699 | const room = await this.iosCatManager.roomRawPayload(roomId) 700 | return room.qrcode || '' 701 | } 702 | 703 | public async roomMemberList (roomId: string): Promise { 704 | log.silly('PuppetIoscat', 'roommemberList(%s)', roomId) 705 | if (!this.iosCatManager) { 706 | throw new Error('no padchat manager') 707 | } 708 | 709 | const memberIdList = await this.iosCatManager.getRoomMemberIdList(roomId) 710 | log.silly('PuppetIoscat', 'roomMemberList()=%d', memberIdList.length) 711 | 712 | if (memberIdList.length <= 0) { 713 | await this.roomPayloadDirty(roomId) 714 | } 715 | 716 | return memberIdList 717 | } 718 | 719 | public async roomMemberRawPayload (roomId: string, contactId: string): Promise { 720 | log.verbose('PuppetIoscat', 'roomMemberRawPayload(%s, %s)', roomId, contactId) 721 | if (!this.iosCatManager) { 722 | throw new Error('no ioscat manager') 723 | } 724 | const memberDictRawPayload = await this.iosCatManager.roomMemberRawpayload(roomId) 725 | return memberDictRawPayload[contactId] 726 | } 727 | 728 | public async roomMemberRawPayloadParser (rawPayload: IosCatRoomMemberRawPayload): Promise { 729 | log.verbose('PuppetIoscat', 'roomMemberRawPayloadParser(%s)', JSON.stringify(rawPayload)) 730 | const contactPayload = await this.iosCatManager.roomRawPayload(rawPayload.platformGid) 731 | return { 732 | avatar : contactPayload.avatar, 733 | id : rawPayload.platformUid, 734 | inviterId : rawPayload.source, 735 | name : contactPayload.name, 736 | roomAlias : rawPayload.alias, 737 | } 738 | } 739 | 740 | public async roomMemberPayloadDirty (roomId: string) { 741 | log.silly('PuppetIoscat', 'roomMemberRawPayloadDirty(%s)', roomId) 742 | 743 | if (this.iosCatManager) { 744 | await this.iosCatManager.roomMemberRawPayloadDirty(roomId) 745 | } 746 | 747 | await super.roomMemberPayloadDirty(roomId) 748 | } 749 | 750 | public async roomPayloadDirty (roomId: string): Promise { 751 | log.verbose('PuppetIoscat', 'roomPayloadDirty(%s)', roomId) 752 | 753 | if (this.iosCatManager) { 754 | this.iosCatManager.roomRawPayloadDirty(roomId) 755 | } 756 | 757 | await super.roomPayloadDirty(roomId) 758 | } 759 | 760 | public async roomAnnounce (roomId: string): Promise 761 | public async roomAnnounce (roomId: string, text: string): Promise 762 | 763 | public async roomAnnounce (roomId: string, text?: string): Promise { 764 | const roomRawPayload = await this.iosCatManager.roomRawPayload(roomId) 765 | if (text) { 766 | log.silly('PuppetIoscat', 'roomAnnounce(roomId: %s, text: %s)', roomId, text) 767 | const requestBody: PBIMSetGroupDescReq = { 768 | customID : this.options.token || ioscatToken(), 769 | groupDesc : text, 770 | platformGid : roomId, 771 | serviceID : CONSTANT.serviceID, 772 | } 773 | const body = (await this.API.imApiSetGroupDescPost(requestBody)).body 774 | if (body.code === 0) { 775 | log.verbose('roomAnnounce(roomId: %s, text: %s)', roomId, text) 776 | return 777 | } 778 | } 779 | log.silly('PuppetIoscat', 'roomAnnounce(roomId: %s)', roomId) 780 | return roomRawPayload.signature || '' 781 | } 782 | 783 | /** 784 | * 785 | * Friendship 786 | * 787 | */ 788 | // 特殊消息的messageId 789 | public async friendshipRawPayload (id: string): Promise { 790 | log.verbose('PuppetIoscat', 'friendshipRawPayload(%s)', id) 791 | const rawPayload = this.cacheIoscatFirendshipMessage.get(id) 792 | if (rawPayload) { 793 | return rawPayload 794 | } 795 | throw new Error('message not exist') 796 | } 797 | public async friendshipRawPayloadParser (rawPayload: any): Promise { 798 | return rawPayload 799 | } 800 | 801 | public async friendshipAdd ( 802 | contactId : string, 803 | hello : string, 804 | ) : Promise { 805 | log.verbose('PuppetIoscat', 'friendshipAdd(%s, %s)', contactId, hello) 806 | throw new Error('not support') 807 | } 808 | 809 | public async friendshipAccept ( 810 | friendshipId : string, 811 | ) : Promise { 812 | log.verbose('PuppetIoscat', 'friendshipAccept(%s)', friendshipId) 813 | const messageRawPayload = await this.messageRawPayload(friendshipId) 814 | if (!messageRawPayload.payload.platformUid) { 815 | throw new Error('platformUid not exist, perhaps this is an error message') 816 | } 817 | const requestBody: PBIMAddFriendReq = { 818 | platformUid : messageRawPayload.payload.platformUid, 819 | profileCustomID : this.options.token || ioscatToken(), 820 | serviceID : CONSTANT.serviceID, 821 | } 822 | const body = (await this.API.imApiAddFriendPost(requestBody)).body 823 | if (body.code === 0) { 824 | log.silly('PuppetIoscat', 'friendshipAccept(%s) success', friendshipId) 825 | } else { 826 | log.error('PuppetIoscat', 'friendshipAccept(%s) failed, reason is %s', friendshipId, body.msg) 827 | } 828 | } 829 | 830 | public ding (data?: string): void { 831 | log.silly('PuppetIoscat', 'ding(%s)', data || '') 832 | this.emit('dong', data) 833 | return 834 | } 835 | 836 | // TODO: Add support 837 | public async roomInvitationAccept (roomInvitationId: string): Promise { 838 | log.silly('roomInvitationAccept (%s)', roomInvitationId) 839 | } 840 | 841 | // TODO: Add support 842 | public async roomInvitationRawPayload (rawPayload: any): Promise { 843 | log.silly('roomInvitationRawPayload (%o)', rawPayload) 844 | return {} as any 845 | } 846 | 847 | // Add support 848 | public async roomInvitationRawPayloadParser (rawPayload: any): Promise { 849 | log.silly('roomInvitationRawPayloadParser (%o)', rawPayload) 850 | return {} as any 851 | } 852 | 853 | /** 854 | * Look for room join event 855 | */ 856 | protected async onIosCatMessageRoomEventJoin (rawPayload: IoscatMessageRawPayload): Promise { 857 | log.verbose('PuppetIoscat', 'onIosCatMessageRoomEventJoin({id=%s})', rawPayload.id) 858 | 859 | const roomJoinEvent = await roomJoinEventMessageParser(rawPayload) 860 | log.silly('roomJoinEvent: %s', JSON.stringify(roomJoinEvent)) 861 | if (roomJoinEvent) { 862 | 863 | const inviteeNameList = roomJoinEvent.inviteeNameList 864 | const inviterName = roomJoinEvent.inviterName 865 | const roomId = roomJoinEvent.roomId 866 | log.silly('PuppetIoscat', 'onIosCatMessageRoomEventJoin() roomJoinEvent="%s"', JSON.stringify(roomJoinEvent)) 867 | const roomPayload = await this.iosCatManager.roomRawPayload(roomId) 868 | // Because the members are new added into the room, we need to 869 | // clear the cache, and reload 870 | await Promise.all([ 871 | this.roomMemberPayloadDirty(roomId), 872 | this.roomPayloadDirty(roomId) 873 | ]) 874 | const inviteeIdList = flatten( 875 | await Promise.all( 876 | inviteeNameList.map( 877 | inviteeName => this.roomMemberSearch(roomId, inviteeName), 878 | ), 879 | ), 880 | ) 881 | 882 | if (inviteeIdList.length < 1) { 883 | throw new Error('inviteeIdList not found') 884 | } 885 | 886 | const inviterIdList = await this.roomMemberSearch(roomId, inviterName) 887 | 888 | if (inviterIdList.length < 1) { 889 | throw new Error('no inviterId found') 890 | } else if (inviterIdList.length > 1) { 891 | log.warn('PuppetIoscat', 'onPadchatMessageRoomEvent() case PadchatMesssageSys:', 892 | 'inviterId found more than 1, use the first one.') 893 | } 894 | 895 | const inviterId = inviterIdList[0] 896 | 897 | this.emit('room-join', roomId, inviteeIdList, inviterId) 898 | // To judge whether the room is just created or not 899 | IosCatEvent.emit('room-create', roomId, roomPayload.name) 900 | } 901 | } 902 | 903 | /** 904 | * Look for room leave event 905 | */ 906 | protected async onIoscatMessageRoomEventLeave (rawPayload: IoscatMessageRawPayload): Promise { 907 | log.verbose('PuppetIoscat', 'onPadchatMessageRoomEventLeave({id=%s})', rawPayload.id) 908 | 909 | const roomLeaveEvent = await roomLeaveEventMessageParser(rawPayload) 910 | log.silly('PuppetIoscat', 'onIoscatMessageRoomEventLeave() roomLeaveEvent="%s"', JSON.stringify(roomLeaveEvent)) 911 | if (roomLeaveEvent) { 912 | const leaverNameList = roomLeaveEvent.leaverNameList 913 | const removerName = roomLeaveEvent.removerName 914 | const roomId = roomLeaveEvent.roomId 915 | log.silly('PuppetIoscat', 'onIoscatMessageRoomEventLeave() roomLeaveEvent="%s"', JSON.stringify(roomLeaveEvent)) 916 | 917 | const leaverIdList = flatten( 918 | await Promise.all( 919 | leaverNameList.map( 920 | leaverName => this.roomMemberSearch(roomId, leaverName), 921 | ), 922 | ), 923 | ) 924 | const removerIdList = await this.roomMemberSearch(roomId, removerName) 925 | if (removerIdList.length < 1) { 926 | throw new Error('no removerId found') 927 | } else if (removerIdList.length > 1) { 928 | log.warn('PuppetIoscat', 'onPadchatMessage() case PadchatMesssageSys: removerId found more than 1', 929 | 'use the first one.') 930 | } 931 | const removerId = removerIdList[0] 932 | 933 | if (!this.iosCatManager) { 934 | throw new Error('no padchatManager') 935 | } 936 | 937 | /** 938 | * Set Cache Dirty 939 | */ 940 | await this.roomMemberPayloadDirty(roomId) 941 | await this.roomPayloadDirty(roomId) 942 | 943 | this.emit('room-leave', roomId, leaverIdList, removerId) 944 | } 945 | } 946 | /** 947 | * Look for room topic event 948 | */ 949 | protected async onIoscatMessageRoomEventTopic (rawPayload: IoscatMessageRawPayload): Promise { 950 | log.verbose('PuppetIoscat', 'onIoscatMessageRoomEventTopic({id=%s})', rawPayload.id) 951 | 952 | const roomTopicEvent = await roomTopicEventMessageParser(rawPayload) 953 | log.silly('PuppetIoscat', 'onIoscatMessageRoomEventTopic() roomTopicEvent="%s"', JSON.stringify(roomTopicEvent)) 954 | log.silly(JSON.stringify(rawPayload, null, 2)) 955 | 956 | if (roomTopicEvent) { 957 | const changerName = roomTopicEvent.changerName 958 | const newTopic = roomTopicEvent.topic 959 | const roomId = roomTopicEvent.roomId 960 | log.silly('PuppetIoscat', 'onIoscatMessageRoomEventTopic() roomTopicEvent="%s"', JSON.stringify(roomTopicEvent)) 961 | 962 | const roomOldPayload = await this.roomRawPayload(roomId) 963 | const oldTopic = roomOldPayload.name 964 | const changerIdList = await this.roomMemberSearch(roomId, changerName) 965 | if (changerIdList.length < 1) { 966 | throw new Error('no changerId found') 967 | } else if (changerIdList.length > 1) { 968 | log.warn('PuppetIoscat', 'onIoscatMessage() case IoscatMesssageSys:changerId found more than 1,', 969 | 'use the first one.') 970 | } 971 | const changerId = changerIdList[0] 972 | 973 | /** 974 | * Set Cache Dirty 975 | */ 976 | await this.roomPayloadDirty(roomId) 977 | this.emit('room-topic', roomId, newTopic, oldTopic, changerId) 978 | } 979 | } 980 | 981 | public async messageSendUrl (receiver: Receiver, urlLinkPayload: UrlLinkPayload): Promise { 982 | log.verbose('PuppetIoscat', 'messageSendLink("%s", %s)', JSON.stringify(receiver), JSON.stringify(urlLinkPayload)) 983 | if (! this.iosCatManager) { 984 | throw new Error('no ioscat manager') 985 | } 986 | try { 987 | await this.iosCatManager.sendMessage(receiver, JSON.stringify(urlLinkPayload), IosCatMessage.Link.messageType) 988 | } catch (err) { 989 | log.error('PuppetIoscat', 'messageSendUrl() failed, %s', JSON.stringify(err, null, 2)) 990 | } 991 | } 992 | 993 | public async messageUrl (messageId: string): Promise { 994 | const rawPayload = await this.messageRawPayload(messageId) 995 | if (rawPayload.payload.messageType !== IosCatMessage.Link.messageType) { 996 | throw new Error('Can not get url from non url payload') 997 | } else { 998 | try { 999 | const urlPayload = JSON.parse(rawPayload.payload.content) 1000 | const urlLinkPayload: UrlLinkPayload = { 1001 | description : urlPayload.des, 1002 | thumbnailUrl : urlPayload.thumburl, 1003 | title : urlPayload.title, 1004 | url : urlPayload.url 1005 | } 1006 | return urlLinkPayload 1007 | } catch (err) { 1008 | throw new Error('Can not parse url message payload') 1009 | } 1010 | } 1011 | } 1012 | public async contactSelfQrcode (): Promise { 1013 | if (! this.id) { 1014 | throw new Error('Please set it in environment variable WECHATY_PUPPET_IOSCAT_TOKEN, and restart') 1015 | } 1016 | const contactPayload = await this.contactRawPayload(this.id) 1017 | return contactPayload.avatar 1018 | } 1019 | public async contactSelfName (newName: string) : Promise { 1020 | if (!this.iosCatManager) { 1021 | throw new Error('no padchat manager') 1022 | } 1023 | 1024 | throw new Error('Ioscat not supports contactSelfSignature yet') 1025 | } 1026 | 1027 | public async contactSelfSignature (signature: string) : Promise { 1028 | if (!this.iosCatManager) { 1029 | throw new Error('no padchat manager') 1030 | } 1031 | throw new Error('Ioscat not supports contactSelfSignature yet') 1032 | } 1033 | } 1034 | 1035 | export default PuppetIoscat 1036 | --------------------------------------------------------------------------------