├── tslint.json ├── .prettierrc ├── .prettierignore ├── .vscode ├── settings.json ├── debug-ts.js └── launch.json ├── src ├── strings │ ├── index.ts │ ├── BotFather.txt │ ├── zh_CN.ts │ └── en_US.ts ├── lib │ ├── HTMLTemplates.ts │ ├── Logger.ts │ ├── MiscHelper.ts │ └── XmlParser.ts ├── bot │ ├── HandleUnlock.ts │ ├── index.ts │ ├── HandleCurrent.ts │ ├── HandleUnmute.ts │ ├── HandleMute.ts │ ├── UpdateTmpFile.ts │ ├── HandleSoundOnly.ts │ ├── HandleNameOnly.ts │ ├── HandleLock.ts │ ├── HandleForwardTo.ts │ ├── HandleFindX.ts │ ├── HandleTelegramMessage.ts │ └── HandleWechatMessage.ts ├── index.ts └── Bot.ts ├── .npmignore ├── tsconfig.module.json ├── .gitignore ├── config-example.json ├── .github ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .editorconfig ├── scripts └── wechaty ├── .circleci └── config.yml ├── tsconfig.json ├── README.md ├── package.json └── LICENSE /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "max-line-length": [true, 150] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 150 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # package.json is formatted by package managers, so we ignore it here 2 | package.json -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | // "typescript.implementationsCodeLens.enabled": true 4 | // "typescript.referencesCodeLens.enabled": true 5 | } 6 | -------------------------------------------------------------------------------- /src/strings/index.ts: -------------------------------------------------------------------------------- 1 | import en_US from './en_US'; 2 | import zh_CN from './zh_CN'; 3 | 4 | export function getLang(lang = 'zh-cn') { 5 | return lang === 'zh-cn' ? zh_CN : en_US; 6 | } 7 | 8 | export default zh_CN; 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | tsconfig.json 4 | tsconfig.module.json 5 | tslint.json 6 | .travis.yml 7 | .github 8 | .prettierignore 9 | .vscode 10 | build/docs 11 | **/*.spec.* 12 | coverage 13 | .nyc_output 14 | *.log 15 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "build/module", 6 | "module": "esnext" 7 | }, 8 | "exclude": [ 9 | "node_modules/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | test 4 | src/**.js 5 | .idea/* 6 | 7 | coverage 8 | .nyc_output 9 | *.log 10 | 11 | *.png 12 | 13 | config.json 14 | leavexchat-bot/ 15 | *.tar.gz 16 | config.development.json 17 | *.memory-card.json 18 | -------------------------------------------------------------------------------- /config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "", // required, telegram bot token 3 | "silent": false, // silent mode, bot alert 4 | "allows": [], // telegram uid 5 | "httpProxy": { 6 | "host": "127.0.0.1", 7 | "port": 1081 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Example Contributing Guidelines 2 | 3 | This is an example of GitHub's contributing guidelines file. Check out GitHub's [CONTRIBUTING.md help center article](https://help.github.com/articles/setting-guidelines-for-repository-contributors/) for more information. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | 4 | 5 | * **What is the current behavior?** (You can also link to an open issue here) 6 | 7 | 8 | 9 | * **What is the new behavior (if this is a feature change)?** 10 | 11 | 12 | 13 | * **Other information**: 14 | -------------------------------------------------------------------------------- /src/lib/HTMLTemplates.ts: -------------------------------------------------------------------------------- 1 | export default class HTMLTemplates { 2 | 3 | static message({ nickname, message }: { nickname: string, message: string }) { 4 | const html = `${nickname}\n\n${message}`; 5 | return html; 6 | } 7 | 8 | static markdown({ nickname, content }: { nickname: string, content: string }) { 9 | return `\`${nickname}\`\n\n${content}`; 10 | } 11 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | * **Summary** 8 | 9 | 10 | 11 | * **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) 12 | -------------------------------------------------------------------------------- /scripts/wechaty: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | rm node_modules/wechaty/src/io-peer/json-rpc-peer.d.ts; 4 | rm node_modules/memory-card/dist/src/memory-card.d.ts; 5 | # rm node_modules/wechaty/dist/esm/src/wechaty/wechaty-base.d.ts; 6 | sed -i -e "/\/\/\/ /d" node_modules/wechaty/dist/esm/src/config.d.ts; 7 | sed -i -e "483d" node_modules/wechaty/dist/esm/src/wechaty/wechaty-base.d.ts; 8 | -------------------------------------------------------------------------------- /src/lib/Logger.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export default class Logger { 4 | static info(...msg: any[]) { 5 | this.log('info', ...msg); 6 | } 7 | 8 | static warn(...objs: any[]) { 9 | this.log('warn', ...objs); 10 | } 11 | 12 | static error(...objs: any[]) { 13 | this.log('error', ...objs); 14 | } 15 | 16 | static log(level: string, ...msg: any[]) { 17 | let info = msg.join(' '); 18 | console.log(`${dayjs().format('HH:mm:ss')} ${level.toUpperCase()} ${info}`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/bot/HandleUnlock.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../Bot'; 2 | import { Context } from 'telegraf/typings/context'; 3 | import MiscHelper from '../lib/MiscHelper'; 4 | import lang from '../strings'; 5 | import { writeFile } from './UpdateTmpFile'; 6 | 7 | export default async (ctx: Context) => { 8 | let user = ctx['user'] as Client; 9 | if (!user.currentContact) return; 10 | if (!user.contactLocked) return; 11 | user.contactLocked = false; 12 | 13 | const name = await MiscHelper.getFriendlyName(user.currentContact); 14 | 15 | await writeFile(`${user.botId}${ctx?.chat?.id}`, { 16 | recentContact: { name, locked: false }, 17 | }); 18 | 19 | ctx.reply(lang.message.contactUnlocked(name)); 20 | }; 21 | -------------------------------------------------------------------------------- /src/bot/index.ts: -------------------------------------------------------------------------------- 1 | import handleCurrent from './HandleCurrent'; 2 | import handleFind from './HandleFindX'; 3 | import handleForwardTo from './HandleForwardTo'; 4 | import handleLock from './HandleLock'; 5 | import handleMute from './HandleMute'; 6 | import handleNameOnly from './HandleNameOnly'; 7 | import handleSoundOnly from './HandleSoundOnly'; 8 | import handleTelegramMessage from './HandleTelegramMessage'; 9 | import handleUnlock from './HandleUnlock'; 10 | import handleUnmute from './HandleUnmute'; 11 | import handleWechatMessage from './HandleWechatMessage'; 12 | 13 | export { 14 | handleFind, 15 | handleLock, 16 | handleUnlock, 17 | handleCurrent, 18 | handleTelegramMessage, 19 | handleWechatMessage, 20 | handleForwardTo, 21 | handleMute, 22 | handleUnmute, 23 | handleSoundOnly, 24 | handleNameOnly, 25 | }; 26 | -------------------------------------------------------------------------------- /src/bot/HandleCurrent.ts: -------------------------------------------------------------------------------- 1 | import { Contact, Room } from 'wechaty'; 2 | 3 | import { Client } from '../Bot'; 4 | import { Context } from 'telegraf/typings/context'; 5 | import lang from '../strings'; 6 | 7 | export default async (ctx: Context) => { 8 | let user = ctx['user'] as Client; 9 | if (!user?.currentContact) { 10 | ctx.reply(lang.message.noCurrentContact); 11 | return; 12 | } 13 | 14 | let name = ''; 15 | 16 | const alias = (await (user.currentContact as Contact)?.['alias']?.()) ?? ''; 17 | name = 18 | (user.currentContact as Contact)?.['name']?.() || 19 | (await (user.currentContact as Room)?.['topic']?.()); 20 | 21 | name = alias ? `${name} (${alias})` : name; 22 | 23 | let info = user.contactLocked 24 | ? ` [${lang.message.contactLocked('').trim()}]` 25 | : ''; 26 | 27 | const sent = await ctx.reply(lang.message.current(name) + info); 28 | user.msgs.set(sent.message_id, { 29 | contact: user.currentContact, 30 | wxmsg: undefined, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/bot/HandleUnmute.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../Bot'; 2 | import { Context } from 'telegraf/typings/context'; 3 | import { Message } from 'telegraf/typings/core/types/typegram'; 4 | import lang from '../strings'; 5 | import { writeFile } from './UpdateTmpFile'; 6 | 7 | export default async (ctx: Context) => { 8 | const msg = ctx.message as Message.TextMessage; 9 | if (!msg) return; 10 | 11 | const id = ctx?.chat?.id; 12 | const user = ctx['user'] as Client; 13 | 14 | let contents = msg.text.split(' '); 15 | contents.shift(); 16 | 17 | let name = contents.reduce((p, c) => `${p} ${c}`, '').trim(); 18 | 19 | if (name) { 20 | user.muteList = user.muteList.filter((i) => i !== name); 21 | user.soundOnlyList = user.soundOnlyList.filter((i) => i !== name); 22 | 23 | await ctx.reply(lang.message.unmuteRoom(name)); 24 | } else { 25 | await ctx.reply(lang.message.unmuteRoom(user.muteList)); 26 | user.muteList = []; 27 | user.soundOnlyList = []; 28 | user.nameOnlyList = {}; 29 | } 30 | 31 | await writeFile(`${user.botId}${id}`, { 32 | muteList: user.muteList, 33 | soundOnly: user.soundOnlyList, 34 | namesOnly: user.nameOnlyList, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/bot/HandleMute.ts: -------------------------------------------------------------------------------- 1 | import Bot, { Client } from '../Bot'; 2 | import { Contact, Room } from 'wechaty'; 3 | 4 | import { Context } from 'telegraf/typings/context'; 5 | import { Message } from 'telegraf/typings/core/types/typegram'; 6 | import lang from '../strings'; 7 | import { writeFile } from './UpdateTmpFile'; 8 | 9 | export default async (ctx: Context) => { 10 | const msg = ctx.message as Message.TextMessage; 11 | const user = ctx['user'] as Client; 12 | 13 | if (!msg) return; 14 | 15 | if (!msg.reply_to_message && !user.currentContact) { 16 | await ctx.reply(lang.message.noQuoteMessage); 17 | return; 18 | } 19 | 20 | const id = ctx?.chat?.id; 21 | const wxmsg = user.msgs.get(msg.reply_to_message?.message_id)?.wxmsg; 22 | 23 | const room = wxmsg?.room() ?? user.currentContact; 24 | 25 | const topic = 26 | (await (room as Room)['topic']?.()) || (room as Contact)['name']?.(); 27 | 28 | if (user.muteList.includes(topic)) { 29 | await ctx.reply(lang.message.muteRoom(topic)); 30 | return; 31 | } 32 | 33 | user.muteList.push(topic); 34 | await ctx.reply(lang.message.muteRoom(topic)); 35 | await writeFile(`${user.botId}${id}`, { muteList: user.muteList }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/bot/UpdateTmpFile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import tmpDir from 'temp-dir'; 4 | 5 | interface ITmpFile { 6 | recentContact?: { name: string; locked?: boolean }; 7 | muteList?: string[]; 8 | soundOnly?: string[]; 9 | namesOnly?: { [group: string]: string[] }; 10 | } 11 | 12 | /** 13 | * 14 | * @param filename botId.chatId 15 | * @param content 16 | */ 17 | export async function writeFile(filename: string, content: ITmpFile) { 18 | const filepath = path.join(tmpDir, filename); 19 | const origin = await readFile(filename); 20 | 21 | return new Promise((resolve) => { 22 | fs.writeFile( 23 | filepath, 24 | JSON.stringify({ ...origin, ...content }), 25 | { encoding: 'utf8' }, 26 | () => resolve() 27 | ); 28 | }); 29 | } 30 | 31 | export function readFile(filename: string) { 32 | const filepath = path.join(tmpDir, filename); 33 | 34 | return new Promise((resolve) => { 35 | fs.readFile(filepath, { encoding: 'utf8' }, (err, data) => { 36 | let content: ITmpFile = {}; 37 | 38 | try { 39 | content = JSON.parse(data); 40 | } catch (error) {} 41 | 42 | resolve(content); 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/bot/HandleSoundOnly.ts: -------------------------------------------------------------------------------- 1 | import Bot, { Client } from '../Bot'; 2 | import { Contact, Room } from 'wechaty'; 3 | 4 | import { Context } from 'telegraf/typings/context'; 5 | import { Message } from 'telegraf/typings/core/types/typegram'; 6 | import lang from '../strings'; 7 | import { writeFile } from './UpdateTmpFile'; 8 | 9 | export default async (ctx: Context) => { 10 | const msg = ctx.message as Message.TextMessage; 11 | const user = ctx['user'] as Client; 12 | 13 | if (!msg) return; 14 | 15 | if (!msg.reply_to_message && !user.currentContact) { 16 | await ctx.reply(lang.message.noQuoteMessage); 17 | return; 18 | } 19 | 20 | const id = ctx?.chat?.id; 21 | const wxmsg = user.msgs.get(msg.reply_to_message?.message_id)?.wxmsg; 22 | 23 | const room = wxmsg?.room() ?? user.currentContact; 24 | 25 | const name = 26 | (await (room as Room)['topic']?.()) || (room as Contact)['name']?.(); 27 | 28 | if (user.soundOnlyList.includes(name)) { 29 | await ctx.reply(lang.message.soundOnlyRoom(name)); 30 | return; 31 | } 32 | 33 | user.soundOnlyList.push(name); 34 | await ctx.reply(lang.message.soundOnlyRoom(name)); 35 | await writeFile(`${user.botId}${id}`, { soundOnly: user.soundOnlyList }); 36 | }; 37 | -------------------------------------------------------------------------------- /.vscode/debug-ts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const meow = require('meow'); 3 | const path = require('path'); 4 | 5 | const tsFile = getTSFile(); 6 | const jsFile = TS2JS(tsFile); 7 | 8 | replaceCLIArg(tsFile, jsFile); 9 | 10 | // Ava debugger 11 | require('ava/profile'); 12 | 13 | /** 14 | * get ts file path from CLI args 15 | * 16 | * @return string path 17 | */ 18 | function getTSFile() { 19 | const cli = meow(); 20 | return cli.input[0]; 21 | } 22 | 23 | /** 24 | * get associated compiled js file path 25 | * 26 | * @param tsFile path 27 | * @return string path 28 | */ 29 | function TS2JS(tsFile) { 30 | const srcFolder = path.join(__dirname, '..', 'src'); 31 | const distFolder = path.join(__dirname, '..', 'build', 'main'); 32 | 33 | const tsPathObj = path.parse(tsFile); 34 | 35 | return path.format({ 36 | dir: tsPathObj.dir.replace(srcFolder, distFolder), 37 | ext: '.js', 38 | name: tsPathObj.name, 39 | root: tsPathObj.root 40 | }); 41 | } 42 | 43 | /** 44 | * replace a value in CLI args 45 | * 46 | * @param search value to search 47 | * @param replace value to replace 48 | * @return void 49 | */ 50 | function replaceCLIArg(search, replace) { 51 | process.argv[process.argv.indexOf(search)] = replace; 52 | } -------------------------------------------------------------------------------- /src/bot/HandleNameOnly.ts: -------------------------------------------------------------------------------- 1 | import Bot, { Client } from '../Bot'; 2 | import { Contact, Room } from 'wechaty'; 3 | 4 | import { Context } from 'telegraf/typings/context'; 5 | import { Message } from 'telegraf/typings/core/types/typegram'; 6 | import lang from '../strings'; 7 | import { writeFile } from './UpdateTmpFile'; 8 | 9 | export default async (ctx: Context) => { 10 | const msg = ctx.message as Message.TextMessage; 11 | const user = ctx['user'] as Client; 12 | 13 | if (!msg) return; 14 | 15 | if (!msg.reply_to_message && !user.currentContact) { 16 | await ctx.reply(lang.message.noQuoteMessage); 17 | return; 18 | } 19 | 20 | const id = ctx?.chat?.id; 21 | const wxmsg = user.msgs.get(msg.reply_to_message?.message_id)?.wxmsg; 22 | 23 | const room = wxmsg?.room?.(); 24 | 25 | if (!room) return; 26 | 27 | const topic = await room.topic(); 28 | 29 | const names = user.nameOnlyList[topic] || []; 30 | const onlyUser = wxmsg.talker().name(); 31 | 32 | if (names.includes(onlyUser)) { 33 | ctx.reply('OK'); 34 | return; 35 | } 36 | 37 | names.push(onlyUser); 38 | 39 | user.nameOnlyList[topic] = names; 40 | 41 | await ctx.reply(lang.message.nameOnly(onlyUser)); 42 | await writeFile(`${user.botId}${id}`, { namesOnly: user.nameOnlyList }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/bot/HandleLock.ts: -------------------------------------------------------------------------------- 1 | import Bot, { Client } from '../Bot'; 2 | import { Contact, Room } from 'wechaty'; 3 | 4 | import { Context } from 'telegraf/typings/context'; 5 | import { Message } from 'telegraf/typings/core/types/typegram'; 6 | import lang from '../strings'; 7 | import { writeFile } from './UpdateTmpFile'; 8 | 9 | export default async (ctx: Context) => { 10 | let user = ctx['user'] as Client; 11 | const msg = ctx.message as Message.TextMessage; 12 | 13 | if (!user.currentContact) { 14 | if (!msg?.reply_to_message) return; 15 | 16 | let wxmsg = user.msgs.get(msg.reply_to_message.message_id); 17 | if (!wxmsg) return; 18 | 19 | user.currentContact = wxmsg?.contact; 20 | user.contactLocked = false; 21 | } 22 | 23 | if (user.contactLocked) return; 24 | user.contactLocked = true; 25 | 26 | let name = ''; 27 | let save = ''; 28 | 29 | const alias = (await (user.currentContact as Contact)['alias']?.()) ?? ''; 30 | name = 31 | (user.currentContact as Contact)['name']?.() || 32 | (await (user.currentContact as Room)['topic']?.()) || 33 | ''; 34 | 35 | save = alias || name; 36 | name = alias ? `${name} (${alias})` : name; 37 | 38 | await writeFile(`${user.botId}${ctx?.chat?.id}`, { 39 | recentContact: { name: save, locked: true }, 40 | }); 41 | 42 | await ctx.reply(lang.message.contactLocked(name)); 43 | }; 44 | -------------------------------------------------------------------------------- /src/bot/HandleForwardTo.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../Bot'; 2 | import { Context } from 'telegraf/typings/context'; 3 | import { Message } from 'telegraf/typings/core/types/typegram'; 4 | import MiscHelper from '../lib/MiscHelper'; 5 | import lang from '../strings'; 6 | 7 | export default async (ctx: Context) => { 8 | const msg = ctx.message as Message.TextMessage; 9 | if (!msg) return; 10 | if (!msg.reply_to_message) { 11 | await ctx.reply(lang.message.noQuoteMessage); 12 | return; 13 | } 14 | 15 | const contents = msg.text.split(' '); 16 | contents.shift(); 17 | 18 | const to = contents.reduce((p, c) => `${p} ${c}`, '').trim(); 19 | const user = ctx['user'] as Client; 20 | 21 | let target = user.currentContact; 22 | 23 | if (to) { 24 | const regexp = new RegExp(to, 'ig'); 25 | target = 26 | (await user.wechat?.Contact.find({ name: regexp })) || 27 | (await user.wechat?.Contact.find({ alias: regexp })); 28 | 29 | if (!target) { 30 | await ctx.reply(lang.message.contactNotFound); 31 | return; 32 | } 33 | } 34 | 35 | const wxmsg = user.msgs.get(msg.reply_to_message.message_id)?.wxmsg; 36 | if (!wxmsg) return; 37 | 38 | await wxmsg.forward(target); 39 | 40 | const name = await MiscHelper.getFriendlyName(target || user.currentContact); 41 | await ctx.reply(lang.message.msgForward(name), { 42 | reply_to_message_id: msg.reply_to_message.message_id, 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [{ 4 | "type": "node", 5 | "request": "launch", 6 | "name": "Debug Project", 7 | // we test in `build` to make cleanup fast and easy 8 | "cwd": "${workspaceFolder}/build", 9 | // Replace this with your project root. If there are multiple, you can 10 | // automatically run the currently visible file with: "program": ${file}" 11 | "program": "${workspaceFolder}/src/cli/cli.ts", 12 | // "args": ["--no-install"], 13 | "outFiles": ["${workspaceFolder}/build/main/**/*.js"], 14 | "skipFiles": [ 15 | "/**/*.js", 16 | "${workspaceFolder}/node_modules/**/*.js" 17 | ], 18 | "preLaunchTask": "npm: build", 19 | "stopOnEntry": true, 20 | "smartStep": true, 21 | "runtimeArgs": ["--nolazy"], 22 | "env": { 23 | "TYPESCRIPT_STARTER_REPO_URL": "${workspaceFolder}" 24 | }, 25 | "console": "externalTerminal" 26 | }, 27 | { 28 | "type": "node", 29 | "request": "launch", 30 | "name": "Debug Spec", 31 | "program": "${workspaceRoot}/.vscode/debug-ts.js", 32 | "args": ["${file}"], 33 | "skipFiles": ["/**/*.js"], 34 | // Consider using `npm run watch` or `yarn watch` for faster debugging 35 | // "preLaunchTask": "npm: build", 36 | // "smartStep": true, 37 | "runtimeArgs": ["--nolazy"] 38 | }] 39 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # https://circleci.com/docs/2.0/language-javascript/ 2 | version: 2 3 | jobs: 4 | 'node-10': 5 | docker: 6 | - image: circleci/node:10 7 | working_directory: ~/typescript-starter 8 | steps: 9 | - checkout 10 | # Download and cache dependencies 11 | - restore_cache: 12 | keys: 13 | - v1-dependencies-{{ checksum "package.json" }} 14 | # fallback to using the latest cache if no exact match is found 15 | - v1-dependencies- 16 | - run: npm install 17 | - save_cache: 18 | paths: 19 | - node_modules 20 | key: v1-dependencies-{{ checksum "package.json" }} 21 | - run: npm test 22 | - run: npx nyc report --reporter=lcov | npx codecov 23 | - run: npm run cov:check 24 | 'node-latest': 25 | docker: 26 | - image: circleci/node:latest 27 | working_directory: ~/typescript-starter 28 | steps: 29 | - checkout 30 | - restore_cache: 31 | keys: 32 | - v1-dependencies-{{ checksum "package.json" }} 33 | - v1-dependencies- 34 | - run: npm install 35 | - save_cache: 36 | paths: 37 | - node_modules 38 | key: v1-dependencies-{{ checksum "package.json" }} 39 | - run: npm test 40 | - run: npx nyc report --reporter=lcov | npx codecov 41 | - run: npm run cov:check 42 | 43 | workflows: 44 | version: 2 45 | build: 46 | jobs: 47 | - 'node-10' 48 | - 'node-latest' 49 | -------------------------------------------------------------------------------- /src/strings/BotFather.txt: -------------------------------------------------------------------------------- 1 | start - Start bot 2 | login - Login Wechat 3 | current - Show current contact 4 | lock - Lock current contact 5 | unlock - Unlock current contact 6 | uptime - Get bot uptime 7 | groupon - Receive group messages 8 | groupoff - Stop receiving group messages 9 | officialon - Receive official account messages 10 | officialoff - Stop receiving official account messages 11 | selfon - Receive self messages 12 | selfoff - Stop receiving self messages 13 | find - Find a contact and set as current contact [/find name] 14 | findandlock - Find and lock contact [/find name] 15 | agree - Agree with current friendship request [/agree name] 16 | disagree - Disagree with current friendship request [/disagree name] 17 | acceptroom - Accept chatroom invitation 18 | logout - Logout Wechat 19 | soundonly - Only audio messages 20 | help - Show this help page 21 | version - Show bot version 22 | 23 | 24 | 25 | 26 | login - 请求登录 27 | current - 显示当前联系人 28 | lock - 锁定当前联系人 29 | unlock - 取消锁定当前联系人 30 | forwardto - 转发微信消息给联系人 [/forward (昵称|备注。若不指定,则转发给当前联系人)] 31 | mute - 静音指定群 32 | soundonly - 仅允许某群语音消息 33 | nameonly - 仅允许某群成员消息 34 | unmute - 启用指定群消息 [/unmute 群名称。若不指定,则全部启用] 35 | selfon - 开启接收自己的消息 36 | selfoff - 关闭接收自己的消息 37 | uptime - 查看 Bot 运行时长 38 | groupon - 开启接收群消息 39 | groupoff - 关闭接收群消息 40 | officialon - 开启接收公众号消息 41 | officialoff - 关闭接收公众号消息 42 | find - 查找并设置为当前联系人(支持模糊查找) [/find 昵称|备注] 43 | findandlock - 查找并锁定为当前联系人(支持模糊查找) [/find 昵称|备注] 44 | agree - 同意好友请求 [/agree reqid] 45 | disagree - 忽略好友请求 [/disagree reqid] 46 | acceptroom - 接受群邀请 47 | logout - 登出WeChat 48 | help - 显示帮助 49 | start - 启动会话 50 | version - 输出版本号 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import Bot, { BotOptions } from './Bot'; 4 | 5 | import { Command } from 'commander'; 6 | import Logger from './lib/Logger'; 7 | import fs from 'fs'; 8 | import inquirer from 'inquirer'; 9 | 10 | // Called directly 11 | if (require.main === module) { 12 | let program = new Command() 13 | .option('-c, --config [path]', 'Configruation File Path', String) 14 | .parse(process.argv); 15 | 16 | let bot: Bot; 17 | 18 | const opts = program.opts(); 19 | 20 | if (opts.config) { 21 | let json = fs.readFileSync(opts.config, { 22 | encoding: 'utf8', 23 | }); 24 | let config = JSON.parse(json); 25 | bot = new Bot(config); 26 | bot.launch(); 27 | } else { 28 | (async () => { 29 | try { 30 | let { token } = (await inquirer.prompt({ 31 | name: 'token', 32 | message: 'Bot Token:', 33 | type: 'input', 34 | })) as { token: string }; 35 | bot = new Bot({ token: token.trim() }); 36 | bot.launch(); 37 | } catch (error) { 38 | Logger.error(error.message); 39 | setTimeout(() => process.exit(1), 1000); 40 | } 41 | })(); 42 | } 43 | 44 | const exit = process.exit; 45 | const hookExit: any = async (code?: number) => { 46 | await bot.sendSystemMessage(`Bot is stopping. Error code:${code}`); 47 | return exit(code); 48 | }; 49 | 50 | process.exit = hookExit; 51 | 52 | //catches uncaught exceptions 53 | process.on('uncaughtException', bot.handleFatalError); 54 | process.on('unhandledRejection', bot.handleFatalError); 55 | 56 | process.title = 'leavexchat'; 57 | } 58 | 59 | export { Bot, Logger, BotOptions }; 60 | -------------------------------------------------------------------------------- /src/lib/MiscHelper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | import { Contact, Room } from 'wechaty'; 4 | 5 | import got from 'got'; 6 | import path from 'path'; 7 | import tmpDir from 'temp-dir'; 8 | import touch from 'touch'; 9 | 10 | export default class MiscHelper { 11 | static async fileExists(path: string) { 12 | try { 13 | await fs.promises.stat(path); 14 | return true; 15 | } catch (error) { 16 | return false; 17 | } 18 | } 19 | 20 | static async download(url: string, path: string) { 21 | return new Promise(async (resolve) => { 22 | if (await this.fileExists(path)) resolve(true); 23 | got.stream(url).pipe(fs.createWriteStream(path)).end(resolve(true)); 24 | }); 25 | } 26 | 27 | static async deleteFile(path: string) { 28 | return new Promise((resolve) => fs.unlink(path, (_) => resolve())); 29 | } 30 | 31 | static async createTmpFile(filename: string) { 32 | const filepath = path.join(tmpDir, filename); 33 | 34 | try { 35 | await touch(filepath); 36 | } catch (error) {} 37 | } 38 | 39 | static async listTmpFile(startsWith: string) { 40 | return new Promise((resolve) => { 41 | fs.readdir(tmpDir, (err, files) => { 42 | if (err) { 43 | resolve([]); 44 | return; 45 | } 46 | 47 | resolve(files.filter((f) => f.startsWith(startsWith))); 48 | }); 49 | }); 50 | } 51 | 52 | static async deleteTmpFile(filename: string) { 53 | const filepath = path.join(tmpDir, filename); 54 | await this.deleteFile(filepath); 55 | } 56 | 57 | static async getFriendlyName(contact: Contact | Room) { 58 | return contact['name']?.() || (await contact['topic']?.()); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/bot/HandleFindX.ts: -------------------------------------------------------------------------------- 1 | import { Contact, Room, Wechaty } from 'wechaty'; 2 | 3 | import Bot from '../Bot'; 4 | import { Client } from '../Bot'; 5 | import { Context } from 'telegraf/typings/context'; 6 | import Logger from '../lib/Logger'; 7 | import { Message } from 'telegraf/typings/core/types/typegram'; 8 | import lang from '../strings'; 9 | import { writeFile } from './UpdateTmpFile'; 10 | 11 | export default async (self: Bot, ctx: Context, next: Function) => { 12 | let contents = (ctx.message as Message.TextMessage).text.split(' '); 13 | contents.shift(); 14 | 15 | let name = contents.reduce((p, c) => `${p} ${c}`, '').trim(); 16 | if (!name) { 17 | ctx.reply(lang.commands.find); 18 | return; 19 | } 20 | 21 | name = name.trim(); 22 | 23 | let user = ctx['user'] as Client; 24 | const { found, foundName } = await findContact(name, user.wechat); 25 | 26 | if (!found) { 27 | ctx.reply(lang.message.contactNotFound); 28 | return; 29 | } 30 | 31 | let info = user.contactLocked 32 | ? ` [${lang.message.contactLocked('').trim()}]` 33 | : ''; 34 | 35 | let sent = await ctx.reply(lang.message.contactFound(`${foundName}`) + info); 36 | 37 | user.currentContact = found; 38 | 39 | user.msgs.set(sent.message_id, { contact: found, wxmsg: undefined }); 40 | 41 | const wname = 42 | (found as Contact)['name']?.() || (await (found as Room)['topic']?.()); 43 | 44 | await writeFile(`${self.id}${ctx?.chat?.id}`, { 45 | recentContact: { name: wname, locked: user.contactLocked }, 46 | }); 47 | 48 | if (next) next(); 49 | }; 50 | 51 | export async function findContact(query: string, wechat: Wechaty) { 52 | const regexp = new RegExp(query, 'ig'); 53 | 54 | let found: Contact | Room | undefined; 55 | let foundName = ''; 56 | try { 57 | found = 58 | (await wechat?.Contact.find({ alias: regexp })) || 59 | (await wechat?.Contact.find({ name: regexp })); 60 | 61 | const alias = await found?.alias(); 62 | foundName = alias ? `${found?.name()} (${alias})` : found?.name(); 63 | } catch (error) { 64 | Logger.error(error.message); 65 | return { found, foundName }; 66 | } 67 | 68 | if (!found) { 69 | found = await wechat?.Room.find({ topic: regexp }); 70 | foundName = await found?.topic(); 71 | } 72 | 73 | if (!found) { 74 | return { found, foundName }; 75 | } 76 | 77 | return { found, foundName }; 78 | } 79 | -------------------------------------------------------------------------------- /src/strings/zh_CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | welcome: '欢迎使用', 3 | nowelcome: '不欢迎使用', 4 | login: { 5 | request: '正在请求 WeChat 登录二维码,请稍等', 6 | logined: (name: string) => `${name} 已经登录`, 7 | logouted: (name: string) => `${name} 已登出`, 8 | retry: '请扫描二维码', 9 | bye: 'Bye', 10 | sessionOK: '[会话恢复 🥳 消息引用已被重置]', 11 | sessionLost: '无法恢复微信会话,请重新登录 /login', 12 | }, 13 | message: { 14 | redpacket: '发送了一个红包', 15 | money: '向你转了一笔账', 16 | noQuoteMessage: '请先引用一条微信消息', 17 | msgForward: (name: string) => `消息已转发给: ${name}`, 18 | contactNotFound: '未找到联系人', 19 | contactFound: (name: string) => `${name} 已是当前联系人`, 20 | contactLocked: (name: string) => `${name} 已锁定`, 21 | contactUnlocked: (name: string) => `${name} 已取消锁定`, 22 | noCurrentContact: '该消息无对应联系人', 23 | current: (name: string) => `当前联系人 ${name}`, 24 | notSupportedMsg: '向你发送了一条 Bot 不支持的消息', 25 | timeout: '登录超时,Bye', 26 | error: 'WeChat 遇到错误,请重试', 27 | inviteRoom: (inviter: string, room: string) => 28 | `${inviter} 邀请你加入: ${room}`, 29 | trySendingFile: '文件发送失败,Bot 尝试重发......', 30 | sendingSucceed: (receipt?: string) => 31 | `发送成功 🥳 ${receipt ? `[To: ${receipt}]` : ''}`, 32 | sendingFileFailed: '发送文件失败,墙太高了 🧱', 33 | msgNotSupported: '不支持发送该类型消息', 34 | muteRoom: (room: string) => `${room} 已静音`, 35 | soundOnlyRoom: (room: string) => `${room} 仅声音模式`, 36 | nameOnly: (user: string) => `仅 ${user} 模式`, 37 | unmuteRoom: (room?: string | string[]) => 38 | `${room ? `${room} ` : '全部消息'}已启用`, 39 | }, 40 | contact: { 41 | card: '联系人卡片', 42 | friend: '新好友申请', 43 | nickname: '昵称', 44 | gender: '性别', 45 | city: '城市', 46 | province: '省份', 47 | wechatid: '微信号', 48 | applying: '申请消息', 49 | 1: '男', 50 | 2: '女', 51 | 0: '未知', 52 | }, 53 | commands: { 54 | find: '/find 昵称|备注', 55 | agree: '/agree 名称', 56 | disagree: '/disagreee 名称', 57 | }, 58 | help: `命令说明: 59 | /start - 启动会话 60 | /login - 请求登录 61 | /logout - 登出WeChat 62 | /groupon - 开启接收群消息 63 | /groupoff - 关闭接收群消息 64 | /officialon - 开启接收公众号消息 65 | /officialoff - 关闭接收公众号消息 66 | /selfon - 开启接收自己的消息 67 | /selfoff - 关闭接收自己的消息 68 | /find - 查找并设置为当前联系人 [/find 昵称|备注] 69 | /lock - 锁定当前联系人 70 | /unlock - 取消锁定当前联系人 71 | /findandlock - 查找并锁定为当前联系人 [/find 昵称|备注] 72 | /current - 显示当前联系人 73 | /agree - 同意好友请求 [/agree reqid] 74 | /disagree - 忽略好友请求 [/disagree reqid] 75 | /acceptroom - 接受群邀请 76 | /help - 显示帮助`, 77 | }; 78 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2017", 5 | "outDir": "build/main", 6 | "rootDir": "src", 7 | "moduleResolution": "node", 8 | "module": "CommonJS", 9 | "declaration": true, 10 | "inlineSourceMap": true, 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 12 | "resolveJsonModule": true /* Include modules imported with .json extension. */, 13 | "skipDefaultLibCheck": true, 14 | // "strict": true /* Enable all strict type-checking options. */, 15 | 16 | /* Strict Type-Checking Options */ 17 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 18 | // "strictNullChecks": true /* Enable strict null checks. */, 19 | // "strictFunctionTypes": true /* Enable strict checking of function types. */, 20 | // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 21 | // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 22 | // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 23 | 24 | /* Additional Checks */ 25 | "noUnusedLocals": false /* Report errors on unused locals. */, 26 | "noUnusedParameters": false /* Report errors on unused parameters. */, 27 | "noImplicitReturns": false /* Report error when not all code paths in function return a value. */, 28 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 29 | 30 | /* Debugging Options */ 31 | "traceResolution": false /* Report module resolution log messages. */, 32 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 33 | "listFiles": false /* Print names of files part of the compilation. */, 34 | "pretty": true /* Stylize errors and messages using color and context. */, 35 | 36 | /* Experimental Options */ 37 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 38 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 39 | 40 | "lib": ["es2017"], 41 | "types": ["node"], 42 | "typeRoots": ["node_modules/@types", "src/types"] 43 | }, 44 | "include": ["src/**/*.ts"], 45 | "exclude": ["node_modules/**"], 46 | "compileOnSave": false 47 | } 48 | -------------------------------------------------------------------------------- /src/strings/en_US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | welcome: `Welcome, I'm a wechat message transferring bot.`, 3 | login: { 4 | request: `I'm requesting Wechat Login QRCode for you, please wait a moment`, 5 | logined: (name: string) => `Congratulations! ${name} has logined`, 6 | logouted: (name: string) => `${name} has logouted`, 7 | retry: `Please scan the QRCode and try again`, 8 | bye: 'Bye', 9 | sessionOK: 'Your last wechat session has been recovered. 📨', 10 | sessionLost: `Last wechat session can't be recoverd. You have to /login again.` 11 | }, 12 | message: { 13 | redpacket: 'A red packet', 14 | money: 'Transferred some money to you', 15 | noQuoteMessage: 'Please quote a wechat message at first.', 16 | msgForward: (name: string) => `The quote message has been forwarded to: ${name}`, 17 | contactNotFound: 'Contact not found', 18 | contactFound: (name: string) => `${name} is current contact`, 19 | contactLocked: (name: string) => `${name} is locked`, 20 | contactUnlocked: (name: string) => `${name} is unlocked`, 21 | noCurrentContact: `No current contact`, 22 | current: (name: string) => `Current contact: ${name}`, 23 | notSupportedMsg: 'Sent you a not supported message', 24 | timeout: 'Login timeout, bye', 25 | error: 'Some errors happen, try again please', 26 | inviteRoom: (inviter: string, room: string) => `${inviter} invites you to join: ${room}`, 27 | trySendingFile: `Sending file failed, bot is trying to resend...`, 28 | sendingSucceed: `This message has been sent successfully 🥳`, 29 | sendingFileFailed: 'Sending file failed.', 30 | msgNotSupported: 'This msg type is not supported.', 31 | muteRoom: (room: string) => `${room} is muted🔇.`, 32 | unmuteRoom: (room?: string | string[]) => `${room ? room + ' ' : 'All groups '}enabled` 33 | }, 34 | contact: { 35 | card: 'Contact Card', 36 | friend: 'Friend recommendation', 37 | nickname: 'Nickname', 38 | gender: 'Gender', 39 | city: 'City', 40 | province: 'Province', 41 | wechatid: 'Wechat ID', 42 | applying: 'Applying', 43 | 1: 'Male', 44 | 2: 'Female', 45 | 0: 'Unknown' 46 | }, 47 | commands: { 48 | find: '/find name|alias', 49 | agree: '/agree name', 50 | disagree: '/disagreee name' 51 | }, 52 | help: `Command reference: 53 | /start - Start bot 54 | /login - Login Wechat 55 | /logout - Logout Wechat 56 | /groupon - Receive group messages 57 | /groupoff - Stop receiving group messages 58 | /officialon - Receive official account messages 59 | /officialoff - Stop receiving official account messages 60 | /selfon - Receive self messages 61 | /selfoff - Stop receiving self messages 62 | /texton - Just text message (default) 63 | /textoff - Show you rich-type message 64 | /find - Find a contact and set as current contact (Case sensitive) [/find name] 65 | /lock - Lock current contact 66 | /unlock - Unlock current contact 67 | /findandlock - Find and lock contact (Case sensitive) [/find name] 68 | /current - Show current contact 69 | /agree - Agree with current friendship request 70 | /disagree - Disagree with current friendship request 71 | /help - Show this help page` 72 | }; 73 | -------------------------------------------------------------------------------- /src/bot/HandleTelegramMessage.ts: -------------------------------------------------------------------------------- 1 | import * as TT from 'telegraf/typings/telegram-types'; 2 | 3 | import { Contact, Room } from 'wechaty'; 4 | import { Message, UserFromGetMe } from 'telegraf/typings/core/types/typegram'; 5 | 6 | import { BotOptions } from '../Bot'; 7 | import { Client } from '../Bot'; 8 | import { Context } from 'telegraf/typings/context'; 9 | import { FileBox } from 'file-box'; 10 | import { HttpsProxyAgent } from 'https-proxy-agent'; 11 | import Logger from '../lib/Logger'; 12 | import MiscHelper from '../lib/MiscHelper'; 13 | import { Telegraf } from 'telegraf'; 14 | import axios from 'axios'; 15 | import ce from 'command-exists'; 16 | import download from 'download'; 17 | import ffmpeg from 'fluent-ffmpeg'; 18 | import fs from 'fs'; 19 | import lang from '../strings'; 20 | import path from 'path'; 21 | import sharp from 'sharp'; 22 | import tempfile from 'tempfile'; 23 | 24 | interface IHandleTelegramMessage extends BotOptions { 25 | bot: UserFromGetMe; 26 | } 27 | 28 | export default async ( 29 | ctx: Context, 30 | { token, httpProxy, bot }: IHandleTelegramMessage 31 | ) => { 32 | let msg = ctx.message as Message; 33 | let user = ctx['user'] as Client; 34 | 35 | if ((msg as Message.TextMessage).text?.startsWith('/find')) { 36 | return; 37 | } 38 | 39 | let contact = user.currentContact; 40 | if ((msg as Message.TextMessage).reply_to_message) { 41 | contact = user.msgs.get( 42 | (msg as Message.TextMessage).reply_to_message.message_id 43 | )?.contact; 44 | } 45 | 46 | if (!contact) { 47 | ctx.reply(lang.message.noCurrentContact); 48 | return; 49 | } 50 | 51 | let file = 52 | (msg as Message.AudioMessage).audio || 53 | (msg as Message.VideoMessage).video || 54 | (msg as Message.PhotoMessage).photo?.[0] || 55 | (msg as Message.VoiceMessage).voice || 56 | (msg as Message.DocumentMessage).document || 57 | (msg as Message.StickerMessage).sticker; 58 | if (file && file.file_size <= 50 * 1024 * 1024) { 59 | let tries = 3; 60 | 61 | do { 62 | tries--; 63 | 64 | try { 65 | let url = `https://api.telegram.org/bot${token}/getFile?file_id=${file.file_id}`; 66 | let httpsAgent = httpProxy 67 | ? new HttpsProxyAgent(`http://${httpProxy.host}:${httpProxy.port}`) 68 | : undefined; 69 | 70 | const proxyAxios = axios.create({ 71 | proxy: false, 72 | httpsAgent, 73 | httpAgent: httpsAgent, 74 | timeout: 10 * 1000, 75 | }); 76 | let resp = await proxyAxios.get(url); 77 | if (!resp.data || !resp.data.ok) return; 78 | 79 | let filePath = resp.data.result.file_path; 80 | url = `https://api.telegram.org/file/bot${token}/${filePath}`; 81 | let ext = path.extname(filePath); 82 | let distFile = tempfile(ext); 83 | if (ext === '.tgs') { 84 | await ctx.reply(lang.message.msgNotSupported); 85 | return; 86 | } 87 | 88 | await new Promise(async (resolve) => 89 | fs.writeFile(distFile, await download(url), () => resolve()) 90 | ); 91 | 92 | if ((msg as Message.StickerMessage).sticker) { 93 | const pngfile = tempfile('.png'); 94 | await sharp(distFile).toFormat('png').toFile(pngfile); 95 | 96 | distFile = pngfile; 97 | } 98 | 99 | if ((msg as Message.VoiceMessage).voice && ce.sync('ffmpeg')) { 100 | const outputFile = tempfile('.mp3'); 101 | 102 | await new Promise((resolve) => { 103 | ffmpeg(distFile) 104 | .toFormat('mp3') 105 | .saveToFile(outputFile) 106 | .on('end', () => resolve()); 107 | }); 108 | 109 | distFile = outputFile; 110 | } 111 | 112 | await contact.say(FileBox.fromFile(distFile)); 113 | if ( 114 | (msg as Message.CaptionableMessage).caption && 115 | (msg as Message.TextMessage).forward_from?.id !== bot.id 116 | ) 117 | await contact.say((msg as Message.CaptionableMessage).caption); 118 | 119 | const name = 120 | (contact as Contact)['name']?.() || 121 | (await (contact as Room)['topic']?.()) || 122 | ''; 123 | 124 | await ctx.reply(lang.message.sendingSucceed(name), { 125 | reply_to_message_id: msg.message_id, 126 | }); 127 | 128 | if (!user.contactLocked) user.currentContact = contact; 129 | 130 | MiscHelper.deleteFile(distFile); 131 | return; 132 | } catch (error) { 133 | if (tries > 0) continue; 134 | 135 | await ctx.reply(lang.message.sendingFileFailed, { 136 | reply_to_message_id: msg.message_id, 137 | }); 138 | Logger.error(error.message); 139 | } 140 | } while (tries > 0); 141 | } 142 | 143 | if ((msg as Message.TextMessage).text) { 144 | await contact.say((msg as Message.TextMessage).text); 145 | } 146 | 147 | if (!user.contactLocked) user.currentContact = contact; 148 | }; 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WallChat 2 | 3 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-blue.svg)](https://github.com/Wechaty/wechaty) 4 | 5 | 使用 Telegram Bot 收发 WeChat 文字、语音、图片、视频、Telegram 静态贴纸等消息 6 | 7 | ## 安装准备 8 | 9 | 1. 安装 Node.js 12+ 官网: https://nodejs.org 10 | 2. 访问 https://t.me/BotFather, 申请你的 `bot token` 11 | 3. 安装 ffmpeg (可选,将 Telegram 语音(oga 文件)转换成 mp3 发送给微信) 12 | 13 | ## 快速开始 14 | 15 | Linux 使用前需要安装如下依赖,新版 wechaty 只支持Ubuntu: 16 | 17 | ~~CentOS 7~~ 18 | 19 | ``` 20 | yum install libX11 pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y 21 | ``` 22 | 23 | ~~CentOS 8~~ 24 | 25 | ``` 26 | dnf install -y libX11-xcb libXtst libXScrnSaver alsa-lib-devel at-spi2-atk gtk3 27 | 28 | alsa-lib.x86_64 atk.x86_64 cups-libs.x86_64 gtk3.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXrandr.x86_64 pango.x86_64 xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-fonts-cyrillic xorg-x11-fonts-misc xorg-x11-fonts-Type1 xorg-x11-utils 29 | 30 | // http://www.ajisaba.net/javascript/puppeteer/lib_error_centos7.html 31 | ``` 32 | 33 | 34 | Ubuntu 35 | 36 | ``` 37 | apt-get update && \ 38 | apt-get install -yq --no-install-recommends \ 39 | libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \ 40 | libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \ 41 | libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ 42 | libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \ 43 | libnss3 libgbm-dev libxshmfence-dev 44 | ``` 45 | 46 | **`libgbm-dev libxshmfence-dev` 是 2.5.0 版本新需要的依赖** 47 | 48 | ## 自行编译 49 | 50 | ```bash 51 | $> git clone https://github.com/UnsignedInt8/leavexchat-bot.git 52 | $> cd leavexchat-bot 53 | $> yarn 54 | $> yarn build 55 | $> node build/main/index.js # 无需配置文件 56 | $> 输入 token, Done! 57 | ``` 58 | 59 | 由于中国用户无法直接访问 Telegram,所以需要在配置文件 `config.json` 中指定 SOCKS5 代理: 60 | 61 | `config.json` 请参照 [config-example.json](./config-example.json) 填写。 62 | 63 | ```bash 64 | # 使用配置文件方式 65 | $> node build/main/index.js -c config.json 66 | ``` 67 | 68 | ** 如果安装遇到问题,清空 node_modules,再重新安装所有依赖 ** 69 | 70 | ## 作者的用法 71 | 72 | 2.0 版本已经加入了 wechat **会话恢复**功能。要发挥该特性,就需要进程守护,推荐使用 forever 73 | 74 | ```bash 75 | $> npm i -g forever 76 | 77 | $> git clone https://github.com/UnsignedInt8/leavexchat-bot.git 78 | $> cd leavexchat-bot 79 | $> yarn 80 | $> yarn build 81 | $> forever build/main/index.js -c config.json 82 | ``` 83 | 84 | 这样可以大幅降低扫码登录的频次 85 | 86 | ## Bot 命令 87 | 88 | | 命令 | 说明 | 示例 | 89 | | ------------ | ---------------------------- | -------------------------------------- | 90 | | /start | 启动会话 | | 91 | | /login | 请求登录 | | 92 | | /logout | 登出 WeChat | | 93 | | /groupon | 开启接收群消息 | | 94 | | /groupoff | 关闭接收群消息 | | 95 | | /officialon | 开启接收公众号消息 | | 96 | | /officialoff | 关闭接收公众号消息 | | 97 | | /selfon | 开启接收自己的消息 | | 98 | | /selfoff | 关闭接收自己的消息 | | 99 | | /find | 查找联系人并设置为当前联系人 | /find ABC | 100 | | /lock | 锁定当前联系人 | | 101 | | /unlock | 取消锁定当前联系人 | | 102 | | /findandlock | 查找并锁定为当前联系人 | /findandlock ABC | 103 | | /current | 显示当前联系人 | | 104 | | /agree | 同意好友请求 | /agree [reqid] | 105 | | /disagree | 忽略好友请求 | /disagree [reqid] | 106 | | /forwardto | 转发消息给联系人 | /forwardto [联系人] | 107 | | /mute | 静音指定群 | 先引用一条群消息, 再 /mute | 108 | | /unmute | 启用指定群消息 | /unmute 群名[可不填,则启用全部群消息] | 109 | | /help | 显示帮助 | | 110 | 111 | 除了 `/find` 和 `/findandlock` 必须带有要查找的联系人名字,其它命令均可无参数 112 | 113 | ## 使用注意 114 | 115 | 1. ~~根据 Wechaty 说明,2017 年 6 月之后注册的 Wechat 账号无法登陆网页版 Wechat,因此无法使用此 bot 代收消息~~ 已经支持所有wechat账号登陆 116 | 117 | 2. 为保证安全,bot 只会在自己的聊天记录保留最近 **200** 条消息 (默认 200) 118 | 119 | 3. 直接在 Telegram 里回复消息的对象**默认**是最近收到消息的发送者(个人或群),如果担心回复错了,请手动指定回复某条消息(最近 200 条以内)。可以手动 /lock /unlock 锁定当前联系人 120 | 121 | 4. 2.1.0 以上版本已经支持发送图片、视频、文档,但不支持发送可被 Wechat 自动识别为音频的消息 122 | 123 | 5. 如果使用 VPS,WeChat 会检测到异地登陆,并发出提示。可以在本地运行该 bot,只需在配置文件里填写好 socks5, http 代理信息即可 124 | 125 | ## Telegram Bot 快捷命令支持 126 | 127 | 命令说明在[此处](./src/strings/BotFather.txt),粘贴到 BotFather 中即可启用 Telegram Bot 输入框提示 128 | 129 | ## License 130 | 131 | MPL-2.0 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wallchat", 3 | "version": "3.1.1", 4 | "description": "wallchat bot", 5 | "main": "build/main/index.js", 6 | "typings": "build/main/index.d.ts", 7 | "module": "build/module/index.js", 8 | "repository": "https://github.com/UnsignedInt8/wallchat", 9 | "author": "UnsignedInt8", 10 | "license": "MPL-2.0", 11 | "keywords": [ 12 | "telegram", 13 | "wechat", 14 | "bot" 15 | ], 16 | "scripts": { 17 | "build": "scripts/wechaty && run-p build:*", 18 | "build:main": "tsc -p tsconfig.json", 19 | "build:module": "tsc -p tsconfig.module.json", 20 | "fix": "run-s fix:*", 21 | "fix:prettier": "prettier \"src/**/*.ts\" --write", 22 | "fix:lint": "eslint src --ext .ts --fix", 23 | "test": "run-s build test:*", 24 | "test:lint": "eslint src --ext .ts", 25 | "test:prettier": "prettier \"src/**/*.ts\" --list-different", 26 | "test:spelling": "cspell \"{README.md,.github/*.md,src/**/*.ts}\"", 27 | "test:unit": "nyc --silent ava", 28 | "check-cli": "run-s test diff-integration-tests check-integration-tests", 29 | "check-integration-tests": "run-s check-integration-test:*", 30 | "diff-integration-tests": "mkdir -p diff && rm -rf diff/test && cp -r test diff/test && rm -rf diff/test/test-*/.git && cd diff && git init --quiet && git add -A && git commit --quiet --no-verify --allow-empty -m 'WIP' && echo '\\n\\nCommitted most recent integration test output in the \"diff\" directory. Review the changes with \"cd diff && git diff HEAD\" or your preferred git diff viewer.'", 31 | "watch:build": "tsc -p tsconfig.json -w", 32 | "watch:test": "nyc --silent ava --watch", 33 | "cov": "run-s build test:unit cov:html cov:lcov && open-cli coverage/index.html", 34 | "cov:html": "nyc report --reporter=html", 35 | "cov:lcov": "nyc report --reporter=lcov", 36 | "cov:send": "run-s cov:lcov && codecov", 37 | "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", 38 | "doc": "run-s doc:html && open-cli build/docs/index.html", 39 | "doc:html": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --out build/docs", 40 | "doc:json": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --json build/docs/typedoc.json", 41 | "doc:publish": "gh-pages -m \"[ci skip] Updates\" -d build/docs", 42 | "version": "standard-version", 43 | "reset-hard": "git clean -dfx && git reset --hard && yarn", 44 | "prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish" 45 | }, 46 | "engines": { 47 | "node": ">=10" 48 | }, 49 | "dependencies": { 50 | "@postlight/mercury-parser": "^2.2.1", 51 | "@types/command-exists": "^1.2.0", 52 | "@types/download": "^8.0.2", 53 | "@types/end-of-stream": "^1.4.1", 54 | "@types/fluent-ffmpeg": "^2.1.21", 55 | "@types/got": "^9.6.12", 56 | "@types/html-entities": "^1.3.4", 57 | "@types/inquirer": "^8.2.1", 58 | "@types/node": "^18.14.2", 59 | "@types/qr-image": "^3.2.5", 60 | "@types/sharp": "^0.29.5", 61 | "@types/touch": "^3.1.2", 62 | "axios": "^0.27.2", 63 | "brolog": "^1.14.2", 64 | "command-exists": "^1.2.9", 65 | "commander": "^9.4.1", 66 | "dayjs": "^1.11.7", 67 | "download": "^8.0.0", 68 | "fast-xml-parser": "^4.0.12", 69 | "file-box": "^1.4.15", 70 | "fluent-ffmpeg": "^2.1.2", 71 | "got": "^11.8.5", 72 | "h2m": "^0.7.0", 73 | "html-entities": "^2.3.3", 74 | "https-proxy-agent": "^5.0.1", 75 | "inquirer": "^8.2.4", 76 | "is-gif": "3.0.0", 77 | "ix": "^4.5.2", 78 | "marked": "^4.2.12", 79 | "node-html-parser": "^5.4.2", 80 | "qr-image": "^3.2.0", 81 | "sharp": "^0.29.3", 82 | "socks-proxy-agent": "^6.2.0", 83 | "telegraf": "^4.11.2", 84 | "temp-dir": "^2.0.0", 85 | "tempfile": "^3.0.0", 86 | "touch": "^3.1.0", 87 | "wechaty": "^1.20.2", 88 | "wechaty-puppet-wechat": "^1.18.4" 89 | }, 90 | "devDependencies": { 91 | "@ava/typescript": "^3.0.1", 92 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 93 | "@typescript-eslint/eslint-plugin": "^5.53.0", 94 | "@typescript-eslint/parser": "^5.53.0", 95 | "ava": "^5.2.0", 96 | "codecov": "^3.5.0", 97 | "cspell": "^6.27.0", 98 | "cz-conventional-changelog": "^3.3.0", 99 | "eslint": "^8.35.0", 100 | "eslint-config-prettier": "^8.6.0", 101 | "eslint-plugin-eslint-comments": "^3.2.0", 102 | "eslint-plugin-functional": "^4.4.1", 103 | "eslint-plugin-import": "^2.27.5", 104 | "npm-run-all": "^4.1.5", 105 | "nyc": "^15.1.0", 106 | "open-cli": "^7.0.1", 107 | "prettier": "^2.8.4", 108 | "standard-version": "^9.5.0", 109 | "ts-node": "^10.9.1", 110 | "typescript": "^4.9.5" 111 | }, 112 | "files": [ 113 | "build/main", 114 | "build/module", 115 | "!**/*.spec.*", 116 | "!**/*.json", 117 | "CHANGELOG.md", 118 | "LICENSE", 119 | "README.md" 120 | ], 121 | "ava": { 122 | "failFast": true, 123 | "timeout": "60s", 124 | "typescript": { 125 | "rewritePaths": { 126 | "src/": "build/main/" 127 | } 128 | }, 129 | "files": [ 130 | "!build/module/**" 131 | ] 132 | }, 133 | "config": { 134 | "commitizen": { 135 | "path": "cz-conventional-changelog" 136 | } 137 | }, 138 | "prettier": { 139 | "singleQuote": true 140 | }, 141 | "nyc": { 142 | "extends": "@istanbuljs/nyc-config-typescript", 143 | "exclude": [ 144 | "**/*.spec.js" 145 | ] 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/bot/HandleWechatMessage.ts: -------------------------------------------------------------------------------- 1 | import * as TT from 'telegraf/typings/telegram-types'; 2 | import * as XMLParser from '../lib/XmlParser'; 3 | import * as fs from 'fs/promises'; 4 | 5 | import { Contact, Message } from 'wechaty'; 6 | import { 7 | Message as TTMessage, 8 | UserFromGetMe, 9 | } from 'telegraf/typings/core/types/typegram'; 10 | 11 | import Bot from '../Bot'; 12 | import { CommonMessageBundle } from 'telegraf/typings/core/types/typegram'; 13 | import { Context } from 'telegraf/typings/context'; 14 | import HTMLTemplates from '../lib/HTMLTemplates'; 15 | import Logger from '../lib/Logger'; 16 | import ce from 'command-exists'; 17 | import { decode } from 'html-entities'; 18 | import download from 'download'; 19 | import ffmpeg from 'fluent-ffmpeg'; 20 | import isGif from 'is-gif'; 21 | import lang from '../strings'; 22 | import tempfile from 'tempfile'; 23 | import { types } from 'wechaty-puppet'; 24 | // import prism from 'prism-media'; 25 | import { writeFile } from './UpdateTmpFile'; 26 | 27 | const { Contact: ContactType, Message: MessageType } = types; 28 | 29 | // const html = new AllHtmlEntities(); 30 | const banNotifications = [ 31 | '分享的二维码加入群聊', 32 | '加入了群聊', 33 | `" 拍了拍 "`, 34 | 'with anyone else in this group chat', 35 | 'joined the group chat via', 36 | 'to the group chat', 37 | ' tickled ', 38 | 'invited you to a group chat with', 39 | '邀请你加入了群聊,群聊参与人还有', 40 | '与群里其他人都不是微信朋友关系,请注意隐私安全', 41 | '你通过扫描二维码加入群聊,群聊参与人还有:', 42 | '" 拍了拍自己', 43 | '确认了一笔转账,当前微信版本不支持展示该内容', 44 | '向他人发起了一笔转账,当前微信版本不支持展示该内容', 45 | '与群里其他人都不是朋友关系,请注意隐私安全' 46 | ]; 47 | 48 | export default async (self: Bot, msg: Message, ctx: Context) => { 49 | let id = ctx?.chat?.id; 50 | let user = self.clients.get(id); 51 | 52 | let from = msg.talker(); 53 | let room = msg.room(); 54 | let topic = ''; 55 | 56 | let isRoomSoundOnly = false; 57 | 58 | if (room) { 59 | topic = await room.topic(); 60 | const isRoomMuted = user.muteList.includes(topic); 61 | 62 | isRoomSoundOnly = user.soundOnlyList.includes(topic); 63 | 64 | if (isRoomMuted) return; 65 | } 66 | 67 | let type = msg.type() as any; 68 | let text = msg 69 | .text() 70 | .replace(/\/g, ' \n') 71 | .replace(/<[^>]*>?/gm, ''); 72 | 73 | if (user.wechatId === from.id && !user.receiveSelf) return; 74 | if (!user.receiveOfficialAccount && from.type() === ContactType.Official) 75 | return; 76 | if (!user.receiveGroups && room) return; 77 | 78 | let alias = await from.alias(); 79 | 80 | let nickname = from.name() + (alias ? ` (${alias})` : ''); 81 | nickname = nickname + (room ? ` [${await room.topic()}]` : ''); 82 | 83 | if ( 84 | user.contactLocked && 85 | user.currentContact && 86 | alias === (await (user.currentContact as Contact)['alias']?.()) && 87 | from.name() === (user.currentContact as Contact)['name']?.() 88 | ) { 89 | nickname = `${nickname}[${lang.message.contactLocked('').trim()}]`; 90 | } 91 | 92 | let sent: CommonMessageBundle; 93 | 94 | if (nickname.includes('Friend recommendation message')) { 95 | await handleFriendApplyingXml(text, ctx); 96 | return; 97 | } 98 | 99 | if (room && isRoomSoundOnly && type !== MessageType.Audio) return; 100 | if ( 101 | room && 102 | user.nameOnlyList[topic]?.length > 0 && 103 | !user.nameOnlyList[topic]?.includes(from.name()) 104 | ) 105 | return; 106 | 107 | switch (type) { 108 | case MessageType.Text: 109 | if (!text) break; 110 | let isXml = text.startsWith(`<?xml version="1.0"?>`); 111 | 112 | if (isXml) { 113 | if (await handleContactXml(text, nickname, ctx)) break; 114 | } else if (room && banNotifications.some((n) => text.includes(n))) { 115 | // junk info 116 | break; 117 | } else if (room && text.includes('#接龙')) { 118 | text = text.substring(text.length - 100, text.length); 119 | text = text.length >= 99 ? `#接龙\n\n${text}` : text; 120 | 121 | sent = await ctx.replyWithHTML( 122 | HTMLTemplates.message({ nickname, message: text }) 123 | ); 124 | } else { 125 | sent = await ctx.replyWithHTML( 126 | HTMLTemplates.message({ nickname, message: text }) 127 | ); 128 | } 129 | break; 130 | 131 | case MessageType.Attachment: 132 | try { 133 | let xml = decode(msg.text()); 134 | let markdown = 135 | from.type() === ContactType.Official 136 | ? XMLParser.parseOffical(xml) 137 | : XMLParser.parseAttach(xml); 138 | sent = await ctx.replyWithMarkdown( 139 | HTMLTemplates.markdown({ nickname, content: markdown }) 140 | ); 141 | } catch (error) { 142 | Logger.error(error.message); 143 | } 144 | 145 | break; 146 | 147 | case MessageType.Contact: 148 | await handleContactXml(text, nickname, ctx); 149 | break; 150 | // case MessageType.RedEnvelope: 151 | // sent = await ctx.replyWithHTML(HTMLTemplates.message({ nickname, message: lang.message.redpacket })); 152 | // break; 153 | 154 | case MessageType.Audio: 155 | let audio = await msg.toFileBox(); 156 | let source = await audio.toBuffer(); 157 | let duration = source.byteLength / (2.95 * 1024); 158 | 159 | // let duration = (await audio.toBuffer()).byteLength / (2.95 * 1024); 160 | // let source = (await audio.toStream()).pipe(new prism.opus.Decoder()).pipe(new prism.opus.Encoder()); 161 | // let source = await audio.toBuffer(); 162 | sent = await ctx.replyWithVoice( 163 | { source }, 164 | { caption: nickname, duration } 165 | ); 166 | break; 167 | 168 | case MessageType.Image: 169 | let image = await msg.toFileBox(); 170 | 171 | const buffer = await image.toBuffer(); 172 | 173 | if (isGif(buffer)) { 174 | if (ce.sync('ffmpeg')) { 175 | const gifTmpPath = tempfile('.gif'); 176 | const gifMp4TmpPath = tempfile('.mp4'); 177 | 178 | await image.toFile(gifTmpPath, true); 179 | 180 | await new Promise((resolve) => { 181 | ffmpeg(gifTmpPath) 182 | .noAudio() 183 | .output(gifMp4TmpPath) 184 | .on('error', (e) => console.log(e)) 185 | .on('end', () => resolve()) 186 | .run(); 187 | }); 188 | 189 | sent = await ctx.replyWithVideo( 190 | { source: gifMp4TmpPath }, 191 | { caption: nickname } 192 | ); 193 | 194 | fs.unlink(gifTmpPath); 195 | fs.unlink(gifMp4TmpPath); 196 | } 197 | 198 | break; 199 | } 200 | 201 | sent = await ctx.replyWithPhoto( 202 | { source: await image.toStream() }, 203 | { caption: nickname } 204 | ); 205 | break; 206 | 207 | case MessageType.Video: 208 | let video = await msg.toFileBox(); 209 | sent = await ctx.replyWithVideo({ source: await video.toStream() }, { 210 | caption: nickname, 211 | } as any); 212 | break; 213 | 214 | default: 215 | if (!room) 216 | sent = await ctx.replyWithHTML( 217 | HTMLTemplates.message({ 218 | nickname, 219 | message: lang.message.notSupportedMsg, 220 | }) 221 | ); 222 | break; 223 | } 224 | 225 | if (!sent) { 226 | return; 227 | } 228 | 229 | if (!user.firstMsgId) user.firstMsgId = sent.message_id; 230 | user.msgs.set(sent.message_id, { contact: room || from, wxmsg: msg }); 231 | 232 | if (!user.contactLocked) { 233 | if (user.currentContact?.id !== (room || from).id) { 234 | await writeFile(`${self.id}${id}`, { 235 | recentContact: { name: room ? await room.topic() : from.name() }, 236 | }); 237 | } 238 | 239 | user.currentContact = room || from; 240 | } 241 | 242 | // The bot just knows recent messages 243 | if (sent.message_id < self.keepMsgs) return; 244 | let countToDelete = sent.message_id - self.keepMsgs; 245 | 246 | do { 247 | user.msgs.delete(countToDelete); 248 | countToDelete--; 249 | } while (countToDelete > 0); 250 | }; 251 | 252 | async function handleContactXml(text: string, from: string, ctx: Context) { 253 | try { 254 | const xml = decode(text); 255 | const c = XMLParser.parseContact(xml); 256 | if (!c.wechatid && !c.nickname && !c.headerUrl) return false; 257 | 258 | const caption = ` 259 | [${lang.contact.card}] 260 | 261 | ${lang.contact.nickname}: ${c.nickname} 262 | ${lang.contact.gender}: ${lang.contact[c.sex]} 263 | ${lang.contact.province}: ${c.province} 264 | ${lang.contact.city}: ${c.city} 265 | ${lang.contact.wechatid}: ${c.wechatid} 266 | ---------------------------- 267 | ${from}`; 268 | 269 | const header = await download(c.headerUrl); 270 | await ctx.replyWithPhoto({ source: header }, { caption }); 271 | 272 | return true; 273 | } catch (error) { 274 | Logger.error(error.message); 275 | } 276 | 277 | return false; 278 | } 279 | 280 | async function handleFriendApplyingXml(text: string, ctx: Context) { 281 | try { 282 | const xml = decode(text); 283 | const c = XMLParser.parseFriendApplying(xml); 284 | if (!c.applyingMsg) return false; 285 | 286 | const reply = ` 287 | [${lang.contact.card}] 288 | 289 | ${lang.contact.nickname}: ${c.nickname} 290 | ${lang.contact.gender}: ${lang.contact[c.sex]} 291 | ${lang.contact.applying}: ${c.applyingMsg} 292 | ${lang.contact.wechatid}: ${c.wechatid} 293 | ---------------------------- 294 | ${lang.contact.friend}`; 295 | 296 | const header = await download(c.headerUrl); 297 | await ctx.replyWithPhoto({ source: header }, { caption: reply }); 298 | 299 | return true; 300 | } catch (error) { 301 | Logger.error(error.message); 302 | } 303 | 304 | return false; 305 | } 306 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src/Bot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Contact, 3 | Friendship, 4 | Message, 5 | Room, 6 | RoomInvitation, 7 | Wechaty, 8 | WechatyBuilder, 9 | } from 'wechaty'; 10 | import { Context, Markup, Telegraf } from 'telegraf'; 11 | import { 12 | Message as TTMessage, 13 | Update as TTUpdate, 14 | UserFromGetMe, 15 | } from 'telegraf/typings/core/types/typegram'; 16 | import { 17 | handleCurrent, 18 | handleFind, 19 | handleForwardTo, 20 | handleLock, 21 | handleMute, 22 | handleNameOnly, 23 | handleSoundOnly, 24 | handleTelegramMessage, 25 | handleUnlock, 26 | handleUnmute, 27 | handleWechatMessage, 28 | } from './bot/index'; 29 | 30 | // import { Context } from 'telegraf/typings/context'; 31 | import HTMLTemplates from './lib/HTMLTemplates'; 32 | import { HttpsProxyAgent } from 'https-proxy-agent'; 33 | import Logger from './lib/Logger'; 34 | import MiscHelper from './lib/MiscHelper'; 35 | import { SocksProxyAgent } from 'socks-proxy-agent'; 36 | import crypto from 'crypto'; 37 | import dayjs from 'dayjs'; 38 | import { findContact } from './bot/HandleFindX'; 39 | import lang from './strings'; 40 | import qr from 'qr-image'; 41 | import { readFile } from './bot/UpdateTmpFile'; 42 | import relativeTime from 'dayjs/plugin/relativeTime'; 43 | 44 | const { version } = require('../../package.json'); 45 | 46 | dayjs.extend(relativeTime); 47 | 48 | export interface BotOptions { 49 | token: string; 50 | padplusToken?: string; 51 | allows?: number[]; 52 | socks5Proxy?: { 53 | host: string; 54 | port: number; 55 | username?: string; 56 | password?: string; 57 | }; 58 | httpProxy?: { 59 | host: string; 60 | port: number; 61 | }; 62 | keepMsgs?: number; 63 | silent?: boolean; 64 | } 65 | 66 | export interface Client { 67 | wechat?: Wechaty; 68 | receiveGroups?: boolean; 69 | receiveOfficialAccount?: boolean; 70 | receiveSelf?: boolean; 71 | msgs: Map; // telegram msgid => wx msg/contact 72 | currentContact?: Room | Contact; 73 | contactLocked?: boolean; 74 | wechatId?: string; // a flag to inidcate wechat client has logined 75 | initialized?: boolean; // a flag to indicate wechat event listeners have been hooked 76 | 77 | botId: string; 78 | firstMsgId?: any; 79 | muteList: string[]; 80 | soundOnlyList: string[]; 81 | nameOnlyList: { [group: string]: string[] }; 82 | } 83 | 84 | export default class Bot { 85 | clients: Map = new Map(); // chat id => client 86 | keepMsgs: number; 87 | options: BotOptions; 88 | 89 | protected bot: Telegraf; 90 | protected botSelf: UserFromGetMe; 91 | protected beforeCheckUserList: ((ctx?: Context) => Promise)[] = []; 92 | protected pendingFriends = new Map(); 93 | protected lastRoomInvitation: RoomInvitation = null; 94 | private recoverWechats = new Map(); // tg chatid => wechaty 95 | 96 | readonly id: string; 97 | readonly uptime = dayjs(); 98 | 99 | constructor(options: BotOptions) { 100 | this.options = options; 101 | 102 | const botid = crypto 103 | .createHash('sha256') 104 | .update(options.token || options.padplusToken) 105 | .digest() 106 | .toString('hex') 107 | .substring(0, 4); 108 | 109 | this.id = `leavexchat_${botid}.`; 110 | 111 | const { token, socks5Proxy, keepMsgs, httpProxy } = options; 112 | this.keepMsgs = 113 | keepMsgs === undefined ? 200 : Math.max(keepMsgs, 100) || 200; 114 | 115 | const socks5agent: any = socks5Proxy 116 | ? new SocksProxyAgent(`socks5://${socks5Proxy.host}:${socks5Proxy.port}`) 117 | : undefined; 118 | const agent: any = httpProxy 119 | ? new HttpsProxyAgent(`http://${httpProxy.host}:${httpProxy.port}`) 120 | : undefined; 121 | 122 | this.bot = new Telegraf(token, { 123 | telegram: { agent: agent || socks5agent }, 124 | }); 125 | 126 | const checkUser = (ctx: Context, n: Function) => this.checkUser(ctx, n); 127 | const replyOk = (ctx: Context) => ctx.reply('OK'); 128 | 129 | const turnGroup = (ctx: Context, n: Function, on: boolean) => { 130 | ctx['user'].receiveGroups = on; 131 | n(); 132 | }; 133 | 134 | const turnOfficial = (ctx: Context, n: Function, on: boolean) => { 135 | ctx['user'].receiveOfficialAccount = on; 136 | n(); 137 | }; 138 | 139 | const turnSelf = (ctx: Context, n: Function, on: boolean) => { 140 | ctx['user'].receiveSelf = on; 141 | n(); 142 | }; 143 | 144 | this.bot.start(this.handleStart); 145 | this.bot.command('version', (ctx) => ctx.reply(`Bot version: ${version}`)); 146 | this.bot.command('stop', checkUser, this.handleLogout); 147 | this.bot.command('login', (ctx) => this.handleLogin(ctx)); 148 | this.bot.command('shutdown', (_) => process.exit(0)); 149 | 150 | const turnGroupOn = (ctx: Context, n: Function) => turnGroup(ctx, n, true); 151 | this.bot.command('groupon', checkUser, turnGroupOn, replyOk); 152 | 153 | const turnGroupOff = (ctx: Context, n: Function) => 154 | turnGroup(ctx, n, false); 155 | this.bot.command('groupoff', checkUser, turnGroupOff, replyOk); 156 | 157 | const turnOfficialOn = (ctx: Context, n: Function) => 158 | turnOfficial(ctx, n, true); 159 | this.bot.command('officialon', checkUser, turnOfficialOn, replyOk); 160 | 161 | const turnOfficialOff = (ctx: Context, n: Function) => 162 | turnOfficial(ctx, n, false); 163 | this.bot.command('officialoff', checkUser, turnOfficialOff, replyOk); 164 | 165 | const turnSelfOn = (ctx: Context, n: Function) => turnSelf(ctx, n, true); 166 | this.bot.command('selfon', checkUser, turnSelfOn, replyOk); 167 | 168 | const turnSelfOff = (ctx: Context, n: Function) => turnSelf(ctx, n, false); 169 | this.bot.command('selfoff', checkUser, turnSelfOff, replyOk); 170 | 171 | const handleUpTime = (ctx: Context) => { 172 | ctx.replyWithHTML( 173 | `${this.uptime.toISOString()} [${dayjs().from( 174 | this.uptime, 175 | true 176 | )}]`, 177 | { 178 | reply_to_message_id: ctx['user'].firstMsgId, 179 | } 180 | ); 181 | }; 182 | this.bot.command('uptime', checkUser, handleUpTime); 183 | 184 | this.bot.command('find', checkUser, this.handleFind); 185 | this.bot.command('lock', checkUser, this.handleLock); 186 | this.bot.command('unlock', checkUser, this.handleUnlock); 187 | this.bot.command( 188 | 'findandlock', 189 | checkUser, 190 | this.handleFind, 191 | this.handleLock 192 | ); 193 | this.bot.command('current', this.handleCurrent); 194 | this.bot.command('agree', checkUser, this.handleAgreeFriendship); 195 | this.bot.command('disagree', checkUser, this.handleDisagreeFriendship); 196 | this.bot.command('acceptroom', checkUser); 197 | this.bot.command('forward', checkUser, this.handleForward); 198 | this.bot.command('forwardto', checkUser, this.handleForward); 199 | this.bot.command('mute', checkUser, this.handleMute); 200 | this.bot.command('soundonly', checkUser, this.handleSoundOnly); 201 | this.bot.command('nameonly', checkUser, this.handleNameOnly); 202 | this.bot.command('unmute', checkUser, this.handleUnmute); 203 | this.bot.command('quitroom', checkUser, this.handleQuitRoom); 204 | this.bot.command('logout', checkUser, this.handleLogout); 205 | this.bot.help((ctx) => ctx.reply(lang.help)); 206 | 207 | // this.bot.on('callback_query', checkUser, ctx => { 208 | // if (ctx.callbackQuery.data === 'agree') { 209 | // this.handleAgreeFriendship(ctx); 210 | // } else { 211 | // this.handleDisagreeFriendship(ctx); 212 | // } 213 | 214 | // ctx.answerCbQuery('', false); 215 | // }); 216 | 217 | // this.bot.on('inline_query', checkUser, ctx => { 218 | // const { inlineQuery } = ctx; 219 | 220 | // if (inlineQuery.query === 'agree') { 221 | // this.handleAgreeFriendship(ctx); 222 | // } else { 223 | // this.handleDisagreeFriendship(ctx); 224 | // } 225 | // }); 226 | 227 | // this.bot.action('agree', ctx => this.handleAgreeFriendship(ctx)); 228 | // this.bot.action('disagree', this.handleDisagreeFriendship); 229 | 230 | this.bot.catch((err) => { 231 | Logger.error('Ooops', err?.['message']); 232 | }); 233 | } 234 | 235 | handleFatalError = async (err: Error | number | NodeJS.Signals) => 236 | Logger.error(`Bot Alert: ${err}`); 237 | 238 | sendSystemMessage = async (msg: string) => { 239 | if (this.options.silent) return; 240 | 241 | const alert = HTMLTemplates.message({ 242 | nickname: '[Bot Alert]', 243 | message: msg, 244 | }); 245 | 246 | for (let [id, _] of this.clients) { 247 | await this.bot.telegram.sendMessage(id, alert, { parse_mode: 'HTML' }); 248 | } 249 | }; 250 | 251 | async launch() { 252 | this.bot.on( 253 | 'message', 254 | (ctx: Context, n: Function) => this.checkUser(ctx, n), 255 | this.handleTelegramMessage 256 | ); 257 | 258 | await this.bot.launch(); 259 | this.botSelf = await this.bot.telegram.getMe(); 260 | Logger.info('Bot is running'); 261 | 262 | await this.recoverSessions(); 263 | } 264 | 265 | async recoverSessions() { 266 | const files = await MiscHelper.listTmpFile(this.id); 267 | const ids = files 268 | .map((f) => f.split('.')[1]) 269 | .filter((s) => s) 270 | .map((s) => Number.parseInt(s)); 271 | 272 | Logger.info(`Recovering ${ids.length} sessions...`); 273 | 274 | await Promise.all( 275 | ids.map(async (chatid) => { 276 | const client = this.createClient(chatid); 277 | const { wechat } = client; 278 | 279 | wechat.once('login', async (user) => { 280 | client.wechatId = user.id; 281 | 282 | const ctx = new Context( 283 | { message: { chat: { id: chatid } } } as TTUpdate, 284 | this.bot.telegram, 285 | this.botSelf 286 | ); 287 | await this.handleLogin(ctx); 288 | 289 | const alert = `${lang.login.sessionOK}`; 290 | await this.bot.telegram.sendMessage(chatid, alert, { 291 | parse_mode: 'HTML', 292 | }); 293 | 294 | const lastDump = await readFile(`${this.id}${chatid}`); 295 | if (lastDump.recentContact?.name) { 296 | const { found, foundName } = await findContact( 297 | lastDump.recentContact.name, 298 | wechat 299 | ); 300 | client.currentContact = found; 301 | client.contactLocked = lastDump.recentContact.locked; 302 | 303 | if (found) { 304 | await ctx.reply( 305 | client.contactLocked 306 | ? lang.message.contactLocked(foundName) 307 | : lang.message.contactFound(foundName) 308 | ); 309 | } else { 310 | client.contactLocked = false; 311 | } 312 | } 313 | 314 | client.muteList = lastDump.muteList || []; 315 | client.soundOnlyList = lastDump.soundOnly || []; 316 | client.nameOnlyList = lastDump.namesOnly || {}; 317 | 318 | this.recoverWechats.delete(chatid); 319 | }); 320 | 321 | const deleteWechaty = async () => { 322 | // wechat?.removeAllListeners(); 323 | 324 | this.clients.delete(chatid); 325 | this.recoverWechats.delete(chatid); 326 | 327 | const alert = HTMLTemplates.message({ 328 | nickname: '[Bot Alert]', 329 | message: lang.login.sessionLost, 330 | }); 331 | 332 | await this.bot.telegram.sendMessage(chatid, alert, { 333 | parse_mode: 'HTML', 334 | }); 335 | 336 | await wechat?.stop(); 337 | await MiscHelper.deleteTmpFile(`${this.id}${chatid}`); 338 | }; 339 | 340 | wechat.once('scan', async (_) => await deleteWechaty()); 341 | wechat.once('error', async (_) => await deleteWechaty()); 342 | 343 | this.recoverWechats.set(chatid, wechat); 344 | await wechat.start(); 345 | }) 346 | ); 347 | } 348 | 349 | async exit() { 350 | for (let [_, client] of this.clients) { 351 | await client.wechat?.logout(); 352 | await client.wechat?.stop(); 353 | } 354 | } 355 | 356 | protected handleStart = async (ctx: Context) => { 357 | await ctx.reply(lang.welcome).catch(); 358 | await ctx.reply(lang.help); 359 | }; 360 | 361 | private createClient(chatid: number) { 362 | if (this.clients.has(chatid)) return this.clients.get(chatid); 363 | 364 | let wechat = 365 | this.recoverWechats.get(chatid) || 366 | WechatyBuilder.build({ 367 | name: `telegram_${chatid})}`, 368 | puppet: 'wechaty-puppet-wechat', 369 | }); 370 | 371 | let client: Client = { 372 | wechat, 373 | msgs: new Map(), 374 | receiveGroups: true, 375 | receiveOfficialAccount: true, 376 | muteList: [], 377 | soundOnlyList: [], 378 | nameOnlyList: {}, 379 | botId: this.id, 380 | }; 381 | 382 | this.clients.set(chatid, client); 383 | 384 | return client; 385 | } 386 | 387 | protected async handleLogin(ctx: Context) { 388 | for (let c of this.beforeCheckUserList) { 389 | if (!(await c(ctx))) return; 390 | } 391 | 392 | if (!this.options.allows?.includes(ctx.message.chat.id) ?? true) { 393 | return ctx.reply(lang.nowelcome); 394 | } 395 | 396 | const id = ctx?.chat?.id; 397 | let qrcodeCache = ''; 398 | if (this.clients.has(id) && this.clients.get(id)?.initialized) { 399 | let user = this.clients.get(id); 400 | if (user.wechatId) { 401 | ctx.reply(lang.login.logined(user.wechat.name?.() || '')); 402 | return; 403 | } 404 | 405 | ctx.reply(lang.login.retry); 406 | return; 407 | } 408 | 409 | const client = this.createClient(id); 410 | const { wechat } = client; 411 | // wechat.removeAllListeners(); // clear all listeners if it is a recovered session 412 | 413 | let loginTimer: NodeJS.Timeout; 414 | 415 | let qrMessage: TTMessage | void = undefined; 416 | 417 | const removeQRMessage = async () => { 418 | if (!qrMessage) return; 419 | await this.bot.telegram.deleteMessage(id, qrMessage.message_id); 420 | qrMessage = undefined; 421 | }; 422 | 423 | const deleteWechat = async ( 424 | { clean }: { clean: boolean } = { clean: true } 425 | ) => { 426 | this.clients.delete(id); 427 | // wechat?.removeAllListeners(); 428 | 429 | await wechat?.stop().catch(); 430 | await removeQRMessage(); 431 | if (clean) await MiscHelper.deleteTmpFile(`${this.id}${id}`); 432 | }; 433 | 434 | const handleQrcode = async (qrcode: string) => { 435 | if (qrcode === qrcodeCache) return; 436 | qrcodeCache = qrcode; 437 | 438 | if (client.wechatId) return; 439 | 440 | if (!loginTimer) { 441 | loginTimer = setTimeout(async () => { 442 | await deleteWechat(); 443 | ctx.reply(lang.message.timeout); 444 | await removeQRMessage(); 445 | }, 3 * 60 * 1000); 446 | } 447 | 448 | await removeQRMessage(); 449 | qrMessage = await ctx 450 | .replyWithPhoto({ source: qr.image(qrcode) }) 451 | .catch(() => deleteWechat()); 452 | }; 453 | 454 | wechat?.on('scan', handleQrcode); 455 | 456 | wechat?.once('login', async (user) => { 457 | this.clients.get(id).wechatId = user.id; 458 | await ctx.reply(lang.login.logined(user.name())); 459 | clearTimeout(loginTimer); 460 | wechat?.off('scan', handleQrcode); 461 | 462 | await removeQRMessage(); 463 | 464 | // create chat tmp id 465 | await MiscHelper.createTmpFile(`${this.id}${id}`); 466 | }); 467 | 468 | wechat?.on('friendship', async (req) => { 469 | let hello = req.hello(); 470 | let contact = req.contact(); 471 | let name = contact.name(); 472 | 473 | if (req.type() === 2) { 474 | let avatar = await (await contact.avatar()).toStream(); 475 | 476 | // const buttons = Markup.inlineKeyboard([Markup.callbackButton('Agree', 'agree'), Markup.callbackButton('Ignore', 'disagree')], { 477 | // columns: 2 478 | // }); 479 | 480 | await ctx.replyWithPhoto( 481 | { source: avatar }, 482 | { 483 | caption: `[${lang.contact.friend}]\n\n${hello}`, 484 | parse_mode: 'MarkdownV2', 485 | reply_markup: undefined, 486 | } 487 | ); 488 | 489 | this.pendingFriends.set(name.toLowerCase(), req); 490 | } 491 | }); 492 | 493 | wechat?.on('room-invite', async (invitation) => { 494 | let inviter = (await invitation.inviter()).name(); 495 | let topic = await invitation.topic(); 496 | 497 | await ctx.reply(`${lang.message.inviteRoom(inviter, topic)} /acceptroom`); 498 | }); 499 | 500 | wechat?.on('logout', async (user) => { 501 | await deleteWechat(); 502 | await ctx.reply(lang.login.logouted(user.name())); 503 | }); 504 | 505 | wechat?.on('error', async (error) => { 506 | Logger.warn(error.message); 507 | await ctx.reply(lang.message.error); 508 | // await deleteWechat({ clean: false }); 509 | // await this.handleLogin(ctx); 510 | process.exit(0); 511 | }); 512 | 513 | wechat?.on('message', (msg) => this.handleWechatMessage(msg, ctx)); 514 | 515 | client.initialized = true; 516 | 517 | // check whether the user has logined 518 | if (client.wechatId) return; 519 | 520 | await ctx.reply(lang.login.request); 521 | await wechat?.start(); 522 | } 523 | 524 | protected async checkUser(ctx: Context, next: Function) { 525 | for (let c of this.beforeCheckUserList) { 526 | if (!(await c(ctx))) return; 527 | } 528 | 529 | if (!ctx) return next ? next() : undefined; 530 | 531 | let id = ctx?.chat?.id; 532 | let user = this.clients?.get(id); 533 | if (!user) return; 534 | 535 | ctx['user'] = user; 536 | next(ctx); 537 | } 538 | 539 | protected handleLogout = async (ctx: Context) => { 540 | let user = ctx['user'] as Client; 541 | if (!user) return; 542 | 543 | try { 544 | this.clients.delete(ctx?.chat?.id); 545 | user.wechat?.reset(); 546 | } catch (error) {} 547 | 548 | await user.wechat?.logout().catch((reason) => Logger.error(reason)); 549 | await user.wechat?.stop().catch((reason) => Logger.error(reason)); 550 | ctx.reply(lang.login.bye); 551 | }; 552 | 553 | handleQuitRoom = async (ctx: Context) => { 554 | let user = ctx['user'] as Client; 555 | if (!user) return; 556 | 557 | let room = user.currentContact as Room; 558 | let topic = await room['topic']?.(); 559 | await room['quit']?.(); 560 | ctx.reply(`${topic} 👋`); 561 | }; 562 | 563 | protected handleAcceptRoomInvitation = async () => { 564 | this.lastRoomInvitation?.accept(); 565 | this.lastRoomInvitation = null; 566 | }; 567 | 568 | protected handleAgreeFriendship = async (ctx: Context) => { 569 | for (let [key, req] of this.pendingFriends) { 570 | await req.accept(); 571 | } 572 | 573 | this.pendingFriends.clear(); 574 | 575 | // let [, id] = ctx.message?.text?.split(' '); 576 | 577 | // if (!id) { 578 | // await ctx.reply(lang.commands.agree); 579 | // return; 580 | // } 581 | 582 | // let req = this.pendingFriends.get(id.toLowerCase()); 583 | // await req?.accept(); 584 | // this.pendingFriends.delete(id.toLowerCase()); 585 | }; 586 | 587 | protected handleDisagreeFriendship = async (ctx: Context) => { 588 | this.pendingFriends.clear(); 589 | 590 | // let [, id] = ctx.message?.text?.split(' '); 591 | 592 | // if (!id) { 593 | // await ctx.reply(lang.commands.disagree); 594 | // return; 595 | // } 596 | 597 | // this.pendingFriends.delete(id.toLowerCase()); 598 | await ctx.reply('OK'); 599 | }; 600 | 601 | protected handleFind = (ctx: Context, next: Function) => 602 | handleFind(this, ctx, next); 603 | protected handleLock = (ctx: Context) => handleLock(ctx); 604 | protected handleUnlock = (ctx: Context) => handleUnlock(ctx); 605 | protected handleMute = (ctx: Context) => handleMute(ctx); 606 | protected handleSoundOnly = (ctx: Context) => handleSoundOnly(ctx); 607 | protected handleNameOnly = (ctx: Context) => handleNameOnly(ctx); 608 | protected handleUnmute = (ctx: Context) => handleUnmute(ctx); 609 | protected handleCurrent = (ctx: Context) => handleCurrent(ctx); 610 | protected handleForward = handleForwardTo; 611 | protected handleTelegramMessage = (ctx: Context) => 612 | handleTelegramMessage(ctx, { ...this.options, bot: this.botSelf }); 613 | protected handleWechatMessage = (msg: Message, ctx: Context) => 614 | handleWechatMessage(this, msg, ctx); 615 | } 616 | -------------------------------------------------------------------------------- /src/lib/XmlParser.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | 3 | import Mercury from '@postlight/mercury-parser'; 4 | import { XMLParser } from 'fast-xml-parser'; 5 | import h2m from 'h2m'; 6 | import marked from 'marked'; 7 | 8 | function replaceMarkdownChars(txt: string | object) { 9 | if (!txt) return ''; 10 | let title = (typeof txt === 'string' ? txt : txt['#text']) || ''; 11 | return title.replace(/\[/g, '').replace(/\]/g, ''); 12 | } 13 | 14 | const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }); 15 | export function parseOffical(rawXml: string): string { 16 | try { 17 | const msg = xml.parse(rawXml); 18 | const items = msg['msg']?.['appmsg']?.['mmreader']?.['category']?.[ 19 | 'item' 20 | ] as Array; 21 | return items.reduce((prev, curr) => { 22 | let title = h2m(replaceMarkdownChars(curr['title'])); 23 | let url = (curr['url'] as string).replace('xtrack=1', 'xtrack=0'); 24 | 25 | return `${prev}[${title}](${url})\n\n`; 26 | }, ''); 27 | } catch (err) { 28 | return parseAttach(rawXml); 29 | } 30 | } 31 | 32 | export function parseAttach(rawXml: string) { 33 | const msg = xml.parse(rawXml.replace(/\/g, '\n')); 34 | const appmsg = msg['msg']?.['appmsg'] || ''; 35 | const title = h2m(replaceMarkdownChars(appmsg?.['title'])); 36 | const desc = h2m(replaceMarkdownChars(appmsg?.['des'])); 37 | const url = appmsg?.['url'] || ''; 38 | 39 | return `[${title}](${url})\n${desc}`; 40 | } 41 | 42 | export function parseContact(rawXml: string) { 43 | const msg = xml.parse(rawXml); 44 | const content = msg['msg']; 45 | const headerUrl: string = 46 | content['bigheadimgurl'] || content['smallheadimgurl']; 47 | const nickname: string = content['nickname']; 48 | const province: string = content['province'] || ''; 49 | const city: string = content['city'] || ''; 50 | const wechatid: string = content['alias'] || content['username']; 51 | const imagestatus = Number.parseInt(content['imagestatus']); 52 | const sex = Number.parseInt(content['sex']); 53 | 54 | return { headerUrl, nickname, province, city, wechatid, imagestatus, sex }; 55 | } 56 | 57 | export function parseFriendApplying(rawXml: string) { 58 | const msg = xml.parse(rawXml); 59 | const content = msg['msg']; 60 | const nickname: string = content['fromnickname']; 61 | const applyingMsg: string = content['content']; 62 | const wechatid: string = content['alias'] || content['username']; 63 | const headerUrl: string = 64 | content['bigheadimgurl'] || content['smallheadimgurl']; 65 | const sex = Number.parseInt(content['sex']); 66 | const sign: string = content['sign']; 67 | 68 | return { nickname, applyingMsg, wechatid, headerUrl, sex, sign }; 69 | } 70 | 71 | export async function convertXmlToTelegraphMarkdown( 72 | rawXml: string, 73 | token: string = '4a1c7c544a7f2e9c146240e92ad4dc9e2e14e3e8a0ec01665ddbc80fbba3' 74 | ) { 75 | const msg = xml.parse(rawXml); 76 | const items = msg['msg']?.['appmsg']?.['mmreader']?.['category'][ 77 | 'item' 78 | ] as Array; 79 | const urls = items.map(async (curr) => { 80 | // let title = h2m(replaceMarkdownChars(curr['title'])) 81 | let url = (curr['url'] as string).replace('xtrack=1', 'xtrack=0'); 82 | 83 | const { title, content, excerpt } = (await Mercury.parse(url, { 84 | contentType: 'markdown', 85 | })) as { 86 | title: string; 87 | content: string; 88 | excerpt: string; 89 | }; 90 | 91 | const html = marked(content, {}); 92 | const root = parse(html, {}) as HTMLElement; 93 | 94 | const convert = (n: HTMLElement) => { 95 | if (n.childNodes.length === 0) return; 96 | convert(n); 97 | }; 98 | root.childNodes.map((c) => { 99 | const n = c as HTMLElement; 100 | return { 101 | tag: n.tagName, 102 | }; 103 | }); 104 | 105 | return { title, url }; 106 | }); 107 | } 108 | 109 | function testOffical() { 110 | const offical = `

