├── 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 | [](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 = `
5
1
0
0
0
0
0
1564468739
509963306
0
0
0
0
0
920674350779580416
2
0
0
0
0
1564468739
509963298
0
0
0
0
0
920674351517777922
2
0
0
0
0
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 行情分析
(今天有点事情简更一下
🏻)
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 = `
纤云弄巧,飞星传恨,银汉迢迢暗度。
金风玉露一相逢,便胜却、人间无数。
柔情似水,佳期如梦,忍顾鹊桥归路。
两情若是久长时,又岂在、朝朝暮暮。
百度AI交互设计院
️天下有情人终成眷属, 借此良辰吉日诚邀各路英雄好汉体验百度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 |
--------------------------------------------------------------------------------