├── .npmignore ├── src ├── typings.d.ts ├── official-account │ ├── mod.ts │ ├── utils.ts │ ├── normalize-file-box.ts │ ├── payload-builder.ts │ ├── simple-unirest.ts │ ├── webhook.spec.ts │ ├── schema.ts │ ├── payload-store.ts │ ├── official-account.spec.ts │ ├── webhook.ts │ └── official-account.ts ├── mod.ts ├── package-json.ts ├── package-json.spec.ts ├── puppet-oa.spec.ts ├── config.ts └── puppet-oa.ts ├── .eslintrc.cjs ├── docs └── images │ └── wechaty-puppet-official-account.png ├── tsconfig.cjs.json ├── .markdownlintrc ├── .editorconfig ├── tsconfig.json ├── scripts ├── package-publish-config-tag.sh ├── generate-package-json.sh └── npm-pack-testing.sh ├── tests ├── fixtures │ ├── smoke-testing.ts │ └── oa-options.ts └── integration.spec.ts ├── .vscode ├── launch.json └── settings.json ├── .gitignore ├── examples ├── echo.ts ├── oauth-scope-snsapi_base.php ├── raw-oa.ts └── ding-dong-bot.ts ├── package.json ├── .github └── workflows │ └── npm.yml ├── README.md └── LICENSE /.npmignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'unirest' 2 | -------------------------------------------------------------------------------- /src/official-account/mod.ts: -------------------------------------------------------------------------------- 1 | export { OfficialAccount } from './official-account.js' 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | 2 | const rules = { 3 | } 4 | 5 | module.exports = { 6 | extends: '@chatie', 7 | rules, 8 | } 9 | -------------------------------------------------------------------------------- /docs/images/wechaty-puppet-official-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-official-account/HEAD/docs/images/wechaty-puppet-official-account.png -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "dist/cjs", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | import { VERSION } from './config.js' 2 | import { PuppetOA } from './puppet-oa.js' 3 | 4 | export { 5 | VERSION, 6 | PuppetOA, 7 | } 8 | export default PuppetOA 9 | -------------------------------------------------------------------------------- /src/official-account/utils.ts: -------------------------------------------------------------------------------- 1 | export function getTimeStampString (): string { 2 | let now: number = new Date().getTime() 3 | if (now > 9999999999) { 4 | now = Math.ceil(now / 1000) 5 | } 6 | return now.toString() 7 | } 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/package-json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will be overwrite when we publish NPM module 3 | * by scripts/generate_version.ts 4 | */ 5 | import type { PackageJson } from 'type-fest' 6 | 7 | /** 8 | * Huan(202108): 9 | * The below default values is only for unit testing 10 | */ 11 | export const packageJson: PackageJson = {} 12 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@chatie/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist/esm", 5 | }, 6 | "exclude": [ 7 | "node_modules/", 8 | "dist/", 9 | "tests/fixtures/", 10 | ], 11 | "include": [ 12 | "bin/*.ts", 13 | "examples/**/*.ts", 14 | "scripts/**/*.ts", 15 | "src/**/*.ts", 16 | "tests/**/*.spec.ts", 17 | ], 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/package-json.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { packageJson } from './package-json.js' 6 | 7 | test('Make sure the packageJson is fresh in source code', async t => { 8 | const keyNum = Object.keys(packageJson).length 9 | t.equal(keyNum, 0, 'packageJson should be empty in source code, only updated before publish to NPM') 10 | }) 11 | -------------------------------------------------------------------------------- /scripts/package-publish-config-tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=$(npx pkg-jq -r .version) 5 | 6 | if npx --package @chatie/semver semver-is-prod $VERSION; then 7 | npx pkg-jq -i '.publishConfig.tag="latest"' 8 | echo "production release: publicConfig.tag set to latest." 9 | else 10 | npx pkg-jq -i '.publishConfig.tag="next"' 11 | echo 'development release: publicConfig.tag set to next.' 12 | fi 13 | 14 | -------------------------------------------------------------------------------- /scripts/generate-package-json.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SRC_PACKAGE_JSON_TS_FILE='src/package-json.ts' 5 | 6 | [ -f ${SRC_PACKAGE_JSON_TS_FILE} ] || { 7 | echo ${SRC_PACKAGE_JSON_TS_FILE}" not found" 8 | exit 1 9 | } 10 | 11 | cat <<_SRC_ > ${SRC_PACKAGE_JSON_TS_FILE} 12 | /** 13 | * This file was auto generated from scripts/generate-version.sh 14 | */ 15 | import type { PackageJson } from 'type-fest' 16 | export const packageJson: PackageJson = $(cat package.json) as any 17 | _SRC_ 18 | -------------------------------------------------------------------------------- /tests/fixtures/smoke-testing.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import{ 3 | PuppetOA, 4 | VERSION, 5 | } from 'wechaty-puppet-official-account' 6 | async function main () { 7 | if (VERSION === '0.0.0') { 8 | throw new Error('version should not be 0.0.0 when prepare for publishing') 9 | } 10 | 11 | const puppet = new PuppetOA() 12 | console.info(`Puppet v${puppet.version()} smoke testing passed.`) 13 | return 0 14 | } 15 | 16 | main() 17 | .then(process.exit) 18 | .catch(e => { 19 | console.error(e) 20 | process.exit(1) 21 | }) 22 | -------------------------------------------------------------------------------- /src/puppet-oa.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import 'dotenv/config.js' 3 | 4 | import { test } from 'tstest' 5 | 6 | import { getOaOptions } from '../tests/fixtures/oa-options.js' 7 | 8 | import { PuppetOA } from './puppet-oa.js' 9 | 10 | import * as ciInfo from 'ci-info' 11 | 12 | class PuppetOATest extends PuppetOA { 13 | } 14 | 15 | test('tbw', async t => { 16 | if (ciInfo.isPR) { 17 | await t.skip('Skip for PR') 18 | return 19 | } 20 | 21 | const oa = new PuppetOATest({ 22 | ...getOaOptions(), 23 | }) 24 | t.ok(oa, 'should be ok') 25 | }) 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "runtimeArgs": ["-r", "/usr/local/lib/node_modules/ts-node/register"], 15 | "args": ["${relativeFile}"]​, 16 | "outFiles": [ 17 | "${workspaceFolder}/**/*.js" 18 | ] 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /tests/fixtures/oa-options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * lizhuohuan 的接口测试号 3 | * 4 | * https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login 5 | * https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Requesting_an_API_Test_Account.html 6 | */ 7 | import { envOptions } from '../../src/config.js' 8 | 9 | const getOaOptions = () => { 10 | const options = envOptions() 11 | 12 | if (!options.appId || !options.appSecret || !options.token) { 13 | throw new Error('getOaOptions(): please check your environment variables!') 14 | } 15 | 16 | return { 17 | appId : options.appId, 18 | appSecret : options.appSecret, 19 | token : options.token, 20 | } 21 | } 22 | 23 | export { getOaOptions } 24 | -------------------------------------------------------------------------------- /src/official-account/normalize-file-box.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileBoxInterface, 3 | FileBoxType, 4 | } from 'file-box' 5 | import { 6 | log, 7 | } from 'wechaty-puppet' 8 | 9 | import type { 10 | FileInfo, 11 | } from './simple-unirest.js' 12 | 13 | const normalizeFileBox = async (fileBox: FileBoxInterface): Promise<{ buf: Buffer, info: FileInfo}> => { 14 | log.verbose('WechatyPluginFreshdesk', 'normalizeFileBox({type: "%s", name: "%s"})', 15 | FileBoxType[fileBox.type], 16 | fileBox.name, 17 | ) 18 | 19 | const buf = await fileBox.toBuffer() 20 | const length = buf.byteLength 21 | 22 | const info: FileInfo = { 23 | contentType : fileBox.mediaType, 24 | filename : fileBox.name.trim(), 25 | knownLength : length, 26 | } 27 | 28 | return { 29 | buf, 30 | info, 31 | } 32 | } 33 | 34 | export { normalizeFileBox } 35 | -------------------------------------------------------------------------------- /src/official-account/payload-builder.ts: -------------------------------------------------------------------------------- 1 | function textMessagePayload (args: { 2 | fromUserName : string, 3 | toUserName : string, 4 | content : string, 5 | }): string { 6 | const xml = [ 7 | '', 8 | '', 9 | '' + new Date().getTime() + '', 10 | '', 11 | '', 12 | ].join('') 13 | return xml 14 | } 15 | 16 | function imageMessagePayload (args: { 17 | toUserName : string, 18 | fromUserName : string, 19 | mediaId : string, 20 | }): string { 21 | const xml = [ 22 | '', 23 | '', 24 | '' + new Date().getTime() + '', 25 | '', 26 | '', 27 | ].join('') 28 | return xml 29 | } 30 | 31 | export { 32 | textMessagePayload, 33 | imageMessagePayload, 34 | } 35 | -------------------------------------------------------------------------------- /.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 | t/ 66 | t.* 67 | 68 | .idea 69 | -------------------------------------------------------------------------------- /examples/echo.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'wechaty' 2 | import { WechatyBuilder } from 'wechaty' 3 | import * as PUPPET from 'wechaty-puppet' 4 | 5 | import { PuppetOA } from '../src/mod.js' 6 | 7 | // 1. Declare your Bot 8 | const puppet = new PuppetOA({ 9 | port: 80, 10 | }) 11 | const bot = WechatyBuilder.build({ 12 | puppet, 13 | }) 14 | 15 | // 2. Register event handlers for Bot 16 | bot 17 | .on('error', onError) 18 | .on('message', onMessage) 19 | 20 | function onError (error: Error) { 21 | console.error('Bot error:', error) 22 | } 23 | 24 | async function onMessage (message: Message) { 25 | switch (message.type()) { 26 | case PUPPET.types.Message.Text: 27 | await message.talker().say(message.text()) 28 | break 29 | case PUPPET.types.Message.Audio: 30 | await message.talker().say(await message.toFileBox()) 31 | break 32 | default: 33 | throw new Error(`Handler for message type ${message.type()} is not implemented the example`) 34 | } 35 | } 36 | 37 | // 3. Start the bot! 38 | bot.start() 39 | .catch(async e => { 40 | console.error('Bot start() fail:', e) 41 | process.exit(-1) 42 | }) 43 | 44 | const welcome = ` 45 | Puppet Version: ${puppet.version()} 46 | 47 | Please wait... I'm trying to login in... 48 | 49 | ` 50 | console.info(welcome) 51 | -------------------------------------------------------------------------------- /src/official-account/simple-unirest.ts: -------------------------------------------------------------------------------- 1 | import unirest from 'unirest' 2 | 3 | export interface FileInfo { 4 | contentType?: string, 5 | filename : string, 6 | knownLength : number, 7 | } 8 | 9 | type RequestType = 'json' | 'html' 10 | 11 | interface UnirestRequest extends Promise<{ body: T }> { 12 | attach : (formName: string, buf: Buffer, info?: FileInfo) => UnirestRequest 13 | type : (t: RequestType) => UnirestRequest 14 | field : (payload: Object) => UnirestRequest 15 | send : (payload: Object | Buffer | string) => UnirestRequest 16 | end : (resolve: (result: any) => void) => UnirestRequest 17 | } 18 | 19 | export interface SimpleUnirest { 20 | get: (url: string) => UnirestRequest 21 | post: (url: string) => UnirestRequest 22 | } 23 | 24 | function getSimpleUnirest ( 25 | endpoint : string, 26 | ): SimpleUnirest { 27 | // const auth = 'Basic ' + Buffer.from(apiKey + ':' + 'X').toString('base64') 28 | const headers = { 29 | // Authorization: auth, 30 | } 31 | 32 | return { 33 | get: (url: string) => unirest 34 | .get(endpoint + url) 35 | .headers(headers), 36 | 37 | post: (url: string) => unirest 38 | .post(endpoint + url) 39 | .headers(headers), 40 | } 41 | } 42 | 43 | export { getSimpleUnirest } 44 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | FileBox, 5 | } from 'file-box' 6 | 7 | import { log } from 'wechaty-puppet' 8 | 9 | import type { PuppetOAOptions } from './puppet-oa.js' 10 | import { packageJson } from './package-json.js' 11 | 12 | const VERSION = packageJson.version || '0.0.0' 13 | 14 | const CHATIE_OFFICIAL_ACCOUNT_QRCODE = 'http://weixin.qq.com/r/qymXj7DEO_1ErfTs93y5' 15 | 16 | function qrCodeForChatie (): FileBox { 17 | return FileBox.fromQRCode(CHATIE_OFFICIAL_ACCOUNT_QRCODE) 18 | } 19 | 20 | function envOptions (): Partial { 21 | /* eslint-disable sort-keys */ 22 | return { 23 | appId : process.env['WECHATY_PUPPET_OA_APP_ID'], 24 | appSecret : process.env['WECHATY_PUPPET_OA_APP_SECRET'], 25 | personalMode : !!process.env['WECHATY_PUPPET_OA_PERSONAL_MODE'], 26 | port : process.env['WECHATY_PUPPET_OA_PORT'] ? parseInt(process.env['WECHATY_PUPPET_OA_PORT']) : undefined, 27 | token : process.env['WECHATY_PUPPET_OA_TOKEN'], 28 | webhookProxyUrl : process.env['WECHATY_PUPPET_OA_WEBHOOK_PROXY_URL'], 29 | accessTokenProxyUrl : process.env['WECHATY_PUPPET_OA_ACCESS_TOKEN_PROXY'], 30 | } 31 | } 32 | 33 | export { 34 | qrCodeForChatie, 35 | envOptions, 36 | log, 37 | VERSION, 38 | } 39 | -------------------------------------------------------------------------------- /scripts/npm-pack-testing.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=$(npx pkg-jq -r .version) 5 | 6 | if npx --package @chatie/semver semver-is-prod "$VERSION"; then 7 | NPM_TAG=latest 8 | else 9 | NPM_TAG=next 10 | fi 11 | 12 | npm run dist 13 | npm pack 14 | 15 | TMPDIR="/tmp/npm-pack-testing.$$" 16 | mkdir "$TMPDIR" 17 | mv ./*-*.*.*.tgz "$TMPDIR" 18 | cp tests/fixtures/smoke-testing.ts "$TMPDIR" 19 | 20 | cd $TMPDIR 21 | 22 | npm init -y 23 | npm install --production *-*.*.*.tgz \ 24 | @types/node \ 25 | @chatie/tsconfig@$NPM_TAG \ 26 | pkg-jq \ 27 | "wechaty-puppet@$NPM_TAG" \ 28 | "wechaty@$NPM_TAG" \ 29 | 30 | # 31 | # CommonJS 32 | # 33 | ./node_modules/.bin/tsc \ 34 | --target es6 \ 35 | --module CommonJS \ 36 | \ 37 | --moduleResolution node \ 38 | --esModuleInterop \ 39 | --lib esnext \ 40 | --noEmitOnError \ 41 | --noImplicitAny \ 42 | --skipLibCheck \ 43 | smoke-testing.ts 44 | 45 | echo 46 | echo "CommonJS: pack testing..." 47 | node smoke-testing.js 48 | 49 | # 50 | # ES Modules 51 | # 52 | npx pkg-jq -i '.type="module"' 53 | 54 | ./node_modules/.bin/tsc \ 55 | --target es2020 \ 56 | --module es2020 \ 57 | \ 58 | --moduleResolution node \ 59 | --esModuleInterop \ 60 | --lib esnext \ 61 | --noEmitOnError \ 62 | --noImplicitAny \ 63 | --skipLibCheck \ 64 | smoke-testing.ts 65 | 66 | echo 67 | echo "ES Module: pack testing..." 68 | node smoke-testing.js 69 | -------------------------------------------------------------------------------- /examples/oauth-scope-snsapi_base.php: -------------------------------------------------------------------------------- 1 | "功能设置"->"网页授权域名" 13 | define ('_appid','APP_ID'); //请换成你自己的公众账号appid 14 | define ('_appsecret','APP_SECRET');//请换成你自己的公众账号的 appsecret 15 | define ('_mpauthurl','https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_base&state=123#wechat_redirect'); 16 | define ('_urlgetaccesstoken', 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code'); 17 | 18 | if (isset($_GET["code"])){ 19 | $code = $_GET["code"]; 20 | $sUrlWebToken = sprintf(_urlgetaccesstoken,_appid,_appsecret,$code); 21 | $aResponse = json_decode (file_get_contents($sUrlWebToken)); 22 | while(list($key,$val)= each($aResponse)) { 23 | if ('openid' == $key) { 24 | echo "your openid is:\n"; 25 | echo $val; 26 | break; 27 | } 28 | } 29 | } else { 30 | $sUrlAuth = sprintf(_mpauthurl,_appid,urlencode(_showurl)); 31 | header('Location: '.$sUrlAuth); 32 | } 33 | 34 | ?> 35 | -------------------------------------------------------------------------------- /tests/integration.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import 'dotenv/config.js' 3 | 4 | import { test } from 'tstest' 5 | 6 | import { WechatyBuilder } from 'wechaty' 7 | 8 | import { 9 | PuppetOA, 10 | } from '../src/mod.js' 11 | 12 | import { getOaOptions } from './fixtures/oa-options.js' 13 | 14 | import ciInfo from 'ci-info' 15 | 16 | test('integration testing', async t => { 17 | if (ciInfo.isPR) { 18 | void t.skip('Skip for PR') 19 | return 20 | } 21 | 22 | const puppet = new PuppetOA({ 23 | ...getOaOptions(), 24 | }) 25 | const wechaty = WechatyBuilder.build({ puppet }) 26 | 27 | t.ok(wechaty, 'should instantiate wechaty with puppet official account') 28 | }) 29 | 30 | test('PuppetOA perfect restart testing', async (t) => { 31 | if (ciInfo.isPR) { 32 | void t.skip('Skip for PR') 33 | return 34 | } 35 | 36 | const puppet = new PuppetOA({ 37 | ...getOaOptions(), 38 | port : 0, 39 | webhookProxyUrl : undefined, 40 | }) 41 | try { 42 | 43 | for (let i = 0; i < 3; i++) { 44 | 45 | await puppet.start() 46 | t.ok(puppet.state.active()) 47 | 48 | await puppet.stop() 49 | t.ok(puppet.state.inactive()) 50 | 51 | t.pass('start/stop-ed at #' + i) 52 | } 53 | 54 | t.pass('PuppetOA() perfect restart pass.') 55 | } catch (e) { 56 | t.fail(e as any) 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /src/official-account/webhook.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { Webhook } from './webhook.js' 6 | 7 | test('Webhook parseWehhookProxyUrl()', async (t) => { 8 | const WEBHOOK_PROXY_URL_LIST = [ 9 | 'http://wechaty-puppet-official-account.serverless.social', 10 | 'https://fsadfasdfs421.localtunnel.chatie.io', 11 | 'http://test.localhost.localdomain', 12 | 'https://wechaty-puppet-official-account-4231fsdaff-312rfsdl4132fsad.localtunnel.chatie.io', 13 | ] 14 | 15 | const EXPECTED_RESULT_LIST = [ 16 | { 17 | host : 'serverless.social', 18 | name : 'wechaty-puppet-official-account', 19 | schema : 'http', 20 | }, 21 | { 22 | host : 'localtunnel.chatie.io', 23 | name : 'fsadfasdfs421', 24 | schema : 'https', 25 | }, 26 | { 27 | host : 'localhost.localdomain', 28 | name : 'test', 29 | schema : 'http', 30 | }, 31 | { 32 | host : 'localtunnel.chatie.io', 33 | name : 'wechaty-puppet-official-account-4231fsdaff-312rfsdl4132fsad', 34 | schema : 'https', 35 | }, 36 | ] 37 | 38 | const webhook = new Webhook({ 39 | port: 0, 40 | verify: (..._: any[]) => true, 41 | }) 42 | 43 | const resultList = WEBHOOK_PROXY_URL_LIST.map(url => webhook.parseWebhookProxyUrl(url)) 44 | 45 | t.deepEqual(resultList, EXPECTED_RESULT_LIST, 'should parse the webhook proxy url right') 46 | }) 47 | -------------------------------------------------------------------------------- /src/official-account/schema.ts: -------------------------------------------------------------------------------- 1 | import type { ContactGender } from 'wechaty-puppet/types' 2 | 3 | export type OAMessageType = 'text' 4 | | 'image' 5 | | 'voice' 6 | | 'video' 7 | | 'shortvideo' 8 | | 'location' 9 | | 'link' 10 | | 'miniprogrampage' 11 | | 'event' 12 | 13 | export type OAMediaType = 'image' 14 | | 'voice' 15 | | 'video' 16 | | 'thumb' 17 | 18 | export type OAEventType = 'subscribe' | 'unsubscribe' | 'SCAN' | 'LOCATION' | 'CLICK' 19 | 20 | export type Language = 'en' 21 | | 'zh_CN' 22 | | 'zh_TW' 23 | 24 | export interface ErrorPayload { 25 | errcode : number, 26 | errmsg : string, 27 | } 28 | 29 | export interface OAMessagePayload { 30 | ToUserName : string 31 | FromUserName : string 32 | CreateTime : string 33 | MsgType : OAMessageType 34 | MsgId : string 35 | Content? : string 36 | PicUrl? : string 37 | MediaId? : string 38 | Event? : OAEventType 39 | EventKey? : string 40 | } 41 | 42 | /* eslint-disable camelcase */ 43 | export type OAContactPayload = Partial & { 44 | subscribe : number, 45 | openid : string, 46 | nickname : string, 47 | sex : ContactGender, 48 | language : Language, 49 | city : string, 50 | province : string, 51 | country : string, 52 | headimgurl : string, 53 | subscribe_time : number, 54 | unionid : string, 55 | remark : string, 56 | groupid : number, 57 | tagid_list : Array, 58 | subscribe_scene : string, 59 | qr_scene : number, 60 | qr_scene_str : string, 61 | } 62 | 63 | export interface OATagPayload { 64 | id : number, 65 | name : string, 66 | count : number, 67 | } 68 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | 4 | "editor.fontFamily": "'Fira Code iScript', Consolas, 'Courier New', monospace", 5 | "editor.fontLigatures": true, 6 | 7 | "editor.tokenColorCustomizations": { 8 | "textMateRules": [ 9 | { 10 | "scope": [ 11 | //following will be in italics (=Pacifico) 12 | "comment", 13 | // "entity.name.type.class", //class names 14 | "keyword", //import, export, return… 15 | "support.class.builtin.js", //String, Number, Boolean…, this, super 16 | "storage.modifier", //static keyword 17 | "storage.type.class.js", //class keyword 18 | "storage.type.function.js", // function keyword 19 | "storage.type.js", // Variable declarations 20 | "keyword.control.import.js", // Imports 21 | "keyword.control.from.js", // From-Keyword 22 | "entity.name.type.js", // new … Expression 23 | "keyword.control.flow.js", // await 24 | "keyword.control.conditional.js", // if 25 | "keyword.control.loop.js", // for 26 | "keyword.operator.new.js", // new 27 | ], 28 | "settings": { 29 | "fontStyle": "italic", 30 | }, 31 | }, 32 | { 33 | "scope": [ 34 | //following will be excluded from italics (My theme (Monokai dark) has some defaults I don't want to be in italics) 35 | "invalid", 36 | "keyword.operator", 37 | "constant.numeric.css", 38 | "keyword.other.unit.px.css", 39 | "constant.numeric.decimal.js", 40 | "constant.numeric.json", 41 | "entity.name.type.class.js" 42 | ], 43 | "settings": { 44 | "fontStyle": "", 45 | }, 46 | } 47 | ] 48 | }, 49 | 50 | "files.exclude": { 51 | "dist/": true, 52 | "doc/": true, 53 | "node_modules/": true, 54 | "package/": true, 55 | }, 56 | "alignment": { 57 | "operatorPadding": "right", 58 | "indentBase": "firstline", 59 | "surroundSpace": { 60 | "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. 61 | "assignment": [1, 1], // The same as above. 62 | "arrow": [1, 1], // The same as above. 63 | "comment": 2, // Special how much space to add between the trailing comment and the code. 64 | // If this value is negative, it means don't align the trailing comment. 65 | } 66 | }, 67 | "editor.formatOnSave": false, 68 | "python.pythonPath": "python3", 69 | "eslint.validate": [ 70 | "javascript", 71 | "typescript", 72 | ], 73 | "cSpell.words": [ 74 | "Miniprogram", 75 | "Unirest", 76 | "appid", 77 | "bodyparser", 78 | "echostr", 79 | "errcode", 80 | "errmsg", 81 | "localdomain", 82 | "msgtype", 83 | "touser" 84 | ], 85 | } 86 | -------------------------------------------------------------------------------- /examples/raw-oa.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import http from 'http' 3 | import express from 'express' 4 | import xmlParser from 'express-xml-bodyparser' 5 | import localtunnel from 'localtunnel' 6 | import crypto from 'crypto' 7 | 8 | import { getSimpleUnirest } from '../src/official-account/simple-unirest.js' 9 | 10 | async function main () { 11 | 12 | const app = express() 13 | 14 | app.use(xmlParser({ 15 | explicitArray : false, 16 | normalize : false, 17 | normalizeTags : false, 18 | trim : true, 19 | })) 20 | 21 | const server = http.createServer(app) 22 | 23 | server.listen(async () => { 24 | const listenedPort = (server.address() as { port: number }).port 25 | console.info('listen on port', listenedPort) 26 | 27 | const tunnel = await localtunnel({ 28 | host: 'https://serverless.social', 29 | port: listenedPort, 30 | // subdomain: 'wechaty-puppet-official-account', 31 | subdomain: 'c9534fb4-4d8d-4b2f-8ee5-ef1d6973364f', 32 | }) 33 | // https://wechaty-puppet-official-account.serverless.social/ 34 | 35 | console.info('tunnel url', tunnel.url) 36 | }) 37 | 38 | const simpleUnirest = getSimpleUnirest('https://api.weixin.qq.com/cgi-bin/') 39 | 40 | const appId = process.env['APP_ID'] 41 | const appSecret = process.env['APP_SECRET'] 42 | 43 | const ret = await simpleUnirest 44 | .get<{ 45 | access_token : string 46 | expires_in : number 47 | }>(`token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`) 48 | 49 | console.info('accessToken', ret.body) 50 | 51 | const accessToken = { 52 | expiresIn : ret.body.expires_in, 53 | timestamp : Date.now(), 54 | token : ret.body.access_token, 55 | } 56 | 57 | app.get('/', (req, res) => { 58 | 59 | const { 60 | signature, 61 | timestamp, 62 | nonce, 63 | echostr, 64 | } = req.query as { [key: string]: string } 65 | 66 | const data = [ 67 | timestamp, 68 | nonce, 69 | process.env['TOKEN'], 70 | ].sort().join('') 71 | 72 | const digest = crypto 73 | .createHash('sha1') 74 | .update(data) 75 | .digest('hex') 76 | 77 | if (digest === signature) { 78 | res.end(echostr) 79 | } else { 80 | res.end() 81 | } 82 | 83 | }) 84 | 85 | app.post('/', (req, res) => { 86 | const payload = req.body.xml 87 | 88 | console.info(payload) 89 | 90 | if (!/ding/i.test(payload.Content)) { 91 | res.end() 92 | return 93 | } 94 | 95 | simpleUnirest 96 | .post(`message/custom/send?access_token=${accessToken.token}`) 97 | .type('json') 98 | .send({ 99 | msgtype: 'text', 100 | text: 101 | { 102 | content: 'dong', 103 | }, 104 | touser: payload.FromUserName, 105 | }) 106 | .then(ret => { 107 | console.info(ret.body) 108 | res.end('success') 109 | return undefined 110 | }) 111 | .catch(console.error) 112 | 113 | }) 114 | } 115 | 116 | main() 117 | .catch(console.error) 118 | -------------------------------------------------------------------------------- /src/official-account/payload-store.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import os from 'os' 3 | import fs from 'fs' 4 | 5 | import { log } from 'wechaty-puppet' 6 | 7 | import { FlashStore } from 'flash-store' 8 | import LRU from 'lru-cache' 9 | 10 | import { VERSION } from '../config.js' 11 | 12 | import type { 13 | OAMessagePayload, 14 | OAContactPayload, 15 | } from './schema.js' 16 | 17 | import semverPkg from 'semver' 18 | const { major, minor } = semverPkg 19 | 20 | class PayloadStore { 21 | 22 | protected cacheOAContactPayload? : FlashStore 23 | protected cacheOAMessagePayload? : LRU 24 | 25 | constructor ( 26 | public appId: string, 27 | ) { 28 | log.verbose('PayloadStore', 'constructor(%s)', appId) 29 | } 30 | 31 | async start () { 32 | log.verbose('PayloadStore', 'start()') 33 | 34 | if (this.cacheOAMessagePayload) { 35 | throw new Error('PayloadStore should be stop() before start() again.') 36 | } 37 | 38 | /** 39 | * FlashStore 40 | */ 41 | const baseDir = path.join( 42 | os.homedir(), 43 | '.wechaty', 44 | 'wechaty-puppet-official-account', 45 | `v${major(VERSION)}.${minor(VERSION)}`, 46 | this.appId, 47 | ) 48 | if (!fs.existsSync(baseDir)) { 49 | fs.mkdirSync(baseDir, { recursive: true }) 50 | } 51 | 52 | this.cacheOAContactPayload = new FlashStore(path.join(baseDir, 'oa-contact-raw-payload')) 53 | 54 | /** 55 | * LRU 56 | */ 57 | const lruOptions: LRU.Options = { 58 | dispose (key: string, val: any) { 59 | log.silly('PayloadStore', `constructor() lruOptions.dispose(${key}, ${JSON.stringify(val)})`) 60 | }, 61 | max : 1000, 62 | maxAge : 1000 * 60 * 60, 63 | } 64 | 65 | this.cacheOAMessagePayload = new LRU(lruOptions) 66 | } 67 | 68 | async stop () { 69 | log.verbose('PayloadStore', 'stop()') 70 | 71 | if (this.cacheOAMessagePayload) { 72 | this.cacheOAMessagePayload = undefined 73 | } 74 | if (this.cacheOAContactPayload) { 75 | await this.cacheOAContactPayload.close() 76 | this.cacheOAContactPayload = undefined 77 | } 78 | } 79 | 80 | async getMessagePayload (id: string): Promise { 81 | log.verbose('PayloadStore', 'getMessagePayload(%s)', id) 82 | return this.cacheOAMessagePayload?.get(id) 83 | } 84 | 85 | async setMessagePayload (id: string, payload: OAMessagePayload): Promise { 86 | log.verbose('PayloadStore', 'setMessagePayload(%s, %s)', id, JSON.stringify(payload)) 87 | await this.cacheOAMessagePayload?.set(id, payload) 88 | } 89 | 90 | async getContactPayload (id: string): Promise { 91 | log.verbose('PayloadStore', 'getContactPayload(%s)', id) 92 | return this.cacheOAContactPayload?.get(id) 93 | } 94 | 95 | async setContactPayload (id: string, payload: OAContactPayload): Promise { 96 | log.verbose('PayloadStore', 'setContactPayload(%s, %s)', id, JSON.stringify(payload)) 97 | await this.cacheOAContactPayload?.set(id, payload) 98 | } 99 | 100 | } 101 | 102 | export { PayloadStore } 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechaty-puppet-official-account", 3 | "version": "1.10.14", 4 | "description": "Wechaty Puppet for WeChat Official Accounts", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "import": "./dist/esm/src/mod.js", 9 | "require": "./dist/cjs/src/mod.js" 10 | } 11 | }, 12 | "typings": "./dist/esm/src/mod.d.ts", 13 | "engines": { 14 | "node": ">=16", 15 | "npm": ">=7" 16 | }, 17 | "scripts": { 18 | "clean": "shx rm -fr dist/*", 19 | "dist": "npm-run-all clean build dist:commonjs", 20 | "build": "tsc && tsc -p tsconfig.cjs.json", 21 | "dist:commonjs": "shx echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", 22 | "start": "ts-node examples/ding-dong-bot.ts", 23 | "start:echo": "ts-node examples/echo.ts", 24 | "lint": "npm run lint:es && npm run lint:ts && npm run lint:md", 25 | "lint:md": "markdownlint README.md", 26 | "lint:ts": "tsc --isolatedModules --noEmit", 27 | "lint:es": "eslint \"src/**/*.ts\" \"examples/*.ts\" \"tests/**/*.spec.ts\" --ignore-pattern tests/fixtures/", 28 | "test": "npm-run-all lint test:src test:unit", 29 | "test:pack": "bash -x scripts/npm-pack-testing.sh", 30 | "test:src": "tap --node-arg=--loader=ts-node/esm --node-arg=--no-warnings \"src/**/*.spec.ts\"", 31 | "test:unit": "tap --node-arg=--loader=ts-node/esm --node-arg=--no-warnings \"tests/**/*.spec.ts\"" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/wechaty/wechaty-puppet-official-account.git" 36 | }, 37 | "keywords": [ 38 | "chatie", 39 | "wechaty", 40 | "chatbot", 41 | "bot", 42 | "wechat", 43 | "sdk", 44 | "puppet", 45 | "oa" 46 | ], 47 | "author": "Huan LI ", 48 | "license": "Apache-2.0", 49 | "bugs": { 50 | "url": "https://github.com/wechaty/wechaty-puppet-official-account/issues" 51 | }, 52 | "homepage": "https://github.com/wechaty/wechaty-puppet-official-account#readme", 53 | "devDependencies": { 54 | "@chatie/eslint-config": "^1.0.4", 55 | "@chatie/git-scripts": "^0.6.2", 56 | "@chatie/semver": "^0.4.7", 57 | "@chatie/tsconfig": "^4.5.3", 58 | "@types/cuid": "^1.3.1", 59 | "@types/express": "^4.17.13", 60 | "@types/express-xml-bodyparser": "^0.3.2", 61 | "@types/localtunnel": "^2.0.1", 62 | "@types/lru-cache": "^5.1.1", 63 | "@types/normalize-package-data": "^2.4.1", 64 | "@types/uuid": "^8.3.3", 65 | "ci-info": "^3.2.0", 66 | "dotenv": "^10.0.0", 67 | "tstest": "^1.0.1", 68 | "wechaty": "^1.5.2" 69 | }, 70 | "peerDependencies": { 71 | "wechaty-puppet": "^1.10.2" 72 | }, 73 | "dependencies": { 74 | "cuid": "^2.1.8", 75 | "express": "^4.17.1", 76 | "express-xml-bodyparser": "^0.3.0", 77 | "file-box": "^1.4.1", 78 | "flash-store": "^1.3.4", 79 | "localtunnel": "^2.0.2", 80 | "lru-cache": "^6.0.0", 81 | "normalize-package-data": "^3.0.3", 82 | "state-switch": "^1.6.2", 83 | "typed-emitter": "^1.4.0", 84 | "unirest": "^0.6.0", 85 | "uuid": "^8.3.2" 86 | }, 87 | "publishConfig": { 88 | "access": "public", 89 | "tag": "next" 90 | }, 91 | "files": [ 92 | "bin/", 93 | "dist/", 94 | "src/" 95 | ], 96 | "tap": { 97 | "check-coverage": false 98 | }, 99 | "git": { 100 | "scripts": { 101 | "pre-push": "npx git-scripts-pre-push" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/official-account/official-account.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import 'dotenv/config.js' 3 | 4 | import { test } from 'tstest' 5 | import cuid from 'cuid' 6 | import ciInfo from 'ci-info' 7 | import unirest from 'unirest' 8 | 9 | import { getOaOptions } from '../../tests/fixtures/oa-options.js' 10 | 11 | import { OfficialAccount } from './official-account.js' 12 | 13 | /* 14 | * refer to : https://github.com/wechaty/wechaty-puppet-official-account/issues/8 15 | * try to fix global pr runtime test 16 | */ 17 | const isPR: boolean = !!(ciInfo.isPR) 18 | 19 | void cuid // for testing 20 | 21 | test('OfficialAccount smoke testing', async t => { 22 | if (isPR) { 23 | void t.skip('Skip for PR') 24 | return 25 | } 26 | 27 | const WEBHOOK_PROXY_URL = [ 28 | 'http://', 29 | 'wechaty-puppet-official-account', 30 | '-', 31 | cuid(), 32 | // '.serverless.social', 33 | '.localtunnel.chatie.io', 34 | // '.test.localhost.localdomain', 35 | ].join('') 36 | 37 | const oa = new OfficialAccount({ 38 | ...getOaOptions(), 39 | webhookProxyUrl : WEBHOOK_PROXY_URL, 40 | }) 41 | await oa.start() 42 | 43 | const future = new Promise(resolve => oa.once('message', resolve)) 44 | 45 | const PAYLOAD = { 46 | Content : 'testing123', 47 | CreateTime : '1596436942', 48 | FromUserName : 'oOiiq59SLkf1AGuuTh668cxP8_Xs', 49 | MsgId : '22855481560378379', 50 | MsgType : 'text', 51 | ToUserName : 'gh_27056d3d5d05', 52 | } 53 | 54 | const XML = ` 55 | 56 | 57 | 58 | ${PAYLOAD.CreateTime} 59 | 60 | 61 | 62 | ` 63 | 64 | const response = await unirest 65 | .post(WEBHOOK_PROXY_URL) 66 | // .headers({'Accept': 'application/json', 'Content-Type': 'application/json'}) 67 | .type('xml') 68 | .send(XML) 69 | t.equal(response.body, 'success', 'should get success response') 70 | 71 | try { 72 | await Promise.race([ 73 | future, 74 | new Promise((resolve, reject) => { void resolve; setTimeout(reject, 15000) }), 75 | ]) 76 | t.pass('should get a message emit event from oa instance') 77 | } catch (e) { 78 | t.fail('should not get timeout rejection') 79 | } 80 | 81 | // await new Promise(resolve => setTimeout(resolve, 100 * 1000)) 82 | await oa.stop() 83 | }) 84 | 85 | test('updateAccessToken()', async t => { 86 | if (isPR) { 87 | await t.skip('Skip for PR') 88 | return 89 | } 90 | 91 | const oa = new OfficialAccount({ 92 | ...getOaOptions(), 93 | port: 0, 94 | }) 95 | 96 | await oa.start() 97 | 98 | try { 99 | t.ok(oa.accessToken, 'should get access token') 100 | } catch (e) { 101 | t.fail('should not be rejected') 102 | } 103 | 104 | await oa.stop() 105 | }) 106 | 107 | test('sendCustomMessage()', async t => { 108 | if (isPR) { 109 | await t.skip('Skip for PR') 110 | return 111 | } 112 | 113 | const oa = new OfficialAccount({ 114 | ...getOaOptions(), 115 | port: 0, 116 | }) 117 | 118 | try { 119 | await oa.start() 120 | 121 | const ret = await oa.sendCustomMessage({ 122 | content: 'wechaty-puppet-official-account CI testing', 123 | msgtype: 'text', 124 | touser: 'oOiiq59SLkf1AGuuTh668cxP8_Xs', 125 | }) 126 | t.not(ret, null, 'should get messageId') 127 | } catch (e) { 128 | console.error(e) 129 | t.fail('should not be rejected') 130 | } finally { 131 | await oa.stop() 132 | } 133 | }) 134 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: NPM 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | node-version: 13 | - 16 14 | 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: npm 23 | cache-dependency-path: package.json 24 | 25 | - name: Install Dependencies 26 | run: npm install 27 | 28 | - name: Test 29 | run: npm test 30 | env: 31 | WECHATY_PUPPET_OA_APP_ID: ${{ secrets.WECHATY_PUPPET_OA_APP_ID }} 32 | WECHATY_PUPPET_OA_APP_SECRET: ${{ secrets.WECHATY_PUPPET_OA_APP_SECRET }} 33 | WECHATY_PUPPET_OA_TOKEN: ${{ secrets.WECHATY_PUPPET_OA_TOKEN }} 34 | WECHATY_PUPPET_OA_WEBHOOK_PROXY_URL: https://wechaty-puppet-official-account-webhook-proxy.localtunnel.me 35 | 36 | pack: 37 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) 38 | name: Pack 39 | needs: build 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: actions/setup-node@v2 44 | with: 45 | node-version: 16 46 | cache: npm 47 | cache-dependency-path: package.json 48 | 49 | - name: Install Dependencies 50 | run: npm install 51 | 52 | - name: Generate Package JSON 53 | run: ./scripts/generate-package-json.sh 54 | 55 | - name: Pack Testing 56 | run: ./scripts/npm-pack-testing.sh 57 | env: 58 | WECHATY_PUPPET_OA_APP_ID: ${{ secrets.WECHATY_PUPPET_OA_APP_ID }} 59 | WECHATY_PUPPET_OA_APP_SECRET: ${{ secrets.WECHATY_PUPPET_OA_APP_SECRET }} 60 | WECHATY_PUPPET_OA_TOKEN: ${{ secrets.WECHATY_PUPPET_OA_TOKEN }} 61 | WECHATY_PUPPET_OA_WEBHOOK_PROXY_URL: https://wechaty-puppet-official-account-webhook-proxy.localtunnel.me 62 | 63 | publish: 64 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) 65 | name: Publish 66 | needs: [build, pack] 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v2 70 | - uses: actions/setup-node@v1 71 | with: 72 | node-version: 16 73 | registry-url: https://registry.npmjs.org/ 74 | cache: npm 75 | cache-dependency-path: package.json 76 | 77 | - name: Install Dependencies 78 | run: npm install 79 | 80 | - name: Generate Package JSON 81 | run: ./scripts/generate-package-json.sh 82 | 83 | - name: Set Publish Config 84 | run: ./scripts/package-publish-config-tag.sh 85 | 86 | - name: Build Dist 87 | run: npm run dist 88 | 89 | - name: Check Branch 90 | id: check-branch 91 | run: | 92 | if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then 93 | echo ::set-output name=match::true 94 | fi # See: https://stackoverflow.com/a/58869470/1123955 95 | - name: Is A Publish Branch 96 | if: steps.check-branch.outputs.match == 'true' 97 | run: | 98 | NAME=$(npx pkg-jq -r .name) 99 | VERSION=$(npx pkg-jq -r .version) 100 | if npx version-exists "$NAME" "$VERSION" 101 | then echo "$NAME@$VERSION exists on NPM, skipped." 102 | else npm publish 103 | fi 104 | env: 105 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 106 | - name: Is Not A Publish Branch 107 | if: steps.check-branch.outputs.match != 'true' 108 | run: echo 'Not A Publish Branch' 109 | -------------------------------------------------------------------------------- /examples/ding-dong-bot.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 * as PUPPET from 'wechaty-puppet' 20 | import { FileBox } from 'file-box' 21 | 22 | import { PuppetOA } from '../src/mod.js' 23 | /** 24 | * 25 | * 1. Declare your Bot! 26 | * 27 | */ 28 | 29 | const puppet = new PuppetOA() 30 | 31 | /** 32 | * 33 | * 2. Register event handlers for Bot 34 | * 35 | */ 36 | puppet 37 | .on('logout', onLogout) 38 | .on('login', onLogin) 39 | .on('scan', onScan) 40 | .on('error', onError) 41 | .on('message', onMessage) 42 | 43 | /** 44 | * 45 | * 3. Start the bot! 46 | * 47 | */ 48 | puppet.start() 49 | .catch(async e => { 50 | console.error('Bot start() fail:', e) 51 | await puppet.stop() 52 | process.exit(-1) 53 | }) 54 | 55 | /** 56 | * 57 | * 4. You are all set. ;-] 58 | * 59 | */ 60 | 61 | /** 62 | * 63 | * 5. Define Event Handler Functions for: 64 | * `scan`, `login`, `logout`, `error`, and `message` 65 | * 66 | */ 67 | function onScan (payload: PUPPET.payloads.EventScan) { 68 | if (payload.qrcode) { 69 | // Generate a QR Code online via 70 | // http://goqr.me/api/doc/create-qr-code/ 71 | const qrcodeImageUrl = [ 72 | 'https://api.qrserver.com/v1/create-qr-code/?data=', 73 | encodeURIComponent(payload.qrcode), 74 | ].join('') 75 | console.info(`[${payload.status}] ${qrcodeImageUrl}\nScan QR Code above to log in: `) 76 | } else { 77 | console.info(`[${payload.status}]`) 78 | } 79 | } 80 | 81 | function onLogin (payload: PUPPET.payloads.EventLogin) { 82 | console.info(`${payload.contactId} login`) 83 | puppet.messageSendText(payload.contactId, 'Wechaty login').catch(console.error) 84 | } 85 | 86 | function onLogout (payload: PUPPET.payloads.EventLogout) { 87 | console.info(`${payload.contactId} logouted`) 88 | } 89 | 90 | function onError (payload: PUPPET.payloads.EventError) { 91 | console.error('Bot error:', payload.data) 92 | /* 93 | if (bot.logonoff()) { 94 | bot.say('Wechaty error: ' + e.message).catch(console.error) 95 | } 96 | */ 97 | } 98 | 99 | /** 100 | * 101 | * 6. The most important handler is for: 102 | * dealing with Messages. 103 | * 104 | */ 105 | async function onMessage (payload: PUPPET.payloads.EventMessage) { 106 | const msgPayload = await puppet.messagePayload(payload.messageId) 107 | console.info('onMessage:', JSON.stringify(msgPayload)) 108 | if (/ding/i.test(msgPayload.text || '')) { 109 | await puppet.messageSendText(msgPayload.fromId!, 'dong') 110 | } else if (/hi|hello/i.test(msgPayload.text || '')) { 111 | const _userinfo = await puppet.contactRawPayload(msgPayload.fromId!) 112 | await puppet.messageSendText(msgPayload.fromId!, 'hello,' + _userinfo.nickname + '. Thanks for your attention') 113 | } else if (/image/i.test(msgPayload.text || '')) { 114 | const fileBox = FileBox.fromUrl('https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=1116676390,2305043183&fm=26&gp=0.jpg', 'ding-dong.jpg') 115 | if (msgPayload.fromId) { 116 | await puppet.messageSendFile(msgPayload.fromId!, fileBox) 117 | } 118 | } else if (/link/i.test(msgPayload.text || '')) { 119 | const imagePath = 'http://mmbiz.qpic.cn/mmbiz_jpg/lOBFkCyo4n9Qhricg66uEO2Ycn9hcCibauvalenRUeMzsRia2VjLok4Gd1iaeuKiarVggr4apCEUNiamIM4FLkpxgurw/0' 120 | const wechatyLink: PUPPET.payloads.UrlLink = ({ description: 'this is wechaty', thumbnailUrl: imagePath, title: 'WECHATY', url:'https://wechaty.js.org/' }) 121 | await puppet.messageSendUrl(msgPayload.fromId!, wechatyLink) 122 | } else if (msgPayload.type === PUPPET.types.Message.Image) { 123 | const imageFile = FileBox.fromUrl(msgPayload.filename + '.jpg') 124 | if (msgPayload.fromId!) { 125 | await puppet.messageSendFile(msgPayload.fromId!, imageFile) 126 | } 127 | } else if (msgPayload.type === PUPPET.types.Message.Audio) { 128 | if (msgPayload.filename) { 129 | const audioFile = FileBox.fromUrl(msgPayload.filename, 'message.amr') 130 | if (msgPayload.fromId!) { 131 | await puppet.messageSendFile(msgPayload.fromId!, audioFile) 132 | } 133 | } 134 | } 135 | } 136 | 137 | /** 138 | * 139 | * 7. Output the Welcome Message 140 | * 141 | */ 142 | const welcome = ` 143 | Puppet Version: ${puppet.version()} 144 | 145 | Please wait... I'm trying to login in... 146 | 147 | ` 148 | console.info(welcome) 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PUPPET-OFFICIAL-ACCOUNT 2 | 3 | [![NPM Version](https://badge.fury.io/js/wechaty-puppet-official-account.svg)](https://badge.fury.io/js/wechaty-puppet-official-account) 4 | [![npm (tag)](https://img.shields.io/npm/v/wechaty-puppet-official-account/next.svg)](https://www.npmjs.com/package/wechaty-puppet-official-account?activeTab=versions) 5 | [![NPM](https://github.com/wechaty/wechaty-puppet-official-account/workflows/NPM/badge.svg)](https://github.com/wechaty/wechaty-puppet-official-account/actions?query=workflow%3ANPM) 6 | [![ES Modules](https://img.shields.io/badge/ES-Modules-brightgreen)](https://github.com/Chatie/tsconfig/issues/16) 7 | 8 | ![WeChat Official Account Puppet for Wechaty](docs/images/wechaty-puppet-official-account.png) 9 | 10 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-brightgreen.svg)](https://github.com/wechaty/wechaty) 11 | [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-blue.svg)](https://www.typescriptlang.org/) 12 | 13 | Wechaty Puppet for WeChat Official Accounts helps you use Wechaty to manage your Official Account from . 14 | 15 | ## FEATURES 16 | 17 | 1. Provide webhook proxy out-of-the-box (powered by [localtunnel](https://github.com/localtunnel/localtunnel)) 18 | 19 | ## USAGE 20 | 21 | This documentation assumes that you are familiar with Wechaty already. 22 | 23 | If you are a newbie to Wechaty, please read the following two links first: 24 | 25 | 1. [Wechaty WebSite](https://wechaty.js.org) 26 | 1. [Wechaty Getting Started](https://github.com/wechaty/wechaty-getting-started) 27 | 28 | To use `wechaty-puppet-official-account` with Wechaty, just like other puppets as well: 29 | 30 | ```ts 31 | import { Wechaty } from 'wechaty' 32 | import { PuppetOA } from 'wechaty-puppet-official-account' 33 | 34 | const oa = new PuppetOA({ 35 | appId : OA_APP_ID, 36 | appSecret : OA_APP_SECRET, 37 | token : OA_TOKEN, 38 | webhookProxyUrl : 'https://aeb082b9-14da-4c91-bdef-90a6d17a4z98.localtunnel.me', 39 | }) 40 | 41 | const bot = new Wechaty({ 42 | name: 'oa-bot', 43 | puppet: oa, 44 | }) 45 | 46 | bot.on('message', msg => { 47 | if (!msg.self() && msg.type() === bot.Message.Type.Text && /ding/i.test(msg.text())) { 48 | await msg.say('dong') 49 | } 50 | }) 51 | await bot.start() 52 | ``` 53 | 54 | > For the full source code, see: 55 | 56 | That's it! 57 | 58 | ## ENVIRONMENTS VARIABLES 59 | 60 | You can use environment variables to configure all of the WeChat Official Account Development Information. 61 | 62 | ### `WECHATY_PUPPET_OA_APP_ID`: `appId` 63 | 64 | Developer ID(AppID) is the developer ID, Official Account identification code, which can call Official Account API with the developer's password. 65 | 66 | ### `WECHATY_PUPPET_OA_APP_SECRET`: `appSecret` 67 | 68 | The Developer Password(AppSecret) is the one with high security to verify the identity of the Official Account developer. 69 | 70 | ### `WECHATY_PUPPET_OA_TOKEN`: `token` 71 | 72 | The token is set by you for your server(URL) configuration. 73 | 74 | ### `WECHATY_PUPPET_OA_PORT` 75 | 76 | Set `WECHATY_PUPPET_OA_PORT` to your local HTTP Server port number if you have a public server that can be visited from the internet. 77 | 78 | After setting ``WECHATY_PUPPET_OA_PORT`, the puppet will expose itself to the internet with this port for providing the HTTP service. 79 | 80 | ### `WECHATY_PUPPET_OA_WEBHOOK_PROXY_URL` 81 | 82 | Set `WECHATY_PUPPET_OA_WEBHOOK_PROXY_URL` to a `localtunnel` supported address so that you will be able to provide the Server Address(URL) for WebHook usage with this URL. 83 | 84 | This is the most convenient way to use this puppet because you can always provide the same URL to the WeChat Official Account platform no matter where your program is running. 85 | 86 | Currently, you can generate this URL by yourself by: 87 | 88 | 1. Generate a UUIDv4 use a generator like [UUID Online Generator](https://uuidonline.com) 89 | 1. Insert your $UUID to `https://${UUID}.localtunnel.me` 90 | 91 | For example, if your UUID is `aeb082b9-14da-4c91-bdef-90a6d17a4z98`, then you can use `https://aeb082b9-14da-4c91-bdef-90a6d17a4z98.localtunnel.me` as `WECHATY_PUPPET_OA_WEBHOOK_PROXY_URL` 92 | 93 | Learn more from [localtunnel](https://localtunnel.github.io/www/) 94 | 95 | ## DEVELOPMENT 96 | 97 | When you start developing the WeChat Official Account, it will be very helpful with the following tools provided by Tencent: 98 | 99 | 1. Apply a test Official Account with full privileges for developing 100 | 1. Simulate the API calls in an online simulation tool. 101 | 102 | ### 1 Apply an Official Account for developing/testing 103 | 104 | 测试号是扫码即可获得的微信公众号,拥有所有完整高级接口权限,测试专用。 105 | 106 | 微信公众帐号测试号申请系统入口地址: 107 | 108 | - [Docs](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Requesting_an_API_Test_Account.html) 109 | - [Link](https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login) 110 | 111 | ### 2 API calls debugging tool 112 | 113 | 允许开发者在平台上提交信息和服务器进行交互,并得到验证结果的在线 API 调试工具。 114 | 115 | Address: 116 | 117 | ## RESOURCES 118 | 119 | - [nodejs+express对微信公众号进行二次开发--接收消息,自动回复文本,图片以及代码优化](https://blog.csdn.net/weixin_44729896/article/details/102525375) 120 | - [Microsoft Azure Bot Service - Connect a bot to WeChat](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-channel-connect-wechat?view=azure-bot-service-4.0) 121 | 122 | ## HISTORY 123 | 124 | ### master v1.0 Release (Oct 29, 2021) 125 | 126 | 1. v0.9 (Oct 2021): Puppet API v0.51 127 | 1. v0.7 (Sep 2021): Enable ES Module support for Node.js 128 | 129 | ### v0.4 (Aug 6, 2020) 130 | 131 | 1. Support localtunnel service from any service provider (domains). 132 | 133 | ### v0.2 (Aug 2, 2018) 134 | 135 | Initial version for Official Account. 136 | 137 | 1. receive messages from users 138 | 1. reply message to a user (passive mode) 139 | 140 | ## Maintainers 141 | 142 | - Admins 143 | - [@huan](https://github.com/huan) Huan 144 | - [@leochen-g](https://wechaty.js.org/contributors/leochen-g/) Leo chen 145 | - Contributors 146 | - [@wj-Mcat](https://github.com/wj-Mcat) 147 | - [@qhduan](https://github.com/qhduan) 148 | 149 | ## COPYRIGHT & LICENSE 150 | 151 | - Code & Docs © 2020-now Wechaty Organization 152 | - Code released under the Apache-2.0 License 153 | - Docs released under Creative Commons 154 | -------------------------------------------------------------------------------- /src/official-account/webhook.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import express from 'express' 3 | import xmlParser from 'express-xml-bodyparser' 4 | import localtunnel from 'localtunnel' 5 | import * as UUID from 'uuid' 6 | import { log } from 'wechaty-puppet' 7 | import { EventEmitter } from 'events' 8 | import type TypedEventEmitter from 'typed-emitter' 9 | 10 | import type { 11 | OAMessagePayload, 12 | OAMessageType, 13 | } from './schema.js' 14 | 15 | const WebhookEventEmitter = EventEmitter as new () => TypedEventEmitter<{ 16 | message: (message: OAMessagePayload) => void, 17 | instantReply: (message: { 18 | touser : string, 19 | msgtype : OAMessageType, 20 | content : string, 21 | }) => void, 22 | }> 23 | 24 | export interface VerifyArgs { 25 | timestamp : string, 26 | nonce : string, 27 | signature : string, 28 | } 29 | 30 | interface WebhookOptions { 31 | personalMode? : boolean, 32 | port? : number, 33 | webhookProxyUrl? : string, 34 | verify : (args: VerifyArgs) => boolean, 35 | } 36 | 37 | class Webhook extends WebhookEventEmitter { 38 | 39 | protected server? : http.Server 40 | protected tunnel? : localtunnel.Tunnel 41 | protected personalMode? : boolean 42 | protected messageCache? : any = {} 43 | protected userOpen? : any = {} 44 | 45 | public readonly webhookProxyHost? : string 46 | public readonly webhookProxySchema? : string 47 | public readonly webhookProxySubDomain? : string 48 | 49 | constructor ( 50 | protected options: WebhookOptions, 51 | ) { 52 | super() 53 | log.verbose('Webhook', 'constructor(%s)', JSON.stringify(options)) 54 | 55 | if (typeof options.port !== 'undefined' && options.webhookProxyUrl) { 56 | throw new Error('Please only provide either `port` or `webhookProxyUrl` for Webhook') 57 | } 58 | if (typeof options.port === 'undefined' && !options.webhookProxyUrl) { 59 | throw new Error('Please provide either `port` or `webhookProxyUrl` for Webhook') 60 | } 61 | 62 | this.personalMode = options.personalMode 63 | 64 | if (options.webhookProxyUrl) { 65 | const result = this.parseWebhookProxyUrl(options.webhookProxyUrl) 66 | if (!result) { 67 | throw new Error(`Webhook: invalid webhookProxyUrl ${options.webhookProxyUrl}`) 68 | } 69 | this.webhookProxyHost = result.host 70 | this.webhookProxySchema = result.schema 71 | this.webhookProxySubDomain = result.name 72 | } 73 | } 74 | 75 | parseWebhookProxyUrl ( 76 | webhookProxyUrl: string, 77 | ): undefined | { 78 | host : string, 79 | name : string, 80 | schema : string, 81 | } { 82 | log.verbose('Webhook', 'parseSubDomain(%s)', webhookProxyUrl) 83 | 84 | /** 85 | * Huan(20208): see webhook.spec.ts unit tests. 86 | * server: https://github.com/localtunnel/server 87 | * 88 | * Huan(202109): TODO: use URL for this? 89 | */ 90 | const URL_RE = /(https?):\/\/([^.]+)\.(.+)/i 91 | 92 | const matches = webhookProxyUrl.match(URL_RE) 93 | 94 | if (!matches) { 95 | log.warn('Webhook', 'parseSubDomain() fail to parse %s', webhookProxyUrl) 96 | return 97 | } 98 | 99 | const [ 100 | , // skip matches[0] 101 | schema, 102 | name, 103 | host, 104 | ] = matches 105 | 106 | log.verbose('Webhook', 'parseSubDomain() schema: %s, name: %s, host: %s', 107 | schema, 108 | name, 109 | host, 110 | ) 111 | 112 | if (!host || !name || !schema) { 113 | return undefined 114 | } 115 | 116 | return { 117 | host, 118 | name, 119 | schema, 120 | } 121 | } 122 | 123 | async start () { 124 | log.verbose('Webhook', 'start()') 125 | 126 | const app = express() 127 | app.use(xmlParser({ 128 | explicitArray : false, 129 | normalize : false, 130 | normalizeTags : false, 131 | trim : true, 132 | })) 133 | 134 | app.get('/', (req, res) => this.appGet(req, res)) 135 | app.post('/', (req, res) => { 136 | ;( 137 | async () => this.appPost(req, res) 138 | )().catch(console.error) 139 | }) 140 | 141 | this.on('instantReply', (msg: { 142 | msgtype: OAMessageType, 143 | content: string, 144 | touser: string 145 | }) => { 146 | if (this.userOpen[msg.touser]) { 147 | this.messageCache[msg.touser] = msg 148 | } else { 149 | throw Error('Webhook: personal mode only allow reply once and within 4s') 150 | } 151 | }) 152 | 153 | const server = this.server = http.createServer(app) 154 | 155 | await new Promise((resolve, reject) => { 156 | /** 157 | * 1. for local port 158 | */ 159 | if (typeof this.options.port !== 'undefined') { 160 | server.listen(this.options.port, resolve) 161 | return 162 | } 163 | 164 | /** 165 | * 2. for tunnel helper 166 | */ 167 | server.listen(() => { 168 | const listenedPort = (server.address() as { port: number }).port 169 | this.setupTunnel(listenedPort) 170 | .then(resolve) 171 | .catch(reject) 172 | }) 173 | }) 174 | } 175 | 176 | async stop () { 177 | log.verbose('Webhook', 'stop()') 178 | 179 | if (this.tunnel) { 180 | this.tunnel.close() 181 | this.tunnel = undefined 182 | } 183 | if (this.server) { 184 | this.server.close() 185 | this.server = undefined 186 | } 187 | } 188 | 189 | async setupTunnel (port: number) { 190 | log.verbose('Webhook', 'setupTunnel(%s)', port) 191 | 192 | const host = `${this.webhookProxySchema}://${this.webhookProxyHost}` 193 | 194 | const tunnel = await localtunnel({ 195 | host, 196 | port, 197 | subdomain: this.webhookProxySubDomain, 198 | }) 199 | 200 | log.verbose('Webhook', 'setupTunnel() created at %s', tunnel.url) 201 | 202 | if (tunnel.url !== this.options.webhookProxyUrl) { 203 | throw new Error(`Webhook: webhookUrlUrl is not available ${this.options.webhookProxyUrl}`) 204 | } 205 | 206 | tunnel.on('close', () => { 207 | log.verbose('Webhook', 'setupTunnel() tunnel.on(close)') 208 | // TODO: check if need to reconnect at here. 209 | // FIXME: try to recover by restarting, or throw error when can not recover 210 | }) 211 | 212 | this.tunnel = tunnel 213 | } 214 | 215 | appGet ( 216 | req : express.Request, 217 | res : express.Response, 218 | ): void { 219 | log.verbose('Webhook', 'appGet({url: %s})', req.url) 220 | 221 | const { 222 | signature, 223 | timestamp, 224 | nonce, 225 | echostr, 226 | } = req.query as { [key: string]: string } 227 | 228 | if (nonce && signature && this.options.verify({ 229 | nonce, 230 | signature, 231 | timestamp: timestamp!, 232 | })) { 233 | log.verbose('Webhook', 'appGet() verify() succeed') 234 | res.end(echostr) 235 | } else { 236 | log.verbose('Webhook', 'appGet() verify() failed') 237 | res.end() 238 | } 239 | } 240 | 241 | async appPost ( 242 | req : express.Request, 243 | res : express.Response, 244 | ): Promise { 245 | const payload = req.body.xml as OAMessagePayload 246 | log.verbose('Webhook', 'appPost({url: %s} with payload: %s', 247 | req.url, 248 | JSON.stringify(payload), 249 | ) 250 | 251 | const knownTypeList = [ 252 | 'text', 253 | 'image', 254 | 'voice', 255 | 'event', 256 | ] 257 | 258 | this.userOpen[payload.FromUserName] = true 259 | /** 260 | * TODO: support more MsgType 261 | */ 262 | if (payload.MsgType === 'event') { 263 | payload.MsgId = UUID.v4() 264 | } 265 | if (knownTypeList.includes(payload.MsgType)) { 266 | if (payload.MsgType === 'event' && payload.Event !== 'CLICK') return 267 | this.emit('message', payload) 268 | } 269 | 270 | /** 271 | * 假如服务器无法保证在五秒内处理并回复,必须做出下述回复,这样微信服务器才不会对此作任何处理, 272 | * 并且不会发起重试(这种情况下,可以使用客服消息接口进行异步回复),否则,将出现严重的错误提示。 273 | * 1、直接回复success(推荐方式) 274 | * 2、直接回复空串(指字节长度为0的空字符串,而不是XML结构体中content字段的内容为空) 275 | * 276 | * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html 277 | */ 278 | if (this.personalMode) { 279 | let reply: string|null = null 280 | const timeout = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 281 | for (let i = 0; i < (4000 / 5); i++) { 282 | await timeout(5) 283 | if (this.messageCache[payload.FromUserName]) { 284 | const msg: any = this.messageCache[payload.FromUserName] 285 | this.messageCache[payload.FromUserName] = undefined 286 | 287 | if (msg.msgtype === 'text') { 288 | reply = ` 289 | 290 | 291 | ${payload.CreateTime} 292 | 293 | 294 | 295 | ` 296 | } 297 | break 298 | } 299 | } 300 | if (reply) { 301 | this.userOpen[payload.FromUserName] = undefined 302 | res.end(reply) 303 | return 304 | } 305 | } 306 | this.userOpen[payload.FromUserName] = undefined 307 | res.end('success') 308 | } 309 | 310 | } 311 | 312 | export { Webhook } 313 | -------------------------------------------------------------------------------- /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 2020 Huan 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/puppet-oa.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 | 20 | import * as PUPPET from 'wechaty-puppet' 21 | import { 22 | FileBox, 23 | FileBoxInterface, 24 | } from 'file-box' 25 | 26 | import { 27 | VERSION, 28 | qrCodeForChatie, 29 | envOptions, 30 | log, 31 | } from './config.js' 32 | 33 | import { 34 | OfficialAccountOptions, 35 | OfficialAccount, 36 | } from './official-account/official-account.js' 37 | import type { 38 | OAContactPayload, 39 | OAMessagePayload, 40 | OAMediaType, 41 | } from './official-account/schema.js' 42 | 43 | export type PuppetOAOptions = PUPPET.PuppetOptions & Partial 44 | 45 | class PuppetOA extends PUPPET.Puppet { 46 | 47 | override messageLocation (_messageId: string): Promise { 48 | throw new Error('Method not implemented.') 49 | } 50 | 51 | override messageSendLocation (_conversationId: string, _locationPayload: PUPPET.payloads.Location): Promise { 52 | throw new Error('Method not implemented.') 53 | } 54 | 55 | override contactPhone (contactId: string, phoneList: string[]): Promise { 56 | log.info('contactPhone(%s, %s)', contactId, phoneList) 57 | throw new Error('Method not implemented.') 58 | } 59 | 60 | override contactCorporationRemark (contactId: string, corporationRemark: string | null): Promise { 61 | log.info('contactCorporationRemark(%s, %s)', contactId, corporationRemark) 62 | throw new Error('Method not implemented.') 63 | } 64 | 65 | override contactDescription (contactId: string, description: string | null): Promise { 66 | log.info('contactDescription(%s, %s)', contactId, description) 67 | throw new Error('Method not implemented.') 68 | } 69 | 70 | static override readonly VERSION = VERSION 71 | 72 | protected appId : string 73 | protected appSecret : string 74 | protected port? : number 75 | protected token : string 76 | protected webhookProxyUrl? : string 77 | protected personalMode? : boolean 78 | 79 | protected accessTokenProxyUrl? : string 80 | 81 | protected oa? : OfficialAccount 82 | private _heartBeatTimer?: ReturnType 83 | 84 | constructor ( 85 | options: PuppetOAOptions = {}, 86 | ) { 87 | super() 88 | log.verbose('PuppetOA', 'constructor()') 89 | 90 | options = { 91 | ...envOptions(), 92 | ...options, 93 | } 94 | 95 | if (options.appId) { 96 | this.appId = options.appId 97 | } else { 98 | throw new Error(` 99 | PuppetOA: appId not found. Please either set the WECHATY_PUPPET_OA_APP_ID environment variable, or set 'appId' optoins for PuppetOA. 100 | `) 101 | } 102 | 103 | if (options.appSecret) { 104 | this.appSecret = options.appSecret 105 | } else { 106 | throw new Error(` 107 | PuppetOA: appSecret not found. Please either set the WECHATY_PUPPET_OA_APP_SECRET environment variable, or set 'appSecret' options for PuppetOA. 108 | `) 109 | } 110 | 111 | if (options.token) { 112 | this.token = options.token 113 | } else { 114 | throw new Error(` 115 | PuppetOA: token not found. Please either set WECHATY_PUPPET_OA_TOKEN environment variabnle, or set 'token' options for PuppetOA. 116 | `) 117 | } 118 | 119 | if (options.personalMode) { 120 | this.personalMode = options.personalMode 121 | } else { 122 | this.personalMode = false 123 | } 124 | 125 | this.port = options.port 126 | this.webhookProxyUrl = options.webhookProxyUrl 127 | 128 | /** 129 | * NOTE: if the ip address of server is dynamic, it can't fetch the accessToken from tencent server. 130 | * So, the accessTokenProxyUrl configuration is needed to fetch the accessToken from the specific endpoint. 131 | * 132 | * eg: accessTokenProxyUrl = 'http://your-endpoint/' 133 | * puppet-oa will fetch accessToken from: http://your-endpoint/token?grant_type=client_credential&appid=${appId}&secret=${appSecret} 134 | */ 135 | if (options.accessTokenProxyUrl) { 136 | if (options.accessTokenProxyUrl.endsWith('/')) { 137 | options.accessTokenProxyUrl = options.accessTokenProxyUrl.substring(0, options.accessTokenProxyUrl.length - 1) 138 | } 139 | this.accessTokenProxyUrl = options.accessTokenProxyUrl 140 | } 141 | } 142 | 143 | override version (): string { 144 | return VERSION 145 | } 146 | 147 | override async onStart (): Promise { 148 | log.verbose('PuppetOA', 'onStart()') 149 | 150 | this.oa = new OfficialAccount({ 151 | appId : this.appId, 152 | appSecret : this.appSecret, 153 | personalMode : this.personalMode, 154 | port : this.port, 155 | token : this.token, 156 | webhookProxyUrl : this.webhookProxyUrl, 157 | }) 158 | 159 | this.bridgeEvents(this.oa) 160 | await this.oa.start() 161 | 162 | await this._startPuppetHeart(true) 163 | // FIXME: Huan(202008) find a way to get the bot user information 164 | // Official Account Info can be customized by user, so It should be 165 | // configured by environment variable. 166 | // set gh_ prefix to identify the official-account 167 | const currentUserId = `gh_${this.appId}` 168 | await this.oa.payloadStore.setContactPayload(currentUserId, { openid: currentUserId } as any) 169 | this.login(currentUserId) 170 | this.emit('ready', { data: 'ready' }) 171 | } 172 | 173 | private async _startPuppetHeart (firstTime: boolean = true) { 174 | if (firstTime && this._heartBeatTimer) { 175 | return 176 | } 177 | 178 | this.emit('heartbeat', { data: 'heartbeat@office: live' }) 179 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 180 | this._heartBeatTimer = setTimeout(async (): Promise => { 181 | await this._startPuppetHeart(false) 182 | return undefined 183 | }, 15 * 1000) // 15s 184 | } 185 | 186 | // 停止监听心跳 187 | private _stopPuppetHeart () { 188 | if (!this._heartBeatTimer) { 189 | return 190 | } 191 | 192 | clearTimeout(this._heartBeatTimer) 193 | this._heartBeatTimer = undefined 194 | } 195 | 196 | protected bridgeEvents (oa: OfficialAccount) { 197 | oa.on('message', msg => this.emit('message', { messageId: msg.MsgId })) 198 | oa.on('login', _ => this.login(this.currentUserId)) 199 | oa.on('ready', _ => this.emit('ready', { data: 'ready' })) 200 | oa.on('logout', _ => this.wrapAsync(this.logout('oa.on(logout)'))) 201 | } 202 | 203 | override async onStop (): Promise { 204 | log.verbose('PuppetOA', 'onStop()') 205 | 206 | if (this.oa) { 207 | this.oa.removeAllListeners() 208 | await this.oa.stop() 209 | this.oa = undefined 210 | await this.logout('oa.on(logout)') 211 | } 212 | this._stopPuppetHeart() 213 | } 214 | 215 | override ding (data?: string): void { 216 | log.silly('PuppetOA', 'ding(%s)', data || '') 217 | // FIXME: do the real job 218 | setTimeout(() => this.emit('dong', { data: data || '' }), 1000) 219 | } 220 | 221 | /** 222 | * 223 | * ContactSelf 224 | * 225 | * 226 | */ 227 | override async contactSelfQRCode (): Promise { 228 | log.verbose('PuppetOA', 'contactSelfQRCode()') 229 | return 'qrcode in the future ;^)' 230 | } 231 | 232 | override async contactSelfName (name: string): Promise { 233 | log.verbose('PuppetOA', 'contactSelfName(%s)', name) 234 | } 235 | 236 | override async contactSelfSignature (signature: string): Promise { 237 | log.verbose('PuppetOA', 'contactSelfSignature(%s)', signature) 238 | } 239 | 240 | /** 241 | * 242 | * Contact 243 | * 244 | */ 245 | override contactAlias (contactId: string) : Promise 246 | override contactAlias (contactId: string, alias: string | null): Promise 247 | 248 | override async contactAlias (contactId: string, alias?: string | null): Promise { 249 | log.verbose('PuppetOA', 'contactAlias(%s, %s)', contactId, alias) 250 | 251 | /** 252 | * 1. set 253 | */ 254 | if (alias) { 255 | await this.oa?.updateContactRemark(contactId, alias) 256 | return alias 257 | } 258 | 259 | /** 260 | * 2. get 261 | */ 262 | const contactPayload = await this.contactPayload(contactId) 263 | if (!contactPayload.alias) { 264 | log.warn('Contact<%s> has no alias', contactId) 265 | } 266 | return contactPayload.alias 267 | } 268 | 269 | override async contactList (): Promise { 270 | log.verbose('PuppetOA', 'contactList()') 271 | const contactIdList = await this.oa?.getContactList() 272 | 273 | if (!contactIdList) { 274 | throw new Error('contactIdList found from oa store') 275 | } 276 | return contactIdList 277 | } 278 | 279 | override async contactAvatar (contactId: string) : Promise 280 | override async contactAvatar (contactId: string, file: FileBoxInterface) : Promise 281 | 282 | override async contactAvatar (contactId: string, file?: FileBoxInterface): Promise { 283 | log.verbose('PuppetOA', 'contactAvatar(%s)', contactId) 284 | 285 | /** 286 | * 1. set 287 | */ 288 | if (file) { 289 | return 290 | } 291 | 292 | /** 293 | * 2. get 294 | */ 295 | 296 | const contactPayload = await this.contactPayload(contactId) 297 | const fileBox = contactPayload.avatar ? FileBox.fromUrl(contactPayload.avatar) : undefined 298 | if (fileBox) { 299 | return fileBox 300 | } 301 | } 302 | 303 | override async contactRawPayloadParser (oaPayload: OAContactPayload): Promise { 304 | const payload: PUPPET.payloads.Contact = { 305 | alias : oaPayload.remark, 306 | avatar : oaPayload.headimgurl, 307 | city : oaPayload.city, 308 | friend : true, 309 | gender : oaPayload.sex, 310 | id : oaPayload.openid, 311 | name : oaPayload.nickname, 312 | phone : [], 313 | province : oaPayload.province, 314 | signature : '', 315 | star : false, 316 | type : PUPPET.types.Contact.Individual, 317 | weixin : oaPayload.unionid, 318 | } 319 | return payload 320 | } 321 | 322 | override async contactRawPayload (id: string): Promise { 323 | log.verbose('PuppetOA', 'contactRawPayload(%s)', id) 324 | 325 | const contactInfoPayload = await this.oa?.getContactPayload(id) 326 | if (!contactInfoPayload) { 327 | throw new Error(`can not get PUPPET.payloads.Contact(${id})`) 328 | } 329 | return contactInfoPayload 330 | } 331 | 332 | /** 333 | * 334 | * Message 335 | * 336 | */ 337 | override async messageContact ( 338 | messageId: string, 339 | ): Promise { 340 | log.verbose('PuppetOA', 'messageContact(%s)', messageId) 341 | // const attachment = this.mocker.MockMessage.loadAttachment(messageId) 342 | // if (attachment instanceof ContactMock) { 343 | // return attachment.id 344 | // } 345 | return '' 346 | } 347 | 348 | override async messageImage ( 349 | messageId : string, 350 | imageType : PUPPET.types.Image, 351 | ) : Promise { 352 | log.verbose('PuppetOA', 'messageImage(%s, %s[%s])', 353 | messageId, 354 | imageType, 355 | PUPPET.types.Image[imageType], 356 | ) 357 | // const attachment = this.mocker.MockMessage.loadAttachment(messageId) 358 | // if (attachment instanceof FileBox) { 359 | // return attachment 360 | // } 361 | const payload: PUPPET.payloads.Message = await this.messagePayload(messageId) 362 | let fileBox: FileBoxInterface 363 | if (payload.type === PUPPET.types.Message.Image) { 364 | if (!payload.filename) { 365 | throw Error(`image message type must have filename file. <${payload}>`) 366 | } 367 | fileBox = FileBox.fromUrl(payload.filename) 368 | } else { 369 | throw Error('can"t get file from the message') 370 | } 371 | return fileBox 372 | } 373 | 374 | override async messageRecall ( 375 | messageId: string, 376 | ): Promise { 377 | log.verbose('PuppetOA', 'messageRecall(%s)', messageId) 378 | return false 379 | } 380 | 381 | override async messageFile (id: string): Promise { 382 | log.verbose('PuppetOA', 'messageFile(%s)', id) 383 | 384 | const payload: PUPPET.payloads.Message = await this.messagePayload(id) 385 | 386 | switch (payload.type) { 387 | case PUPPET.types.Message.Image: 388 | if (!payload.filename) { 389 | throw Error(`Image message must have filename. <${payload}>`) 390 | } 391 | return FileBox.fromUrl(payload.filename) 392 | case PUPPET.types.Message.Audio: 393 | if (!payload.filename) { 394 | throw Error(`Audio message must have filename. <${payload}>`) 395 | } 396 | // payload.filename is an URL to the audio file. The name of the file is not in the URL. 397 | // Setting a filename with expected extension is necessary for inference of mime type in 398 | // FileBox. 399 | return FileBox.fromUrl(payload.filename, 'message.amr') 400 | default: 401 | throw Error('can"t get file from the message') 402 | } 403 | } 404 | 405 | override async messageUrl (messageId: string) : Promise { 406 | log.verbose('PuppetOA', 'messageUrl(%s)', messageId) 407 | // const attachment = this.mocker.MockMessage.loadAttachment(messageId) 408 | // if (attachment instanceof UrlLink) { 409 | // return attachment.payload 410 | // } 411 | return { 412 | title : 'mock title for ' + messageId, 413 | url : 'https://mock.url', 414 | } 415 | } 416 | 417 | override async messageMiniProgram (messageId: string): Promise { 418 | log.verbose('PuppetOA', 'messageMiniProgram(%s)', messageId) 419 | // const attachment = this.mocker.MockMessage.loadAttachment(messageId) 420 | // if (attachment instanceof MiniProgram) { 421 | // return attachment.payload 422 | // } 423 | return { 424 | title : 'mock title for ' + messageId, 425 | } 426 | } 427 | 428 | override async messageRawPayloadParser (rawPayload: OAMessagePayload): Promise { 429 | const payload: PUPPET.payloads.Message = { 430 | id : rawPayload.MsgId, 431 | listenerId: rawPayload.ToUserName, 432 | talkerId : rawPayload.FromUserName, 433 | timestamp : parseInt(rawPayload.CreateTime), 434 | type : PUPPET.types.Message.Text, 435 | } 436 | if (rawPayload.MsgType === 'image') { 437 | payload.type = PUPPET.types.Message.Image 438 | if (!rawPayload.PicUrl) { 439 | throw Error(`Image Payload must has PicUrl field :<${JSON.stringify(rawPayload)}>`) 440 | } 441 | payload.filename = rawPayload.PicUrl 442 | } else if (rawPayload.MsgType === 'video') { 443 | payload.type = PUPPET.types.Message.Video 444 | } else if (rawPayload.MsgType === 'location') { 445 | payload.type = PUPPET.types.Message.Location 446 | } else if (rawPayload.MsgType === 'text') { 447 | payload.text = rawPayload.Content 448 | } else if (rawPayload.MsgType === 'event') { 449 | payload.text = rawPayload.EventKey 450 | } else if (rawPayload.MsgType === 'voice') { 451 | payload.type = PUPPET.types.Message.Audio 452 | payload.filename = await this.oa?.getAudioUrl(rawPayload.MediaId!) 453 | } 454 | return payload 455 | } 456 | 457 | override async messageRawPayload (id: string): Promise { 458 | log.verbose('PuppetOA', 'messageRawPayload(%s)', id) 459 | 460 | const payload = await this.oa?.payloadStore.getMessagePayload(id) 461 | 462 | if (!payload) { 463 | throw new Error('payload not found from oa store') 464 | } 465 | return payload 466 | } 467 | 468 | private async _messageSend ( 469 | conversationId: string, 470 | something: string | FileBox, // | Attachment 471 | mediatype: OAMediaType = 'image', 472 | ): Promise { 473 | log.verbose('PuppetOA', 'messageSend(%s, %s)', conversationId, something) 474 | let msgId = null 475 | if (typeof something === 'string') { 476 | const payload = { 477 | content: something, 478 | msgtype: 'text' as const, 479 | touser: conversationId, 480 | } 481 | if (this.personalMode) { 482 | msgId = await this.oa?.sendCustomMessagePersonal(payload) 483 | if (!msgId) { 484 | throw new Error('can"t send personal CustomeMessage') 485 | } 486 | } else { 487 | msgId = await this.oa?.sendCustomMessage(payload) 488 | } 489 | } else if (FileBox.valid(something)) { 490 | await something.ready() 491 | msgId = await this.oa?.sendFile({ file: something, msgtype: mediatype, touser: conversationId }) 492 | } 493 | if (!msgId) { 494 | throw new Error('PuppetOA messageSend() can"t get msgId response') 495 | } 496 | return msgId 497 | } 498 | 499 | override async messageSendText ( 500 | conversationId: string, 501 | text : string, 502 | ): Promise { 503 | return this._messageSend(conversationId, text) 504 | } 505 | 506 | override async messageSendFile ( 507 | conversationId: string, 508 | file : FileBox, 509 | ): Promise { 510 | let msgtype: OAMediaType 511 | const mimeType = file.mediaType 512 | switch (mimeType) { 513 | case 'image/jpeg': 514 | case 'image/jpg': 515 | case 'image/png': 516 | case 'image/gif': 517 | msgtype = 'image' 518 | break 519 | case 'audio/amr': 520 | case 'audio/mpeg': 521 | msgtype = 'voice' 522 | break 523 | case 'video/mp4': 524 | msgtype = 'video' 525 | break 526 | default: 527 | throw new Error(`unsupported media type: ${file.mimeType}`) 528 | } 529 | return this._messageSend(conversationId, file, msgtype) 530 | } 531 | 532 | override async messageSendContact ( 533 | conversationId: string, 534 | contactId : string, 535 | ): Promise { 536 | log.verbose('PuppetOA', 'messageSendUrl(%s, %s)', conversationId, contactId) 537 | 538 | // const contact = this.mocker.MockContact.load(contactId) 539 | // return this.messageSend(conversationId, contact) 540 | } 541 | 542 | override async messageSendUrl ( 543 | conversationId: string, 544 | urlLinkPayload : PUPPET.payloads.UrlLink, 545 | ) : Promise { 546 | log.verbose('PuppetOA', 'messageSendUrl(%s, %s)', conversationId, urlLinkPayload) 547 | let msgId = null 548 | msgId = await this.oa?.sendCustomLink({ touser: conversationId, urlLinkPayload }) 549 | if (!msgId) { 550 | throw new Error('PuppetOA messageSendUrl() can"t get msgId response') 551 | } 552 | return msgId 553 | } 554 | 555 | override async messageSendMiniProgram ( 556 | conversationId: string, 557 | miniProgramPayload: PUPPET.payloads.MiniProgram, 558 | ): Promise { 559 | log.verbose('PuppetOA', 'messageSendMiniProgram(%s, %s)', conversationId, JSON.stringify(miniProgramPayload)) 560 | let msgId = null 561 | msgId = await this.oa?.sendCustomMiniProgram({ miniProgram:miniProgramPayload, touser: conversationId }) 562 | if (!msgId) { 563 | throw new Error('PuppetOA messageSendMiniProgram() can"t get msgId response') 564 | } 565 | return msgId 566 | } 567 | 568 | override async messageForward ( 569 | conversationId: string, 570 | messageId : string, 571 | ): Promise { 572 | log.verbose('PuppetOA', 'messageForward(%s, %s)', 573 | conversationId, 574 | messageId, 575 | ) 576 | } 577 | 578 | override async conversationReadMark ( 579 | conversationId : string, 580 | hasRead? : boolean, 581 | ): Promise { 582 | log.verbose('PuppetOA', 'conversationReadMark(%s, %s)', 583 | conversationId, 584 | hasRead, 585 | ) 586 | } 587 | 588 | /** 589 | * 590 | * Room 591 | * 592 | */ 593 | override async roomRawPayloadParser (payload: PUPPET.payloads.Room) { return payload } 594 | override async roomRawPayload (id: string): Promise { 595 | log.verbose('PuppetOA', 'roomRawPayload(%s)', id) 596 | return {} as any 597 | } 598 | 599 | override async roomList (): Promise { 600 | log.verbose('PuppetOA', 'roomList()') 601 | return [] 602 | } 603 | 604 | override async roomDel ( 605 | roomId : string, 606 | contactId : string, 607 | ): Promise { 608 | log.verbose('PuppetOA', 'roomDel(%s, %s)', roomId, contactId) 609 | } 610 | 611 | override async roomAvatar (roomId: string): Promise { 612 | log.verbose('PuppetOA', 'roomAvatar(%s)', roomId) 613 | 614 | const payload = await this.roomPayload(roomId) 615 | 616 | if (payload.avatar) { 617 | return FileBox.fromUrl(payload.avatar) 618 | } 619 | log.warn('PuppetOA', 'roomAvatar() avatar not found, use the chatie default.') 620 | return qrCodeForChatie() 621 | } 622 | 623 | override async roomAdd ( 624 | roomId : string, 625 | contactId : string, 626 | ): Promise { 627 | log.verbose('PuppetOA', 'roomAdd(%s, %s)', roomId, contactId) 628 | } 629 | 630 | override async roomTopic (roomId: string) : Promise 631 | override async roomTopic (roomId: string, topic: string) : Promise 632 | 633 | override async roomTopic ( 634 | roomId: string, 635 | topic?: string, 636 | ): Promise { 637 | log.verbose('PuppetOA', 'roomTopic(%s, %s)', roomId, topic) 638 | 639 | if (typeof topic === 'undefined') { 640 | return 'mock room topic' 641 | } 642 | await this.dirtyPayload(PUPPET.types.Payload.Room, roomId) 643 | } 644 | 645 | override async roomCreate ( 646 | contactIdList : string[], 647 | topic : string, 648 | ): Promise { 649 | log.verbose('PuppetOA', 'roomCreate(%s, %s)', contactIdList, topic) 650 | 651 | return 'mock_room_id' 652 | } 653 | 654 | override async roomQuit (roomId: string): Promise { 655 | log.verbose('PuppetOA', 'roomQuit(%s)', roomId) 656 | } 657 | 658 | override async roomQRCode (roomId: string): Promise { 659 | log.verbose('PuppetOA', 'roomQRCode(%s)', roomId) 660 | return roomId + ' mock qrcode' 661 | } 662 | 663 | override async roomMemberList (roomId: string) : Promise { 664 | log.verbose('PuppetOA', 'roomMemberList(%s)', roomId) 665 | return [] 666 | } 667 | 668 | override async roomMemberRawPayload (roomId: string, contactId: string): Promise { 669 | log.verbose('PuppetOA', 'roomMemberRawPayload(%s, %s)', roomId, contactId) 670 | return { 671 | avatar : 'mock-avatar-data', 672 | id : 'xx', 673 | name : 'mock-name', 674 | roomAlias : 'yy', 675 | } 676 | } 677 | 678 | override async roomMemberRawPayloadParser (rawPayload: PUPPET.payloads.RoomMember): Promise { 679 | log.verbose('PuppetOA', 'roomMemberRawPayloadParser(%s)', rawPayload) 680 | return rawPayload 681 | } 682 | 683 | override async roomAnnounce (roomId: string) : Promise 684 | override async roomAnnounce (roomId: string, text: string) : Promise 685 | 686 | override async roomAnnounce (roomId: string, text?: string) : Promise { 687 | if (text) { 688 | return 689 | } 690 | return 'mock announcement for ' + roomId 691 | } 692 | 693 | /** 694 | * 695 | * Room Invitation 696 | * 697 | */ 698 | override async roomInvitationAccept (roomInvitationId: string): Promise { 699 | log.verbose('PuppetOA', 'roomInvitationAccept(%s)', roomInvitationId) 700 | } 701 | 702 | override async roomInvitationRawPayload (roomInvitationId: string): Promise { 703 | log.verbose('PuppetOA', 'roomInvitationRawPayload(%s)', roomInvitationId) 704 | } 705 | 706 | override async roomInvitationRawPayloadParser (rawPayload: any): Promise { 707 | log.verbose('PuppetOA', 'roomInvitationRawPayloadParser(%s)', JSON.stringify(rawPayload)) 708 | return rawPayload 709 | } 710 | 711 | /** 712 | * 713 | * Friendship 714 | * 715 | */ 716 | override async friendshipRawPayload (id: string): Promise { 717 | return { id } as any 718 | } 719 | 720 | override async friendshipRawPayloadParser (rawPayload: any): Promise { 721 | return rawPayload 722 | } 723 | 724 | override async friendshipSearchPhone ( 725 | phone: string, 726 | ): Promise { 727 | log.verbose('PuppetOA', 'friendshipSearchPhone(%s)', phone) 728 | return null 729 | } 730 | 731 | override async friendshipSearchWeixin ( 732 | weixin: string, 733 | ): Promise { 734 | log.verbose('PuppetOA', 'friendshipSearchWeixin(%s)', weixin) 735 | return null 736 | } 737 | 738 | override async friendshipAdd ( 739 | contactId : string, 740 | hello : string, 741 | ): Promise { 742 | log.verbose('PuppetOA', 'friendshipAdd(%s, %s)', contactId, hello) 743 | } 744 | 745 | override async friendshipAccept ( 746 | friendshipId : string, 747 | ): Promise { 748 | log.verbose('PuppetOA', 'friendshipAccept(%s)', friendshipId) 749 | } 750 | 751 | /** 752 | * 753 | * Tag 754 | * 755 | */ 756 | override async tagContactAdd ( 757 | tagId: string, 758 | contactId: string, 759 | ): Promise { 760 | log.verbose('PuppetOA', 'tagContactAdd(%s)', tagId, contactId) 761 | await this.oa?.addTagToMembers(tagId, [ contactId ]) 762 | } 763 | 764 | override async tagContactRemove ( 765 | tagId: string, 766 | contactId: string, 767 | ): Promise { 768 | log.verbose('PuppetOA', 'tagContactRemove(%s)', tagId, contactId) 769 | await this.oa?.removeTagFromMembers(tagId, [ contactId ]) 770 | } 771 | 772 | override async tagContactDelete ( 773 | tagId: string, 774 | ): Promise { 775 | log.verbose('PuppetOA', 'tagContactDelete(%s)', tagId) 776 | await this.oa?.deleteTag(tagId) 777 | } 778 | 779 | override async tagContactList ( 780 | contactId?: string, 781 | ): Promise { 782 | log.verbose('PuppetOA', 'tagContactList(%s)', contactId) 783 | if (!this.oa) { 784 | throw new Error('can not find oa object') 785 | } 786 | 787 | // 1. get all of the tags 788 | if (!contactId) { 789 | const tagList = await this.oa.getContactList() 790 | return tagList 791 | } 792 | 793 | // 2. get the member tags 794 | const tagList = await this.oa.getMemberTags(contactId) 795 | return tagList 796 | } 797 | 798 | } 799 | 800 | export { PuppetOA } 801 | export default PuppetOA 802 | -------------------------------------------------------------------------------- /src/official-account/official-account.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { 3 | log, 4 | } from 'wechaty-puppet' 5 | import { 6 | ContactGender, 7 | } from 'wechaty-puppet/types' 8 | import type { 9 | MiniProgram, 10 | UrlLink, 11 | } from 'wechaty-puppet/payloads' 12 | import type { 13 | FileBoxInterface, 14 | } from 'file-box' 15 | import { FileBoxType } from 'file-box' 16 | 17 | import * as UUID from 'uuid' 18 | import * as crypto from 'crypto' 19 | import { EventEmitter } from 'events' 20 | 21 | import { 22 | Webhook, 23 | VerifyArgs, 24 | } from './webhook.js' 25 | import { 26 | getSimpleUnirest, 27 | SimpleUnirest, 28 | } from './simple-unirest.js' 29 | import type { 30 | OAMessageType, 31 | // OAMediaPayload, 32 | OAMediaType, 33 | ErrorPayload, 34 | OAContactPayload, 35 | OATagPayload, 36 | OAMessagePayload, 37 | } from './schema.js' 38 | import { PayloadStore } from './payload-store.js' 39 | import { getTimeStampString } from './utils.js' 40 | import { normalizeFileBox } from './normalize-file-box.js' 41 | 42 | export interface OfficialAccountOptions { 43 | appId : string, 44 | appSecret : string, 45 | port? : number, 46 | token : string, 47 | webhookProxyUrl? : string, 48 | personalMode? : boolean, 49 | accessTokenProxyUrl? : string, 50 | } 51 | 52 | export interface AccessTokenPayload { 53 | expiresIn : number, 54 | timestamp : number, 55 | token : string, 56 | } 57 | type StopperFn = () => void 58 | 59 | class OfficialAccount extends EventEmitter { 60 | 61 | payloadStore : PayloadStore 62 | 63 | protected webhook : Webhook 64 | protected simpleUnirest : SimpleUnirest 65 | 66 | protected accessTokenPayload? : AccessTokenPayload 67 | 68 | protected stopperFnList : StopperFn[] 69 | protected oaId : string 70 | 71 | // proxy of the access token center 72 | protected accessTokenProxyUrl?: string 73 | 74 | get accessToken (): string { 75 | if (!this.accessTokenPayload) { 76 | throw new Error('accessToken() this.accessTokenPayload uninitialized!') 77 | } 78 | return this.accessTokenPayload.token 79 | } 80 | 81 | constructor ( 82 | public options: OfficialAccountOptions, 83 | ) { 84 | super() 85 | log.verbose('OfficialAccount', 'constructor(%s)', JSON.stringify(options)) 86 | 87 | // keep the official account id consist with puppet-oa 88 | this.oaId = `gh_${options.appId}` 89 | 90 | this.webhook = new Webhook({ 91 | personalMode : !!this.options.personalMode, 92 | port : this.options.port, 93 | verify : this.verify.bind(this), 94 | webhookProxyUrl : this.options.webhookProxyUrl, 95 | }) 96 | 97 | this.payloadStore = new PayloadStore(options.appId) 98 | this.simpleUnirest = getSimpleUnirest('https://api.weixin.qq.com/cgi-bin/') 99 | this.stopperFnList = [] 100 | 101 | this.accessTokenProxyUrl = options.accessTokenProxyUrl 102 | } 103 | 104 | verify (args: VerifyArgs): boolean { 105 | log.verbose('OfficialAccount', 'verify(%s)', JSON.stringify(args)) 106 | 107 | const data = [ 108 | args.timestamp, 109 | args.nonce, 110 | this.options.token, 111 | ].sort().join('') 112 | 113 | const digest = crypto 114 | .createHash('sha1') 115 | .update(data) 116 | .digest('hex') 117 | 118 | return digest === args.signature 119 | } 120 | 121 | async start () { 122 | log.verbose('OfficialAccount', 'start()') 123 | 124 | this.webhook.on('message', message => { 125 | this.payloadStore.setMessagePayload(message.MsgId, message) 126 | .then(() => this.emit('message', message)) 127 | .catch(console.error) 128 | }) 129 | 130 | await this.payloadStore.start() 131 | 132 | const succeed = await this.updateAccessToken() 133 | if (!succeed) { 134 | log.error('OfficialAccount', 'start() updateAccessToken() failed.') 135 | } 136 | 137 | const stopper = await this.startSyncingAccessToken() 138 | this.stopperFnList.push(stopper) 139 | 140 | await this.webhook.start() 141 | } 142 | 143 | async stop () { 144 | log.verbose('OfficialAccount', 'stop()') 145 | 146 | while (this.stopperFnList.length > 0) { 147 | const stopper = this.stopperFnList.pop() 148 | if (stopper) { 149 | await stopper() 150 | } 151 | } 152 | 153 | await this.webhook.stop() 154 | await this.payloadStore.stop() 155 | } 156 | 157 | protected async updateAccessToken (): Promise { 158 | log.verbose('OfficialAccount', 'updateAccessToken()') 159 | 160 | /** 161 | * updated: { 162 | * "access_token":"3...Q", 163 | * "expires_in":7200 164 | * } 165 | */ 166 | 167 | let simpleUnirest: SimpleUnirest = this.simpleUnirest 168 | // NOTE: it will fetch accessToken from the specific endpoint 169 | if (this.accessTokenProxyUrl) { 170 | simpleUnirest = getSimpleUnirest(this.accessTokenProxyUrl) 171 | } 172 | 173 | const ret = await simpleUnirest 174 | .get & { 175 | access_token : string 176 | expires_in : number 177 | }>(`token?grant_type=client_credential&appid=${this.options.appId}&secret=${this.options.appSecret}`) 178 | 179 | log.verbose('OfficialAccount', 'updateAccessToken() %s', JSON.stringify(ret.body)) 180 | 181 | if (ret.body.errcode && ret.body.errcode > 0) { 182 | // {"errcode":40164,"errmsg":"invalid ip 111.199.187.71 ipv6 ::ffff:111.199.187.71, not in whitelist hint: [H.BDtZFFE-Q7bNKA] rid: 5f283869-46321ea1-07d7260c"} 183 | log.warn('OfficialAccount', `updateAccessToken() ${ret.body.errcode}: ${ret.body.errmsg}`) 184 | 185 | if (this.accessTokenPayload) { 186 | const expireTimestamp = this.accessTokenPayload.timestamp 187 | + (this.accessTokenPayload.expiresIn * 1000) 188 | 189 | if (expireTimestamp > Date.now()) { 190 | // expired. 191 | log.warn('OfficialAccount', 'updateAccessToken() token expired!') 192 | this.accessTokenPayload = undefined 193 | } 194 | } 195 | 196 | return false 197 | } 198 | 199 | this.accessTokenPayload = { 200 | expiresIn : ret.body.expires_in, 201 | timestamp : Date.now(), 202 | token : ret.body.access_token, 203 | } 204 | 205 | log.verbose('OfficialAccount', 'updateAccessToken() synced. New token will expiredIn %s seconds', 206 | this.accessTokenPayload.expiresIn, 207 | ) 208 | 209 | return true 210 | } 211 | 212 | /** 213 | * https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html 214 | */ 215 | protected async startSyncingAccessToken (): Promise { 216 | log.verbose('OfficialAccount', 'startSyncingAccessToken()') 217 | 218 | const marginSeconds = 5 * 60 // 5 minutes 219 | const tryAgainSeconds = 60 // 1 minute 220 | 221 | /** 222 | * Huan(202102): Why we lost `NodeJS` ? 223 | * 224 | * https://stackoverflow.com/a/56239226/1123955 225 | */ 226 | let timer: undefined | ReturnType 227 | 228 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 229 | const update: () => void = () => this.updateAccessToken() 230 | .then(succeed => succeed 231 | ? this.accessTokenPayload!.expiresIn - marginSeconds 232 | : tryAgainSeconds, 233 | ) 234 | .then(seconds => setTimeout(update, seconds * 1000)) 235 | // eslint-disable-next-line no-return-assign 236 | .then(newTimer => timer = newTimer) 237 | .catch(e => log.error('OfficialAccount', 'startSyncingAccessToken() update() rejection: %s', e)) 238 | 239 | if (!this.accessTokenPayload) { 240 | await update() 241 | } else { 242 | const seconds = this.accessTokenPayload.expiresIn - marginSeconds 243 | timer = setTimeout(update, seconds * 1000) 244 | } 245 | 246 | return () => timer && clearTimeout(timer) 247 | } 248 | 249 | async sendCustomMessagePersonal (args: { 250 | touser: string, 251 | msgtype: OAMessageType, 252 | content: string, 253 | }): Promise { 254 | this.webhook.emit('instantReply', args) 255 | const uuid: string = UUID.v4() 256 | await this.payloadStore.setMessagePayload(uuid, { 257 | Content : args.content, 258 | CreateTime : getTimeStampString(), 259 | FromUserName : this.oaId, 260 | MsgId : uuid, 261 | MsgType : 'text', 262 | ToUserName : args.touser, 263 | }) 264 | return uuid 265 | } 266 | 267 | /** 268 | * 客服接口-发消息 269 | * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html#7 270 | */ 271 | async sendCustomMessage (args: { 272 | touser: string, 273 | msgtype: OAMessageType, 274 | content: string, 275 | }): Promise { 276 | log.verbose('OfficialAccount', 'sendCustomMessage(%s)', JSON.stringify(args)) 277 | 278 | const ret = await this.simpleUnirest 279 | .post(`message/custom/send?access_token=${this.accessToken}`) 280 | .type('json') 281 | .send({ 282 | msgtype : args.msgtype, 283 | text : 284 | { 285 | content : args.content, 286 | }, 287 | touser : args.touser, 288 | }) 289 | /** 290 | * { errcode: 0, errmsg: 'ok' } 291 | */ 292 | 293 | /** 294 | * TODO(huan) 202008: deal with this situation 295 | * { 296 | errcode: 45015, 297 | errmsg: 'response out of time limit or subscription is canceled hint: [CgCD2CMre-brVPIA] rid: 5f2b8ff1-4943a9b3-70b9fe5e' 298 | } 299 | */ 300 | 301 | // save the official-account payload 302 | if (ret.body.errcode) { 303 | throw new Error(`OfficialAccount sendCustomMessage() can send message <${JSON.stringify(args)}>`) 304 | } 305 | 306 | const uuid: string = UUID.v4() 307 | await this.payloadStore.setMessagePayload(uuid, { 308 | CreateTime : getTimeStampString(), 309 | FromUserName : this.oaId, 310 | MsgId : uuid, 311 | MsgType : 'text', 312 | ToUserName : args.touser, 313 | }) 314 | return uuid 315 | } 316 | 317 | async sendCustomLink (args: { 318 | touser: string 319 | urlLinkPayload:UrlLink 320 | }): Promise { 321 | log.verbose('OfficialAccount', 'sendCustomLink(%s)', JSON.stringify(args)) 322 | const msgtype: OAMessageType = 'link' 323 | const ret = await this.simpleUnirest 324 | .post(`message/custom/send?access_token=${this.accessToken}`) 325 | .type('json') 326 | .send({ 327 | msgtype : 'link', 328 | [msgtype] : 329 | { 330 | description: args.urlLinkPayload.description, 331 | thumb_url : args.urlLinkPayload.thumbnailUrl, 332 | title : args.urlLinkPayload.title, 333 | url : args.urlLinkPayload.url, 334 | }, 335 | touser : args.touser, 336 | }) 337 | 338 | if (ret.body.errcode) { 339 | throw new Error(`OfficialAccount sendCustomLink() can send link <${JSON.stringify(args)}>`) 340 | } 341 | 342 | const uuid: string = UUID.v4() 343 | await this.payloadStore.setMessagePayload(uuid, { 344 | CreateTime : getTimeStampString(), 345 | FromUserName : this.oaId, 346 | MsgId : uuid, 347 | MsgType : 'link', 348 | ToUserName : args.touser, 349 | }) 350 | return uuid 351 | } 352 | 353 | async sendCustomMiniProgram (args: { 354 | miniProgram:MiniProgram 355 | touser: string, 356 | }): Promise { 357 | log.verbose('OfficialAccount', 'sendCustomMiniProgram(%s)', JSON.stringify(args)) 358 | const msgtype:OAMessageType = 'miniprogrampage' 359 | const ret = await this.simpleUnirest 360 | .post(`message/custom/send?access_token=${this.accessToken}`) 361 | .type('json') 362 | .send({ 363 | msgtype : 'miniprogrampage', 364 | [msgtype] : 365 | { 366 | appid : args.miniProgram.appid, 367 | pagepath : args.miniProgram.pagePath, 368 | thumb_media_id: args.miniProgram.thumbKey, 369 | title : args.miniProgram.title, 370 | }, 371 | touser : args.touser, 372 | }) 373 | 374 | if (ret.body.errcode) { 375 | throw new Error(`OfficialAccount sendCustomMiniProgram can send miniProgram <${JSON.stringify(args)}>`) 376 | } 377 | 378 | const uuid: string = UUID.v4() 379 | await this.payloadStore.setMessagePayload(uuid, { 380 | CreateTime : getTimeStampString(), 381 | FromUserName : this.oaId, 382 | MsgId : uuid, 383 | MsgType : 'miniprogrampage', 384 | ToUserName : args.touser, 385 | }) 386 | return uuid 387 | } 388 | 389 | async sendFile (args: { 390 | file : FileBoxInterface, 391 | touser : string, 392 | msgtype : OAMediaType, 393 | }): Promise { 394 | log.verbose('OfficialAccount', 'sendFile(%s)', JSON.stringify(args)) 395 | // JSON.stringify does not support .mp3 filetype 396 | 397 | const { buf, info } = await normalizeFileBox(args.file) 398 | 399 | // all of the image file are compressed into image/jpeg type 400 | // and fetched fileBox has no name, which will cause error in upload file process. 401 | // this works for all of the image file 402 | // TODO -> should be improved later. 403 | 404 | if (args.file.type === FileBoxType.Url && args.file.mediaType === 'image/jpeg') { 405 | info.filename = `${args.file.name}.jpeg` 406 | } 407 | 408 | if (args.file.type === FileBoxType.Url && args.file.mediaType === 'audio/amr') { 409 | info.filename = `${args.file.name}` 410 | } 411 | const mediaResponse = await this.simpleUnirest.post & { 412 | media_id : string, 413 | created_at : string, 414 | type : string, 415 | }>(`media/upload?access_token=${this.accessToken}&type=${args.msgtype}`).attach('attachments[]', buf, info) 416 | // the type of result is string 417 | if (typeof mediaResponse.body === 'string') { 418 | mediaResponse.body = JSON.parse(mediaResponse.body) 419 | } 420 | 421 | const data = { 422 | [args.msgtype] : 423 | { 424 | media_id : mediaResponse.body.media_id, 425 | }, 426 | msgtype : args.msgtype, 427 | touser : args.touser, 428 | } 429 | 430 | const messageResponse = await this.simpleUnirest.post(`message/custom/send?access_token=${this.accessToken}`).type('json').send(data) 431 | if (messageResponse.body.errcode) { 432 | log.error('OfficialAccount', 'SendFile() can not send file to wechat user .<%s>', messageResponse.body.errmsg) 433 | throw new Error(`OfficialAccount', 'SendFile() can not send file to wechat user .<${messageResponse.body.errmsg}>'`) 434 | } 435 | 436 | // Now only support uploading image or audio. 437 | // Notes about image upload: 438 | // Situation One: when contact user send image file to oa, there will be PicUrl & MediaId fields 439 | // Situation Two: when server send file to tencent server, there is only MediaId field. 440 | if (!(args.msgtype === 'voice' || args.msgtype === 'image' || args.msgtype === 'video')) { 441 | throw new Error(`OfficialAccount, sendFile() doesn't support message type ${args.msgtype}`) 442 | } 443 | const messagePayload: OAMessagePayload = { 444 | CreateTime : getTimeStampString(), 445 | FromUserName : this.oaId, 446 | MediaId : mediaResponse.body.media_id, 447 | MsgId : UUID.v4(), 448 | MsgType : args.msgtype, 449 | ToUserName : args.touser, 450 | } 451 | await this.payloadStore.setMessagePayload(messagePayload.MsgId, messagePayload) 452 | return messagePayload.MsgId 453 | } 454 | 455 | async getContactList (): Promise { 456 | log.verbose('OfficialAccount', 'getContactList') 457 | 458 | let openIdList: string[] = [] 459 | let nextOpenId = '' 460 | 461 | // Individual subscription accounts and unverified accounts cannot access user information. 462 | if (this.options.personalMode) { 463 | return openIdList 464 | } 465 | 466 | // eslint-disable-next-line 467 | while (true) { 468 | const req = await this.simpleUnirest.get & { 469 | total : number, 470 | count : number, 471 | data : { 472 | openid : string[] 473 | }, 474 | next_openid : string 475 | }>(`user/get?access_token=${this.accessToken}&next_openid=${nextOpenId}`) 476 | 477 | if (req.body.errcode) { 478 | log.error(`OfficialAccount', 'getContactList() ${req.body.errmsg}`) 479 | return openIdList 480 | } 481 | 482 | if (!req.body.next_openid) { 483 | break 484 | } 485 | openIdList = openIdList.concat(req.body.data.openid) 486 | nextOpenId = req.body.next_openid 487 | } 488 | return openIdList 489 | } 490 | 491 | async getContactPayload (openId: string): Promise { 492 | log.verbose('OfficialAccount', 'getContactPayload(%s)', openId) 493 | 494 | if (openId && openId.startsWith('gh_')) { 495 | // if (openId) { 496 | 497 | // wechaty load the SelfContact object, so just return it. 498 | /* eslint-disable sort-keys */ 499 | const selfContactPayload: OAContactPayload = { 500 | subscribe : 0, 501 | openid : openId, 502 | nickname : 'from official-account options ?', 503 | sex : ContactGender.Unknown, 504 | language : 'zh_CN', 505 | city : '北京', 506 | province : '北京', 507 | country : '中国', 508 | headimgurl : '', 509 | subscribe_time : 0, 510 | unionid : '0', 511 | remark : '微信公众号客服', 512 | groupid : 0, 513 | tagid_list : [], 514 | subscribe_scene : '', 515 | qr_scene : 0, 516 | qr_scene_str : '', 517 | } 518 | return selfContactPayload 519 | } 520 | 521 | if (openId && !!this.options.personalMode) { 522 | // Individual subscription accounts and unverified accounts cannot access user information. 523 | /* eslint-disable sort-keys */ 524 | const subscribeContactPayload: OAContactPayload = { 525 | subscribe : 1, 526 | openid : openId, 527 | nickname : '订阅者', 528 | sex : ContactGender.Unknown, 529 | language : 'zh_CN', 530 | city : '北京', 531 | province : '北京', 532 | country : '中国', 533 | headimgurl : '', 534 | subscribe_time : 0, 535 | unionid : '0', 536 | remark : '订阅者', 537 | groupid : 0, 538 | tagid_list : [], 539 | subscribe_scene : '', 540 | qr_scene : 0, 541 | qr_scene_str : '', 542 | } 543 | return subscribeContactPayload 544 | } 545 | 546 | const res = await this.simpleUnirest.get(`user/info?access_token=${this.accessToken}&openid=${openId}&lang=zh_CN`) 547 | 548 | if (res.body.errcode) { 549 | log.error(`OfficialAccount', 'getContactPayload() ${res.body.errmsg}`) 550 | return 551 | } 552 | 553 | // const payload: ContactPayload = { 554 | // alias : res.body.remark, 555 | // avatar : res.body.headimgurl, 556 | // city : res.body.city, 557 | // friend : true, 558 | // gender : res.body.sex, 559 | // id : res.body.openid, 560 | // name : res.body.nickname, 561 | // province : res.body.province, 562 | // signature : '', 563 | // star : false, 564 | // type : ContactType.Individual, 565 | // weixin : res.body.unionid, 566 | // } 567 | 568 | /* 569 | * wj-Mcat: What kind of the ContactType should be ? 570 | * TODO -> there are some data can be feed into ContactPayload 571 | */ 572 | return res.body 573 | } 574 | 575 | async updateContactRemark (openId: string, remark: string): Promise { 576 | log.verbose('OfficialAccount', 'setContactRemark(%s)', JSON.stringify({ openId, remark })) 577 | 578 | const res = await this.simpleUnirest.post(`user/info/updateremark?access_token=${this.accessToken}`) 579 | 580 | if (res.body.errcode) { 581 | log.error('OfficialAccount', 'setContactRemark() can update contact remark (%s)', res.body.errmsg) 582 | } 583 | } 584 | 585 | async createTag (name: string): Promise { 586 | log.verbose('OfficialAccount', 'createTag(%s)', name) 587 | 588 | const res = await this.simpleUnirest.post & { 589 | tag?: { 590 | id : string, 591 | } 592 | }>(`tags/create?access_token=${this.accessToken}`) 593 | if (res.body.errcode) { 594 | log.error('OfficialAccount', 'createTag(%s) error code : %s', name, res.body.errcode) 595 | } else { 596 | return name 597 | } 598 | } 599 | 600 | async getTagList (): Promise { 601 | log.verbose('OfficialAccount', 'getTagList()') 602 | 603 | const res = await this.simpleUnirest.get & { 604 | tags? : OATagPayload[] 605 | }>(`tags/get?access_token=${this.accessToken}`) 606 | 607 | if (res.body.errcode) { 608 | log.error('OfficialAccount', 'getTagList() error code : %s', res.body.errcode) 609 | return [] 610 | } 611 | 612 | if (!res.body.tags || res.body.tags.length === 0) { 613 | log.warn('OfficialAccount', 'getTagList() get empty tag list') 614 | return [] 615 | } 616 | return res.body.tags 617 | } 618 | 619 | private async getTagIdByName (tagName: string): Promise { 620 | log.verbose('OfficialAccount', 'deleteTag(%s)', tagName) 621 | 622 | /** 623 | * TODO: this is not a frequent interface, So I don't cache the taglist 624 | */ 625 | const tagList: OATagPayload[] = await this.getTagList() 626 | const tag: OATagPayload[] = tagList.filter((item) => item.name === tagName) 627 | 628 | if (tag.length === 0) { 629 | return null 630 | } 631 | return tag[0]!.id 632 | } 633 | 634 | async deleteTag (tagName: string): Promise { 635 | log.verbose('OfficialAccount', 'deleteTag(%s)', tagName) 636 | 637 | // find tagId by tagName from tagList 638 | const tagId = await this.getTagIdByName(tagName) 639 | 640 | if (!tagId) { 641 | throw new Error(`can not find tag(${tagName})`) 642 | } 643 | const res = await this.simpleUnirest.post>(`tags/delete?access_token=${this.accessToken}`).send({ 644 | tag: { 645 | id : tagId, 646 | }, 647 | }) 648 | 649 | if (res.body.errcode) { 650 | log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) 651 | } 652 | } 653 | 654 | async addTagToMembers (tagName: string, openIdList: string[]): Promise { 655 | log.verbose('OfficialAccount', 'addTagToMembers(%s)', JSON.stringify({ tagName, openIdList })) 656 | 657 | const tagId = await this.getTagIdByName(tagName) 658 | 659 | if (!tagId) { 660 | throw new Error(`can not find tag(${tagName})`) 661 | } 662 | const res = await this.simpleUnirest.post>(`tags/members/batchtagging?access_token=${this.accessToken}`).send({ 663 | opeid_list : openIdList, 664 | tag_id : tagId, 665 | }) 666 | 667 | if (res.body.errcode) { 668 | log.error('OfficialAccount', 'addTagToMembers() error code : %s', res.body.errcode) 669 | } 670 | } 671 | 672 | async removeTagFromMembers (tagName: string, openIdList: string[]): Promise { 673 | log.verbose('OfficialAccount', 'removeTagFromMembers(%s)', JSON.stringify({ tagName, openIdList })) 674 | 675 | const tagId = await this.getTagIdByName(tagName) 676 | if (!tagId) { 677 | throw new Error(`can not find tag(${tagName})`) 678 | } 679 | 680 | const res = await this.simpleUnirest.post>(`tags/members/batchuntagging?access_token=${this.accessToken}`).send({ 681 | opeid_list : openIdList, 682 | tag_id : tagId, 683 | }) 684 | 685 | if (res.body.errcode) { 686 | log.error('OfficialAccount', 'removeTagFromMembers() error code : %s', res.body.errcode) 687 | } 688 | } 689 | 690 | async getMemberTags (openid: string): Promise { 691 | log.verbose('OfficialAccount', 'getMemberTags(%s)', openid) 692 | 693 | const res = await this.simpleUnirest.post & { 694 | tagid_list : number[] 695 | }>(`tags/getidlist?access_token=${this.accessToken}`).send({ 696 | openid, 697 | }) 698 | 699 | if (res.body.errcode) { 700 | throw new Error(`OfficialAccount deleteTag() error code : ${res.body.errcode}`) 701 | } 702 | 703 | // 1. build the tag id-name map to improve search efficiency 704 | const allTagList = await this.getTagList() 705 | const tagIdMap = allTagList.reduce((map: any, tag) => { map[tag.id] = tag.name; return map }, {}) 706 | 707 | // 2. retrive the names from id 708 | const tagNames: string[] = [] 709 | 710 | for (const tagId of res.body.tagid_list) { 711 | if (tagId in tagIdMap) { 712 | tagNames.push(tagIdMap[tagId]) 713 | } 714 | } 715 | 716 | return tagNames 717 | } 718 | 719 | async getAudioUrl (mediaId: string): Promise { 720 | // NOTE(zhangfan): As per Wechat API documentation (https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_temporary_materials.html), 721 | // /media/get behavior is not documented if the retrieved media content is an audio. 722 | // From real world testing, we learned it returns the audio content directly. 723 | // This is subject to changes. 724 | // 725 | // Here is an excerpt of the response header seen in tests: 726 | // "connection": "close", 727 | // "cache-control": "no-cache, must-revalidate", 728 | // "date": "Thu, 04 Feb 2021 08:51:34 GMT", 729 | // "content-disposition": "attachment; filename=\"Nz30tHrSoMhGf7FcOmddXuCIud-TP7Z71Yci6nOgYtGnLTkoD9V4yisRlj75Ghs7.amr\"", 730 | // "content-type": "audio/amr", 731 | // "content-length": "8630" 732 | return `https://api.weixin.qq.com/cgi-bin/media/get?access_token=${this.accessToken}&media_id=${mediaId}` 733 | } 734 | 735 | async setMemberRemark (openid: string, remark: string): Promise { 736 | log.verbose('OfficialAccount', 'setMemberRemark(%s)', openid) 737 | 738 | const res = await this.simpleUnirest.post>(`user/info/updateremark?access_token=${this.accessToken}`).send({ 739 | openid, 740 | remark, 741 | }) 742 | 743 | if (res.body.errcode) { 744 | log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) 745 | } 746 | } 747 | 748 | async sendBatchTextMessageByTagId (tagId: number, msg: string): Promise { 749 | log.verbose('OfficialAccount', 'sendBatchTextMessageByTagId(%s)', JSON.stringify({ tagId, msg })) 750 | 751 | const res = await this.simpleUnirest.post & { 752 | msg_id : number, 753 | msg_data_id : number, 754 | }>(`message/mass/sendall?access_token=${this.accessToken}`).send({ 755 | filter: { 756 | is_to_all : false, 757 | tag_id : tagId, 758 | }, 759 | text: { 760 | content : msg, 761 | }, 762 | msgtype : 'text', 763 | }) 764 | 765 | if (res.body.errcode) { 766 | log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) 767 | } 768 | } 769 | 770 | async sendBatchTextMessageByOpenidList (openidList: string[], msg: string): Promise { 771 | log.verbose('OfficialAccount', 'sendBatchTextMessageByOpenidList(%s)', JSON.stringify({ openidList, msg })) 772 | 773 | const res = await this.simpleUnirest.post & { 774 | msg_id : number, 775 | msg_data_id : number, 776 | }>(`message/mass/send?access_token=${this.accessToken}`).send({ 777 | touser : openidList, 778 | msgtype : 'text', 779 | text : { content: msg }, 780 | }) 781 | 782 | if (res.body.errcode) { 783 | log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) 784 | } 785 | } 786 | 787 | /** 788 | * 获取授权方的帐号基本信息 789 | * 该 API 用于获取授权方的基本信息,包括头像、昵称、帐号类型、认证类型、微信号、原始ID和二维码图片URL。 790 | * https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/api/api_get_authorizer_info.html 791 | */ 792 | // async getInfo () { 793 | // log.verbose('OfficialAccount', 'sendCustomMessage(%s)', JSON.stringify(args)) 794 | 795 | // const ret = await this.simpleUnirest 796 | // .post(`component/api_get_authorizer_info?component_access_token=${this.accessToken}`) 797 | // .type('json') 798 | // .send({ 799 | // msgtype: args.msgtype, 800 | // text: 801 | // { 802 | // content: args.content, 803 | // }, 804 | // touser: args.touser, 805 | // }) 806 | 807 | // return ret.body 808 | // POST https://api.weixin.qq.com/cgi-bin/ 809 | 810 | // } 811 | 812 | } 813 | 814 | export { OfficialAccount } 815 | --------------------------------------------------------------------------------