<![CDATA[出差别踩这些红线!官方解读来了]]>


5
1

0



0









0
0




0
<![CDATA[出差别踩这些红线!官方解读来了]]>



1564468739



509963306







0
0
0


0
0




920674350779580416


2
0
0
0



0
<![CDATA[8月起,这些新规将影响你我生活→]]>



1564468739



509963298







0
0
0


0
0




920674351517777922


2
0
0
0



0
<![CDATA[边充电边玩手机,真的会炸吗?是时候科普一下了]]>



1564468739



509963256







0
0
0


0
0




920674352205643776


2
0
0
0









0







1







`; 111 | console.log(parseOffical(offical)); 112 | } 113 | 114 | function testAttach() { 115 | const att = `


靠印钞支撑的复兴梦,是如何崩掉的?
作死的!

5
0
0




0
http://mp.weixin.qq.com/s?__biz=MzAxNzczMTY2Ng==&mid=2648625685&idx=1&sn=1650bc329f928bd5b14d43bbca4cb65b&chksm=83cb6b28b4bce23e7b002f67760f679144c1f822a4ff6f8d7e3eecbe48fdee6d3b2e6fec2984&scene=0&xtrack=1#rd






0








财主家的余粮
https://mmbiz.qpic.cn/mmbiz_jpg/r4Em8wOBKDicia3Chia2gKTHWPBmENdibPVLYG5wLxn5GhiaYj3gQVCWb26GYCSodWAzP9QpCe7f0wnM02lFNMSqcgA/300?wxtype=jpeg&wxfrom=0



0
0
1564394943
0
0
0

0



0

1




`; 116 | console.log(parseAttach(att)); 117 | } 118 | 119 | function testPic() { 120 | const pic = `


20190804 行情分析<br/>(今天有点事情简更一下<img class="emoji emoji1f64f" text="_web" src="/zh_CN/htmledition/v2/images/spacer.gif" />🏻)
20190804 行情分析
(今天有点事情简更一下🏻)


5
0
0




0
http://mp.weixin.qq.com/s?__biz=MzU1NzIxOTE2Mw==&mid=2247486208&idx=1&sn=6d760535050133defe1f9de17d3e38d2&chksm=fc386734cb4fee221d5ec93da95757e1fbe354a0d698df4f98d53b8097f14950138a05018ba2&scene=0&xtrack=1#rd






0








果酱之链
https://mmbiz.qpic.cn/mmbiz_jpg/TT9VgDtkqIUEUr41ToT7CPSiaoD1H3O4BJStZoIibhxl4Pn4rxA6ias0LbGq92ktmvN9cFP1GaDMNCaoYsBGgN8uw/640?wxfrom=0



8
0
1564912371
0
0
0

0



0

1




`; 121 | console.log(parseAttach(pic)); 122 | } 123 | 124 | function testBr() { 125 | const br = `


纤云弄巧,飞星传恨,银汉迢迢暗度。<br/>金风玉露一相逢,便胜却、人间无数。<br/>柔情似水,佳期如梦,忍顾鹊桥归路。<br/>两情若是久长时,又岂在、朝朝暮暮。<br/><br/>百度AI交互设计院<img class="emoji emoji3297" text="_web" src="/zh_CN/htmledition/v2/images/spacer.gif" />️天下有情人终成眷属, 借此良辰吉日诚邀各路英雄好汉体验百度AI小程序,附赠七夕心意一份,希望幸运的您可以被选中。
纤云弄巧,飞星传恨,银汉迢迢暗度。
金风玉露一相逢,便胜却、人间无数。
柔情似水,佳期如梦,忍顾鹊桥归路。
两情若是久长时,又岂在、朝朝暮暮。

百度AI交互设计院️天下有情人终成眷属, 借此良辰吉日诚邀各路英雄好汉体验百度AI小程序,附赠七夕心意一份,希望幸运的您可以被选中。


5
0
0




0
http://mp.weixin.qq.com/s?__biz=MzU0OTUzMjUwOA==&mid=2247486316&idx=1&sn=9ad9437503c9d17b21f114fb7300a042&chksm=fbaf2d5fccd8a4490c596ad20ac666f39ac0ec2810cc9c94d9e831ee782612894d9253719ef5&scene=0&xtrack=1#rd






0








百度AI交互设计院
https://mmbiz.qpic.cn/mmbiz_jpg/iacQlDibO9CB5aBiaVw3opHv7RlzEgtAhhL35hN5gIdia3JncfQ2R8Xv4eFKnlmibhiczebWB3MV0SmUU373Vu2cWicGA/640?wxfrom=0



8
0
1565162314
0
0
0

0



0

1




`; 126 | console.log(parseAttach(br)); 127 | } 128 | 129 | function testContact() { 130 | const xml = ``; 131 | 132 | console.log(parseContact(xml)); 133 | } 134 | 135 | function testFriendApplying() { 136 | const xml = ``; 137 | const xml2 = ``; 138 | console.log(parseFriendApplying(xml)); 139 | console.log(parseFriendApplying(xml2)); 140 | } 141 | 142 | // testContact(); 143 | // testFriendApplying(); 144 | // testOffical(); 145 | // testAttach(); 146 | // testPic(); 147 | // testBr(); 148 | --------------------------------------------------------------------------------