├── .node-version ├── .npmrc ├── pnpm-workspace.yaml ├── ui ├── src │ ├── env.d.ts │ ├── main.ts │ ├── Index.tsx │ └── App.tsx ├── vite.config.ts ├── index.html ├── package.json └── tsconfig.json ├── main ├── assets │ ├── tgs │ │ ├── tgs0.tgs │ │ ├── tgs1.tgs │ │ ├── tgs2.tgs │ │ ├── tgs3.tgs │ │ ├── tgs4.tgs │ │ └── tgs5.tgs │ └── richHeader.ejs ├── src │ ├── constants │ │ ├── exts.ts │ │ ├── regExps.ts │ │ ├── lottie.ts │ │ ├── flags.ts │ │ ├── emoji.ts │ │ └── commands.ts │ ├── models │ │ ├── db.ts │ │ ├── env.ts │ │ ├── Pair.ts │ │ ├── ForwardPairs.ts │ │ ├── TelegramSession.ts │ │ └── Instance.ts │ ├── types │ │ └── definitions.d.ts │ ├── helpers │ │ ├── dataPath.ts │ │ ├── CallbackQueryHelper.ts │ │ ├── setupHelper.ts │ │ ├── WaitForMessageHelper.ts │ │ ├── convert.ts │ │ └── forwardHelper.ts │ ├── utils │ │ ├── arrays.ts │ │ ├── hashing.ts │ │ ├── getAboutText.ts │ │ ├── flagControl.ts │ │ ├── urls.ts │ │ ├── highLevelFunces.ts │ │ ├── random.ts │ │ ├── paginatedInlineSelector.ts │ │ └── inlineDigitInput.ts │ ├── encoding │ │ ├── tgsToGif.ts │ │ ├── convertWithFfmpeg.ts │ │ └── silk.ts │ ├── controllers │ │ ├── OicqErrorNotifyController.ts │ │ ├── StatusReportController.ts │ │ ├── MiraiSkipFilterController.ts │ │ ├── AliveCheckController.ts │ │ ├── InstanceManageController.ts │ │ ├── DeleteMessageController.ts │ │ ├── RequestController.ts │ │ ├── FileAndFlashPhotoController.ts │ │ ├── InChatCommandsController.ts │ │ ├── SetupController.ts │ │ ├── ConfigController.ts │ │ ├── HugController.ts │ │ ├── ForwardController.ts │ │ └── QuotLyController.ts │ ├── index.ts │ ├── client │ │ ├── TelegramImportSession.ts │ │ ├── TelegramChat.ts │ │ ├── OicqClient.ts │ │ └── Telegram.ts │ ├── api │ │ ├── index.ts │ │ ├── richHeader.ts │ │ └── telegramAvatar.ts │ └── services │ │ ├── SetupService.ts │ │ ├── DeleteMessageService.ts │ │ ├── InChatCommandsService.ts │ │ └── ConfigService.ts ├── tsconfig.json ├── package.json └── prisma │ └── schema.prisma ├── .idea ├── .gitignore ├── codeStyles │ └── codeStyleConfig.xml ├── vcs.xml ├── discord.xml ├── modules.xml └── Q2TG.iml ├── .dockerignore ├── .vscode └── settings.json ├── package.json ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── docker-compose.yaml ├── README_backup.md ├── patches └── @icqqjs__icqq@1.2.0.patch ├── Dockerfile └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 18.18.2 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @icqqjs:registry=https://npm.pkg.github.com 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - main 3 | - ui 4 | -------------------------------------------------------------------------------- /ui/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /main/assets/tgs/tgs0.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nofated095/Q2TG/HEAD/main/assets/tgs/tgs0.tgs -------------------------------------------------------------------------------- /main/assets/tgs/tgs1.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nofated095/Q2TG/HEAD/main/assets/tgs/tgs1.tgs -------------------------------------------------------------------------------- /main/assets/tgs/tgs2.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nofated095/Q2TG/HEAD/main/assets/tgs/tgs2.tgs -------------------------------------------------------------------------------- /main/assets/tgs/tgs3.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nofated095/Q2TG/HEAD/main/assets/tgs/tgs3.tgs -------------------------------------------------------------------------------- /main/assets/tgs/tgs4.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nofated095/Q2TG/HEAD/main/assets/tgs/tgs4.tgs -------------------------------------------------------------------------------- /main/assets/tgs/tgs5.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nofated095/Q2TG/HEAD/main/assets/tgs/tgs5.tgs -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 默认忽略的文件 2 | /shelf/ 3 | /workspace.xml 4 | # 基于编辑器的 HTTP 客户端请求 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /main/src/constants/exts.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | images: ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'] 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist 6 | build 7 | data 8 | .env 9 | .github 10 | -------------------------------------------------------------------------------- /main/src/constants/regExps.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | qq: /^[1-9]\d{4,10}$/, 3 | roomId: /^-?[1-9]\d{4,10}$/, 4 | }; 5 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App'; 3 | 4 | createApp(App) 5 | .mount('#app'); 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "github-actions.workflows.pinned.workflows": [ 3 | ".github/workflows/main.yml" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /main/src/models/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const db = new PrismaClient(); 4 | 5 | export default db; 6 | -------------------------------------------------------------------------------- /ui/src/Index.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue'; 2 | 3 | export default defineComponent({ 4 | render() { 5 | return
nya!
; 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /main/src/types/definitions.d.ts: -------------------------------------------------------------------------------- 1 | import { MessageRet } from '@icqqjs/icqq'; 2 | 3 | export type WorkMode = 'group' | 'personal'; 4 | export type QQMessageSent = MessageRet & { senderId: number, brief: string }; 5 | -------------------------------------------------------------------------------- /main/src/helpers/dataPath.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import env from '../models/env'; 3 | 4 | // Wrap of path.join, add base DATA_DIR 5 | export default (...paths: string[]) => 6 | path.join(env.DATA_DIR, ...paths); 7 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /main/src/utils/arrays.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | pagination(arr: T[], pageSize: number, currentPage: number) { 3 | const skipNum = currentPage * pageSize; 4 | return (skipNum + pageSize >= arr.length) ? arr.slice(skipNum, arr.length) : arr.slice(skipNum, skipNum + pageSize); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vueJsx from '@vitejs/plugin-vue-jsx'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: '/ui/', 7 | plugins: [vueJsx()], 8 | resolve: { 9 | alias: { 10 | '@': '/src', 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ESNext", 5 | "esModuleInterop": true, 6 | "sourceMap": false, 7 | "moduleResolution": "node", 8 | "outDir": "build", 9 | "skipLibCheck" : true 10 | }, 11 | "exclude": [ 12 | "node_modules" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /main/src/encoding/tgsToGif.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import env from '../models/env'; 3 | 4 | export default function tgsToGif(tgsPath: string) { 5 | return new Promise(resolve => { 6 | spawn(env.TGS_TO_GIF, [tgsPath]).on('exit', () => { 7 | resolve(tgsPath + '.gif'); 8 | }); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /main/src/encoding/convertWithFfmpeg.ts: -------------------------------------------------------------------------------- 1 | import ffmpeg from 'fluent-ffmpeg'; 2 | 3 | export default function (sourcePath: string, targetPath: string, format: string){ 4 | return new Promise(resolve => { 5 | ffmpeg(sourcePath).toFormat(format).save(targetPath) 6 | .on('end', () => { 7 | resolve(); 8 | }) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Q2TG Web UI 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /main/src/constants/lottie.ts: -------------------------------------------------------------------------------- 1 | const TGS = ['打call', '流泪', '变形', '比心', '庆祝', '鞭炮']; 2 | export default { 3 | getTgsIndex(message: string) { 4 | const index1 = TGS.map(text => `[${text}]请使用最新版手机QQ体验新功能`).indexOf(message); 5 | if (index1 > -1) { 6 | return index1; 7 | } 8 | return TGS.map(text => `/${text}`).indexOf(message); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import {defineComponent} from 'vue'; 2 | import {dateZhCN, NConfigProvider, zhCN} from 'naive-ui'; 3 | import Index from "./Index"; 4 | 5 | export default defineComponent({ 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "q2tg", 3 | "scripts": { 4 | "dev": "pnpm run --stream --parallel dev", 5 | "build": "pnpm run --stream --parallel build" 6 | }, 7 | "devDependencies": { 8 | "typescript": "^5.4.5" 9 | }, 10 | "pnpm": { 11 | "patchedDependencies": { 12 | "@icqqjs/icqq@1.2.0": "patches/@icqqjs__icqq@1.2.0.patch" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "q2tg-webui", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "dev:expose": "vite --host", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@vitejs/plugin-vue-jsx": "^3.1.0", 13 | "naive-ui": "^2.38.2", 14 | "sass": "^1.76.0", 15 | "vite": "^5.2.11", 16 | "vue": "^3.4.26", 17 | "vue-tg": "^0.6.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.idea/Q2TG.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /main/src/constants/flags.ts: -------------------------------------------------------------------------------- 1 | enum flags { 2 | DISABLE_Q2TG = 1, 3 | DISABLE_TG2Q = 1 << 1, 4 | DISABLE_JOIN_NOTICE = 1 << 2, 5 | DISABLE_POKE = 1 << 3, 6 | NO_DELETE_MESSAGE = 1 << 4, 7 | NO_AUTO_CREATE_PM = 1 << 5, 8 | COLOR_EMOJI_PREFIX = 1 << 6, 9 | RICH_HEADER = 1 << 7, 10 | NO_QUOTE_PIN = 1 << 8, 11 | NO_FORWARD_OTHER_BOT = 1 << 9, 12 | USE_MARKDOWN = 1 << 10, 13 | DISABLE_SEAMLESS = 1 << 11, 14 | NO_FLASH_PIC = 1 << 12, 15 | DISABLE_SLASH_COMMAND = 1 << 13, 16 | } 17 | 18 | export default flags; 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /main/src/controllers/OicqErrorNotifyController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import OicqClient from '../client/OicqClient'; 3 | import { throttle } from '../utils/highLevelFunces'; 4 | 5 | export default class OicqErrorNotifyController { 6 | private sendMessage = throttle((message: string) => { 7 | return this.instance.ownerChat.sendMessage(message); 8 | }, 1000 * 60); 9 | 10 | public constructor(private readonly instance: Instance, 11 | private readonly oicq: OicqClient) { 12 | oicq.on('system.offline', async ({ message }) => { 13 | await this.sendMessage(`QQ 机器人掉线\n${message}`); 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "jsxFactory": "h", 10 | "jsxFragmentFactory": "Fragment", 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "lib": [ 15 | "esnext", 16 | "dom" 17 | ], 18 | "paths": { 19 | "@": [ 20 | "./src" 21 | ], 22 | "@/*": [ 23 | "./src/*" 24 | ] 25 | } 26 | }, 27 | "include": [ 28 | "src/**/*.ts", 29 | "src/**/*.d.ts", 30 | "src/**/*.tsx" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /main/src/helpers/CallbackQueryHelper.ts: -------------------------------------------------------------------------------- 1 | import { CallbackQueryEvent } from 'telegram/events/CallbackQuery'; 2 | 3 | export default class CallbackQueryHelper { 4 | private readonly queries: Array<(event: CallbackQueryEvent) => any> = []; 5 | 6 | public registerCallback(cb: (event: CallbackQueryEvent) => any) { 7 | const id = this.queries.push(cb) - 1; 8 | const buf = Buffer.alloc(2); 9 | buf.writeUInt16LE(id); 10 | return buf; 11 | } 12 | 13 | public onCallbackQuery = async (event: CallbackQueryEvent) => { 14 | const id = event.query.data.readUint16LE(); 15 | if (this.queries[id]) { 16 | this.queries[id](event); 17 | } 18 | try { 19 | await event.answer(); 20 | } 21 | catch { 22 | } 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /main/src/constants/emoji.ts: -------------------------------------------------------------------------------- 1 | import random from '../utils/random'; 2 | 3 | export default { 4 | picture: () => random.pick('🎆', '🌃', '🌇', '🎇', '🌌', '🌠', '🌅', '🌉', '🏞', '🌆', '🌄', '🖼', '🗾', '🎑', '🏙', '🌁'), 5 | color(index: number) { 6 | const arr = [...new Intl.Segmenter().segment('🔴🟠🟡🟢🔵🟣⚫️⚪️🟤')].map(x => x.segment); 7 | index = index % arr.length; 8 | return arr[index]; 9 | }, 10 | tgColor(index: number) { 11 | // https://github.com/telegramdesktop/tdesktop/blob/7049929a59176a996c4257d5a09df08b04ac3b22/Telegram/SourceFiles/ui/chat/chat_style.cpp#L1043 12 | // https://github.com/LyoSU/quote-api/blob/master/utils/quote-generate.js#L163 13 | const arr = [...new Intl.Segmenter().segment('❤️🧡💜💚🩵💙🩷')].map(x => x.segment); 14 | index = index % arr.length; 15 | return arr[index]; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /main/src/utils/hashing.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export function md5(input: crypto.BinaryLike) { 4 | const hash = crypto.createHash('md5'); 5 | return hash.update(input).digest(); 6 | } 7 | 8 | export function md5Hex(input: crypto.BinaryLike) { 9 | const hash = crypto.createHash('md5'); 10 | return hash.update(input).digest('hex'); 11 | } 12 | 13 | export function md5B64(input: crypto.BinaryLike) { 14 | const hash = crypto.createHash('md5'); 15 | return hash.update(input).digest('base64'); 16 | } 17 | 18 | export function sha256Hex(input: crypto.BinaryLike) { 19 | const hash = crypto.createHash('sha256'); 20 | return hash.update(input).digest('hex'); 21 | } 22 | 23 | export function sha256B64(input: crypto.BinaryLike) { 24 | const hash = crypto.createHash('sha256'); 25 | return hash.update(input).digest('base64'); 26 | } 27 | -------------------------------------------------------------------------------- /main/src/helpers/setupHelper.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from '@icqqjs/icqq'; 2 | 3 | export default { 4 | convertTextToPlatform(text: string): Platform { 5 | switch (text) { 6 | case '安卓手机': 7 | return Platform.Android; 8 | case '安卓平板': 9 | return Platform.aPad; 10 | case 'macOS': 11 | return Platform.iMac; 12 | case '安卓手表': 13 | return Platform.Watch; 14 | case 'iPad': 15 | default: 16 | return Platform.iPad; 17 | } 18 | }, 19 | convertTextToWorkMode(text: string) { 20 | switch (text) { 21 | case '个人模式': 22 | return 'personal'; 23 | case '群组模式': 24 | return 'group'; 25 | default: 26 | return ''; 27 | } 28 | }, 29 | checkSignApiAddress(signApi: string) { 30 | try { 31 | new URL(signApi); 32 | return signApi; 33 | } catch (err) { 34 | return ""; 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /main/src/controllers/StatusReportController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | 5 | export default class { 6 | constructor(private readonly instance: Instance, 7 | private readonly tgBot: Telegram, 8 | private readonly qqBot: OicqClient) { 9 | setInterval(() => this.report(), 1000 * 60); 10 | this.report(); 11 | } 12 | 13 | private async report() { 14 | if (!this.instance.reportUrl) return; 15 | let offline = [] as string[]; 16 | if (!this.tgBot?.isOnline) { 17 | offline.push('tgBot'); 18 | } 19 | if (!this.qqBot?.isOnline()) { 20 | offline.push('qqBot'); 21 | } 22 | const online = !offline.length; 23 | const url = new URL(this.instance.reportUrl); 24 | url.searchParams.set('status', online ? 'up' : 'down'); 25 | url.searchParams.set('msg', online ? 'OK' : offline.join(',')); 26 | const res = await fetch(url); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /main/src/helpers/WaitForMessageHelper.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import { BigInteger } from 'big-integer'; 3 | import { Api } from 'telegram'; 4 | 5 | export default class WaitForMessageHelper { 6 | // BugInteger 好像不能用 === 判断,Telegram 的 ID 还没有超过 number 7 | private map = new Map any>(); 8 | 9 | constructor(private tg: Telegram) { 10 | tg.addNewMessageEventHandler(async e => { 11 | if (!e.chat || !e.chat.id) return false; 12 | const handler = this.map.get(Number(e.chat.id)); 13 | if (handler) { 14 | this.map.delete(Number(e.chat.id)); 15 | handler(e); 16 | return true; 17 | } 18 | return false; 19 | }); 20 | } 21 | 22 | public waitForMessage(chatId: BigInteger | number) { 23 | return new Promise(resolve => { 24 | chatId = Number(chatId); 25 | this.map.set(chatId, resolve); 26 | }); 27 | } 28 | 29 | public cancel(chatId: BigInteger | number | string) { 30 | this.map.delete(Number(chatId)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /main/src/index.ts: -------------------------------------------------------------------------------- 1 | import { configure, getLogger } from 'log4js'; 2 | import Instance from './models/Instance'; 3 | import db from './models/db'; 4 | import api from './api'; 5 | import env from './models/env'; 6 | 7 | (async () => { 8 | configure({ 9 | appenders: { 10 | console: { type: 'console' }, 11 | }, 12 | categories: { 13 | default: { level: env.LOG_LEVEL, appenders: ['console'] }, 14 | }, 15 | }); 16 | const log = getLogger('Main'); 17 | 18 | if (!process.versions.node.startsWith('18.')) { 19 | log.warn('当前正在使用的 Node.JS 版本为', process.versions.node, ',未经测试'); 20 | } 21 | 22 | process.on('unhandledRejection', error => { 23 | log.error('UnhandledException: ', error); 24 | }); 25 | 26 | await api.startListening(); 27 | 28 | const instanceEntries = await db.instance.findMany(); 29 | 30 | if (!instanceEntries.length) { 31 | await Instance.start(0); 32 | } 33 | else { 34 | for (const instanceEntry of instanceEntries) { 35 | await Instance.start(instanceEntry.id); 36 | } 37 | } 38 | 39 | })(); 40 | -------------------------------------------------------------------------------- /main/src/client/TelegramImportSession.ts: -------------------------------------------------------------------------------- 1 | import TelegramChat from './TelegramChat'; 2 | import { BigInteger } from 'big-integer'; 3 | import { Api, TelegramClient } from 'telegram'; 4 | import { CustomFile } from 'telegram/client/uploads'; 5 | 6 | export class TelegramImportSession { 7 | constructor(public readonly chat: TelegramChat, 8 | private readonly client: TelegramClient, 9 | private readonly importId: BigInteger) { 10 | } 11 | 12 | public async uploadMedia(fileName: string, media: Api.TypeInputMedia) { 13 | return await this.client.invoke( 14 | new Api.messages.UploadImportedMedia({ 15 | peer: this.chat.entity, 16 | importId: this.importId, 17 | fileName, 18 | media, 19 | }), 20 | ); 21 | } 22 | 23 | public async finish() { 24 | return await this.client.invoke( 25 | new Api.messages.StartHistoryImport({ 26 | peer: this.chat.id, 27 | importId: this.importId, 28 | }), 29 | ); 30 | } 31 | 32 | public async uploadFile(file: CustomFile) { 33 | return await this.client.uploadFile({ file, workers: 2 }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /main/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from 'log4js'; 2 | import Fastify from 'fastify'; 3 | import FastifyProxy from '@fastify/http-proxy'; 4 | import FastifyStatic from '@fastify/static'; 5 | import env from '../models/env'; 6 | import richHeader from './richHeader'; 7 | import telegramAvatar from './telegramAvatar'; 8 | 9 | const log = getLogger('Web Api'); 10 | const fastify = Fastify(); 11 | 12 | fastify.get('/', async (request, reply) => { 13 | return { hello: 'Q2TG' }; 14 | }); 15 | 16 | fastify.register(richHeader, { prefix: '/richHeader' }); 17 | fastify.register(telegramAvatar, { prefix: '/telegramAvatar' }); 18 | 19 | if (env.UI_PROXY) { 20 | fastify.register(FastifyProxy, { 21 | upstream: env.UI_PROXY, 22 | prefix: '/ui', 23 | rewritePrefix: '/ui', 24 | websocket: true, 25 | }); 26 | } 27 | else if (env.UI_PATH) { 28 | fastify.register(FastifyStatic, { 29 | root: env.UI_PATH, 30 | prefix: '/ui', 31 | }); 32 | } 33 | 34 | export default { 35 | async startListening() { 36 | await fastify.listen({ port: env.LISTEN_PORT, host: '0.0.0.0' }); 37 | log.info('Listening on', env.LISTEN_PORT); 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /main/src/utils/getAboutText.ts: -------------------------------------------------------------------------------- 1 | import { Friend, Group } from '@icqqjs/icqq'; 2 | 3 | export default async function getAboutText(entity: Friend | Group, html: boolean) { 4 | let text: string; 5 | if (entity instanceof Friend) { 6 | text = `备注:${entity.remark}\n` + 7 | `昵称:${entity.nickname}\n` + 8 | `账号:${entity.user_id}`; 9 | } 10 | else { 11 | const owner = entity.pickMember(entity.info.owner_id); 12 | await owner.renew(); 13 | const self = entity.pickMember(entity.client.uin); 14 | await self.renew(); 15 | text = `群名称:${entity.name}\n` + 16 | `${entity.info.member_count} 名成员\n` + 17 | `群号:${entity.group_id}\n` + 18 | (self ? `我的群名片:${self.title ? `「${self.title}」` : ''}${self.card}\n` : '') + 19 | (owner ? `群主:${owner.title ? `「${owner.title}」` : ''}` + 20 | `${owner.card || owner.info.nickname} (${owner.user_id})` : '') + 21 | ((entity.is_admin || entity.is_owner) ? '\n可管理' : ''); 22 | } 23 | 24 | if (!html) { 25 | text = text.replace(/<\/?\w+>/g, ''); 26 | } 27 | return text; 28 | } 29 | -------------------------------------------------------------------------------- /main/src/controllers/MiraiSkipFilterController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | import { GroupMessageEvent, MiraiElem, PrivateMessageEvent } from '@icqqjs/icqq'; 5 | 6 | export default class { 7 | constructor(private readonly instance: Instance, 8 | private readonly tgBot: Telegram, 9 | // private readonly tgUser: Telegram, 10 | private readonly qqBot: OicqClient) { 11 | qqBot.addNewMessageEventHandler(this.onQqMessage); 12 | } 13 | 14 | // 当 mapInstance 用同服务器其他个人模式账号发送消息后,message mirai 会带 q2tgSkip=true 15 | // 防止 bot 重新收到消息再转一圈回来重新转发或者重新响应命令 16 | private onQqMessage = async (event: PrivateMessageEvent | GroupMessageEvent) => { 17 | if ('friend' in event) return; 18 | if (!event.message) return; 19 | const messageMirai = event.message.find(it => it.type === 'mirai') as MiraiElem; 20 | if (messageMirai) { 21 | try { 22 | const miraiData = JSON.parse(messageMirai.data); 23 | if (miraiData.q2tgSkip) return true; 24 | } 25 | catch { 26 | } 27 | } 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /main/src/utils/flagControl.ts: -------------------------------------------------------------------------------- 1 | import flags from '../constants/flags'; 2 | import { Pair } from '../models/Pair'; 3 | import Instance from '../models/Instance'; 4 | 5 | const displayFlag = (flag: number) => { 6 | const enabled = []; 7 | for (const name in flags) { 8 | const value = flags[name] as any as number; 9 | if (flag & value) { 10 | enabled.push(name); 11 | } 12 | } 13 | return ['0b' + flag.toString(2), ...enabled].join('\n'); 14 | }; 15 | 16 | export const editFlags = async (params: string[], target: Pair | Instance) => { 17 | if (!params.length) { 18 | return displayFlag(target.flags); 19 | } 20 | if (params.length !== 2) return '参数格式错误'; 21 | 22 | let operand = Number(params[1]); 23 | if (isNaN(operand)) { 24 | operand = flags[params[1].toUpperCase()]; 25 | } 26 | if (isNaN(operand)) return 'flag 格式错误'; 27 | 28 | switch (params[0]) { 29 | case 'add': 30 | case 'set': 31 | target.flags |= operand; 32 | break; 33 | case 'rm': 34 | case 'remove': 35 | case 'del': 36 | case 'delete': 37 | target.flags &= ~operand; 38 | break; 39 | case 'put': 40 | target.flags = operand; 41 | break; 42 | } 43 | 44 | return displayFlag(target.flags); 45 | }; 46 | -------------------------------------------------------------------------------- /main/src/utils/urls.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Friend, Group } from '@icqqjs/icqq'; 3 | 4 | export function getAvatarUrl(room: number | bigint | Friend | Group): string { 5 | if (!room) return ''; 6 | if (room instanceof Friend) { 7 | room = room.user_id; 8 | } 9 | if (room instanceof Group) { 10 | room = -room.group_id; 11 | } 12 | return room < 0 ? 13 | `https://p.qlogo.cn/gh/${-room}/${-room}/0` : 14 | `https://q1.qlogo.cn/g?b=qq&nk=${room}&s=0`; 15 | } 16 | 17 | export function getImageUrlByMd5(md5: string) { 18 | return 'https://gchat.qpic.cn/gchatpic_new/0/0-0-' + md5.toUpperCase() + '/0'; 19 | } 20 | 21 | export function getBigFaceUrl(file: string) { 22 | return `https://gxh.vip.qq.com/club/item/parcel/item/${file.substring(0, 2)}/${file.substring(0, 32)}/300x300.png`; 23 | } 24 | 25 | export async function fetchFile(url: string): Promise { 26 | const res = await axios.get(url, { 27 | responseType: 'arraybuffer', 28 | }); 29 | return res.data; 30 | } 31 | 32 | export function getAvatar(room: number | Friend | Group) { 33 | return fetchFile(getAvatarUrl(room)); 34 | } 35 | 36 | export function isContainsUrl(msg: string): boolean { 37 | return msg.includes("https://") || msg.includes("http://") 38 | } 39 | -------------------------------------------------------------------------------- /main/src/controllers/AliveCheckController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | import { Api } from 'telegram'; 5 | 6 | export default class AliveCheckController { 7 | constructor(private readonly instance: Instance, 8 | private readonly tgBot: Telegram, 9 | private readonly oicq: OicqClient) { 10 | tgBot.addNewMessageEventHandler(this.handleMessage); 11 | } 12 | 13 | private handleMessage = async (message: Api.Message) => { 14 | if (!message.sender.id.eq(this.instance.owner) || !message.isPrivate) { 15 | return false; 16 | } 17 | if (!['似了吗', '/alive'].includes(message.message)) { 18 | return false; 19 | } 20 | 21 | await message.reply({ 22 | message: await this.genMessage(this.instance.id === 0 ? Instance.instances : [this.instance]), 23 | }); 24 | }; 25 | 26 | private async genMessage(instances: Instance[]): Promise { 27 | const boolToStr = (value: boolean) => { 28 | return value ? '好' : '坏'; 29 | }; 30 | const messageParts: string[] = []; 31 | 32 | for (const instance of instances) { 33 | const oicq = instance.oicq; 34 | const tgBot = instance.tgBot; 35 | 36 | return messageParts.join('\n\n'); 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /main/src/utils/highLevelFunces.ts: -------------------------------------------------------------------------------- 1 | export function debounce(fn: (...originArgs: TArgs) => TRet, dur = 100) { 2 | let timer: NodeJS.Timeout; 3 | return function (...args: TArgs) { 4 | clearTimeout(timer); 5 | timer = setTimeout(() => { 6 | // @ts-ignore 7 | fn.apply(this, args); 8 | }, dur); 9 | }; 10 | } 11 | 12 | export function throttle(fn: (...originArgs: TArgs) => TRet, time = 500) { 13 | let timer: NodeJS.Timeout; 14 | return function (...args) { 15 | if (timer == null) { 16 | fn.apply(this, args); 17 | timer = setTimeout(() => { 18 | timer = null; 19 | }, time); 20 | } 21 | }; 22 | } 23 | 24 | export function consumer(fn: (...originArgs: TArgs) => TRet, time = 100) { 25 | const tasks: Function[] = []; 26 | let timer: NodeJS.Timeout; 27 | 28 | const nextTask = () => { 29 | if (tasks.length === 0) return false; 30 | 31 | tasks.shift().call(null); 32 | return true; 33 | }; 34 | 35 | return function (...args: TArgs) { 36 | tasks.push(fn.bind(this, ...args)); 37 | 38 | if (timer == null) { 39 | nextTask(); 40 | timer = setInterval(() => { 41 | if (!nextTask()) { 42 | clearInterval(timer); 43 | timer = null; 44 | } 45 | }, time); 46 | } 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish docker container 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | name: Publish container image 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: OCI meta 16 | id: meta 17 | uses: docker/metadata-action@v5 18 | with: 19 | images: ghcr.io/${{ github.repository }} 20 | tags: | 21 | type=ref,event=branch 22 | type=ref,event=pr 23 | type=semver,pattern={{version}} 24 | type=sha 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v3 31 | 32 | - name: Login to GHCR 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.repository_owner }} 37 | password: ${{ secrets.PACKAGES_TOKEN }} 38 | 39 | - name: Build and push 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: . 43 | push: ${{ github.actor != 'dependabot[bot]' }} 44 | tags: ${{ steps.meta.outputs.tags }} 45 | labels: ${{ steps.meta.outputs.labels }} 46 | platforms: linux/amd64 47 | cache-from: type=gha 48 | cache-to: type=gha,mode=max 49 | secrets: | 50 | "npmrc=//npm.pkg.github.com/:_authToken=${{ secrets.GPR_TOKEN }}" 51 | -------------------------------------------------------------------------------- /main/src/controllers/InstanceManageController.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from 'log4js'; 2 | import Telegram from '../client/Telegram'; 3 | import { Api } from 'telegram'; 4 | import Instance from '../models/Instance'; 5 | import { Button } from 'telegram/tl/custom/button'; 6 | 7 | export default class InstanceManageController { 8 | private readonly log = getLogger('InstanceManageController'); 9 | 10 | constructor(private readonly instance: Instance, 11 | private readonly tgBot: Telegram) { 12 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 13 | } 14 | 15 | private onTelegramMessage = async (message: Api.Message) => { 16 | if (!(message.chat.id.eq(this.instance.owner) && message.message)) return; 17 | const messageSplit = message.message.split(' '); 18 | if (messageSplit[0] !== '/newinstance') return; 19 | if (messageSplit.length === 1) { 20 | await message.reply({ 21 | message: '通过 /newinstance 新的 Bot API Token 创建一个新的转发机器人实例', 22 | }); 23 | return true; 24 | } 25 | else { 26 | await message.reply({ 27 | message: `正在创建,请稍候`, 28 | }); 29 | const newInstance = await Instance.createNew(messageSplit[1]); 30 | this.log.info(`已创建新的实例 实例 ID: ${newInstance.id} Bot Token: ${messageSplit[1]}`); 31 | await message.reply({ 32 | message: `已创建新的实例\n实例 ID: ${newInstance.id}`, 33 | buttons: Button.url('去配置', `https://t.me/${newInstance.botMe.username}?start=setup`), 34 | }); 35 | return true; 36 | } 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /main/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | const random = { 4 | int(min: number, max: number) { 5 | min = Math.ceil(min); 6 | max = Math.floor(max); 7 | return Math.floor(Math.random() * (max - min + 1)) + min; //含最大值,含最小值 8 | }, 9 | hex(length: number) { 10 | return crypto.randomBytes(length / 2).toString('hex'); 11 | }, 12 | pick(...array: T[]) { 13 | const index = random.int(0, array.length - 1); 14 | return array[index]; 15 | }, 16 | fakeUuid() { 17 | return `${random.hex(8)}-${random.hex(4)}-${random.hex(4)}-${random.hex(4)}-${random.hex(12)}`; 18 | }, 19 | imei() { 20 | const uin = random.int(1000000, 4294967295); 21 | let imei = uin % 2 ? '86' : '35'; 22 | const buf = Buffer.alloc(4); 23 | buf.writeUInt32BE(uin); 24 | let a: number | string = buf.readUInt16BE(); 25 | let b: number | string = Buffer.concat([Buffer.alloc(1), buf.slice(1)]).readUInt32BE(); 26 | if (a > 9999) 27 | a = Math.trunc(a / 10); 28 | else if (a < 1000) 29 | a = String(uin).substring(0, 4); 30 | while (b > 9999999) 31 | b = b >>> 1; 32 | if (b < 1000000) 33 | b = String(uin).substring(0, 4) + String(uin).substring(0, 3); 34 | imei += a + '0' + b; 35 | 36 | function calcSP(imei: string) { 37 | let sum = 0; 38 | for (let i = 0; i < imei.length; ++i) { 39 | if (i % 2) { 40 | let j = parseInt(imei[i]) * 2; 41 | sum += j % 10 + Math.floor(j / 10); 42 | } 43 | else { 44 | sum += parseInt(imei[i]); 45 | } 46 | } 47 | return (100 - sum) % 10; 48 | } 49 | 50 | return imei + calcSP(imei); 51 | }, 52 | }; 53 | 54 | export default random; 55 | -------------------------------------------------------------------------------- /main/src/utils/paginatedInlineSelector.ts: -------------------------------------------------------------------------------- 1 | import { ButtonLike } from 'telegram/define'; 2 | import arrays from './arrays'; 3 | import { Button } from 'telegram/tl/custom/button'; 4 | import { Api } from 'telegram'; 5 | import TelegramChat from '../client/TelegramChat'; 6 | 7 | export default async function createPaginatedInlineSelector(chat: TelegramChat, message: string, choices: ButtonLike[][]) { 8 | const PAGE_SIZE = 12; 9 | let currentPage = 0; 10 | const totalPages = Math.ceil(choices.length / PAGE_SIZE); 11 | let sentMessage: Api.Message; 12 | const buttonPageUp = Button.inline('⬅︎ 上一页', chat.parent.registerCallback(() => { 13 | currentPage = Math.max(0, currentPage - 1); 14 | sentMessage.edit({ 15 | text: message + `\n\n第 ${currentPage + 1} 页,共 ${totalPages} 页`, 16 | buttons: getButtons(), 17 | }); 18 | })); 19 | const buttonPageDown = Button.inline('下一页 ➡︎', chat.parent.registerCallback(() => { 20 | currentPage = Math.min(totalPages - 1, currentPage + 1); 21 | sentMessage.edit({ 22 | text: message + `\n\n第 ${currentPage + 1} 页,共 ${totalPages} 页`, 23 | buttons: getButtons(), 24 | }); 25 | })); 26 | const getButtons = () => { 27 | const buttons = arrays.pagination(choices, PAGE_SIZE, currentPage); 28 | const paginateButtons: ButtonLike[] = []; 29 | currentPage > 0 && paginateButtons.push(buttonPageUp); 30 | currentPage !== totalPages - 1 && paginateButtons.push(buttonPageDown); 31 | paginateButtons.length && buttons.push(paginateButtons); 32 | return buttons; 33 | }; 34 | sentMessage = await chat.sendMessage({ 35 | message: message + `\n\n第 ${currentPage + 1} 页,共 ${totalPages} 页`, 36 | buttons: getButtons(), 37 | }); 38 | return sentMessage; 39 | } 40 | -------------------------------------------------------------------------------- /main/src/api/richHeader.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback } from 'fastify'; 2 | import { Pair } from '../models/Pair'; 3 | import ejs from 'ejs'; 4 | import fs from 'fs'; 5 | import { Group } from '@icqqjs/icqq'; 6 | import { format } from 'date-and-time'; 7 | 8 | const template = ejs.compile(fs.readFileSync('./assets/richHeader.ejs', 'utf-8')); 9 | 10 | export default ((fastify, opts, done) => { 11 | fastify.get<{ 12 | Params: { apiKey: string, userId: string } 13 | }>('/:apiKey/:userId', async (request, reply) => { 14 | const pair = Pair.getByApiKey(request.params.apiKey); 15 | if (!pair) { 16 | reply.code(404); 17 | return 'Group not found'; 18 | } 19 | const group = pair.qq as Group; 20 | const members = await group.getMemberMap(); 21 | const member = members.get(Number(request.params.userId)); 22 | if (!member) { 23 | reply.code(404); 24 | return 'Member not found'; 25 | } 26 | const profile = await pair.qq.client.getProfile(member.user_id); 27 | 28 | reply.type('text/html'); 29 | return template({ 30 | userId: request.params.userId, 31 | title: member.title, 32 | name: member.card || member.nickname, 33 | role: member.role, 34 | joinTime: format(new Date(member.join_time * 1000), 'YYYY-MM-DD HH:mm'), 35 | lastSentTime: format(new Date(member.last_sent_time * 1000), 'YYYY-MM-DD HH:mm'), 36 | regTime: format(new Date(profile.regTimestamp * 1000), 'YYYY-MM-DD HH:mm'), 37 | location: [profile.country, profile.province, profile.city].join(' ').trim(), 38 | nickname: member.nickname, 39 | email: profile.email, 40 | qid: profile.QID, 41 | signature: profile.signature, 42 | birthday: (profile.birthday || []).join('/'), 43 | }); 44 | }); 45 | 46 | done(); 47 | }) as FastifyPluginCallback; 48 | -------------------------------------------------------------------------------- /main/src/api/telegramAvatar.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback } from 'fastify'; 2 | import Instance from '../models/Instance'; 3 | import convert from '../helpers/convert'; 4 | import Telegram from '../client/Telegram'; 5 | import { Api } from 'telegram'; 6 | import BigInteger from 'big-integer'; 7 | import { getLogger } from 'log4js'; 8 | import fs from 'fs'; 9 | 10 | const log = getLogger('telegramAvatar'); 11 | 12 | const userAvatarFileIdCache = new Map(); 13 | 14 | const getUserAvatarFileId = async (tgBot: Telegram, userId: string) => { 15 | let cached = userAvatarFileIdCache.get(userId); 16 | if (cached) return cached; 17 | 18 | const user = await tgBot.getChat(userId); 19 | if ('photo' in user.entity && user.entity.photo instanceof Api.UserProfilePhoto) { 20 | cached = user.entity.photo.photoId; 21 | } 22 | else { 23 | cached = BigInteger.zero; 24 | } 25 | userAvatarFileIdCache.set(userId, cached); 26 | return cached; 27 | }; 28 | 29 | const getUserAvatarPath = async (tgBot: Telegram, userId: string) => { 30 | const fileId = await getUserAvatarFileId(tgBot, userId); 31 | if (fileId.eq(0)) return ''; 32 | return await convert.cachedBuffer(fileId.toString(16) + '.jpg', () => tgBot.downloadEntityPhoto(userId)); 33 | }; 34 | 35 | export default ((fastify, opts, done) => { 36 | fastify.get<{ 37 | Params: { instanceId: string, userId: string } 38 | }>('/:instanceId/:userId', async (request, reply) => { 39 | log.debug('请求头像', request.params.userId); 40 | const instance = Instance.instances.find(it => it.id.toString() === request.params.instanceId); 41 | const avatar = await getUserAvatarPath(instance.tgBot, request.params.userId); 42 | 43 | if (!avatar) { 44 | reply.code(404); 45 | return; 46 | } 47 | reply.type('image/jpeg'); 48 | return fs.createReadStream(avatar); 49 | }); 50 | 51 | done(); 52 | }) as FastifyPluginCallback; 53 | -------------------------------------------------------------------------------- /main/src/utils/inlineDigitInput.ts: -------------------------------------------------------------------------------- 1 | import TelegramChat from '../client/TelegramChat'; 2 | import { Button } from 'telegram/tl/custom/button'; 3 | 4 | export default async function inlineDigitInput(chat: TelegramChat, length: number) { 5 | return new Promise(async resolve => { 6 | const SYMBOL_EMPTY = '-'; 7 | const SYMBOL_INPUT = '_'; 8 | const SYMBOL_SPACE = ' '; 9 | 10 | let input = ''; 11 | 12 | function getDisplay() { 13 | const leftLength = length - input.length; 14 | let display = Array.from(input); 15 | leftLength > 0 && display.push(SYMBOL_INPUT); 16 | leftLength > 1 && display.push(...SYMBOL_EMPTY.repeat(leftLength - 1)); 17 | // 增大一点键盘的大小,方便按 18 | return `>>> ${display.join(SYMBOL_SPACE)} <<<`; 19 | } 20 | 21 | function refreshDisplay() { 22 | if (input.length === length) { 23 | resolve(input); 24 | message.edit({ 25 | text: `${input}`, 26 | buttons: Button.clear(), 27 | }); 28 | return; 29 | } 30 | message.edit({ 31 | text: getDisplay(), 32 | }); 33 | } 34 | 35 | function inputButton(digit: number | string) { 36 | digit = digit.toString(); 37 | return Button.inline(digit, chat.parent.registerCallback(() => { 38 | input += digit; 39 | refreshDisplay(); 40 | })); 41 | } 42 | 43 | const backspaceButton = Button.inline('⌫', chat.parent.registerCallback(() => { 44 | if (!input.length) return; 45 | input = input.substring(0, input.length - 1); 46 | refreshDisplay(); 47 | })); 48 | 49 | const message = await chat.sendMessage({ 50 | message: getDisplay(), 51 | buttons: [ 52 | [inputButton(1), inputButton(2), inputButton(3)], 53 | [inputButton(4), inputButton(5), inputButton(6)], 54 | [inputButton(7), inputButton(8), inputButton(9)], 55 | [inputButton(0), backspaceButton], 56 | ], 57 | }); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /main/src/encoding/silk.ts: -------------------------------------------------------------------------------- 1 | import ffmpeg from 'fluent-ffmpeg'; 2 | import { file } from 'tmp-promise'; 3 | import silk from 'silk-sdk'; 4 | import fsP from 'fs/promises'; 5 | 6 | const conventOggToPcm = (oggPath: string, tmpFilePath: string): Promise => { 7 | return new Promise(resolve => { 8 | ffmpeg(oggPath) 9 | .outputFormat('s16le') 10 | .outputOptions([ 11 | '-ar', '24000', 12 | '-ac', '1', 13 | '-acodec', 'pcm_s16le', 14 | ]) 15 | .on('end', async () => { 16 | resolve(); 17 | }).save(tmpFilePath); 18 | }); 19 | }; 20 | 21 | const conventPcmToOgg = (pcmPath: string, savePath: string): Promise => { 22 | return new Promise(resolve => { 23 | ffmpeg(pcmPath).inputOption([ 24 | '-f', 's16le', 25 | '-ar', '24000', 26 | '-ac', '1', 27 | ]) 28 | .outputFormat('ogg') 29 | .on('end', async () => { 30 | resolve(); 31 | }).save(savePath); 32 | }); 33 | }; 34 | 35 | export default { 36 | async encode(oggPath: string): Promise { 37 | const { path, cleanup } = await file(); 38 | await conventOggToPcm(oggPath, path); 39 | const bufSilk = silk.encode(path, { 40 | tencent: true, 41 | }); 42 | await cleanup(); 43 | return bufSilk; 44 | }, 45 | 46 | async decode(bufSilk: Buffer, outputPath: string): Promise { 47 | const bufPcm = silk.decode(bufSilk); 48 | const { path, cleanup } = await file(); 49 | await fsP.writeFile(path, bufPcm); 50 | await conventPcmToOgg(path, outputPath); 51 | cleanup(); 52 | }, 53 | 54 | conventOggToPcm16000: (oggPath: string, tmpFilePath: string): Promise => { 55 | return new Promise(resolve => { 56 | ffmpeg(oggPath) 57 | .outputFormat('s16le') 58 | .outputOptions([ 59 | '-ar', '16000', 60 | '-ac', '1', 61 | '-acodec', 'pcm_s16le', 62 | ]) 63 | .on('end', async () => { 64 | resolve(); 65 | }).save(tmpFilePath); 66 | }); 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "q2tg-main", 3 | "scripts": { 4 | "dev": "tsx src/index.ts", 5 | "build": "tsc", 6 | "start": "prisma db push --accept-data-loss --skip-generate && node build/index.js", 7 | "prisma": "prisma", 8 | "import": "ts-node tools/import" 9 | }, 10 | "bin": "build/index.js", 11 | "files": [ 12 | "build", 13 | "assets" 14 | ], 15 | "devDependencies": { 16 | "@types/cli-progress": "^3.11.5", 17 | "@types/date-and-time": "^3.0.3", 18 | "@types/dockerode": "^3.3.29", 19 | "@types/ejs": "^3.1.5", 20 | "@types/fluent-ffmpeg": "^2.1.24", 21 | "@types/lodash": "^4.17.1", 22 | "@types/markdown-escape": "^1.1.3", 23 | "@types/node": "^20.12.8", 24 | "@types/probe-image-size": "^7.2.4", 25 | "@types/prompts": "^2.4.9", 26 | "tsx": "^4.9.0" 27 | }, 28 | "dependencies": { 29 | "@fastify/http-proxy": "^9.5.0", 30 | "@fastify/static": "^7.0.3", 31 | "@icqqjs/icqq": "1.2.0", 32 | "@prisma/client": "5.13.0", 33 | "axios": "^1.6.8", 34 | "baidu-aip-sdk": "^4.16.15", 35 | "big-integer": "^1.6.52", 36 | "cli-progress": "^3.12.0", 37 | "date-and-time": "^3.1.1", 38 | "dockerode": "^4.0.2", 39 | "dotenv": "^16.4.5", 40 | "ejs": "^3.1.10", 41 | "eviltransform": "^0.2.2", 42 | "fastify": "^4.26.2", 43 | "file-type": "^19.0.0", 44 | "fluent-ffmpeg": "^2.1.2", 45 | "image-size": "^1.1.1", 46 | "lodash": "^4.17.21", 47 | "log4js": "^6.9.1", 48 | "markdown-escape": "^2.0.0", 49 | "nodejs-base64": "^2.0.0", 50 | "prisma": "5.13.0", 51 | "probe-image-size": "^7.2.3", 52 | "prompts": "^2.4.2", 53 | "quote-api": "https://github.com/Clansty/quote-api/archive/014b21138afbbe0e12c91b00561414b1e851fc0f.tar.gz", 54 | "sharp": "^0.33.3", 55 | "silk-sdk": "^0.2.2", 56 | "telegram": "https://github.com/clansty/gramjs/releases/download/2.19.10%2Brevert_media/telegram-2.19.10.tgz", 57 | "tmp-promise": "^3.0.3", 58 | "undici": "^6.15.0", 59 | "zincsearch-node": "^2.1.1", 60 | "zod": "^3.23.6" 61 | }, 62 | "engines": { 63 | "node": "^14.13.1 || >=16.0.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /main/src/models/env.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | import path from 'path'; 3 | 4 | const configParsed = z.object({ 5 | DATA_DIR: z.string().default(path.resolve('./data')), 6 | LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark', 'off']).default('info'), 7 | OICQ_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark', 'off']).default('warn'), 8 | TG_LOG_LEVEL: z.enum(['none', 'error', 'warn', 'info', 'debug']).default('warn'), 9 | FFMPEG_PATH: z.string().optional(), 10 | FFPROBE_PATH: z.string().optional(), 11 | SIGN_API: z.string().url().optional(), 12 | SIGN_VER: z.string().optional(), 13 | TG_API_ID: z.string().regex(/^\d+$/).transform(Number), 14 | TG_API_HASH: z.string(), 15 | TG_BOT_TOKEN: z.string(), 16 | TG_CONNECTION: z.enum(['websocket', 'tcp']).default('tcp'), 17 | TG_INITIAL_DCID: z.string().regex(/^\d+$/).transform(Number).optional(), 18 | TG_INITIAL_SERVER: z.string().ip().optional(), 19 | IPV6: z.string().transform((v) => ['true', '1', 'yes'].includes(v.toLowerCase())).default('false'), 20 | PROXY_IP: z.string().ip().optional(), 21 | PROXY_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), 22 | PROXY_USERNAME: z.string().optional(), 23 | PROXY_PASSWORD: z.string().optional(), 24 | TGS_TO_GIF: z.string().default('tgs_to_gif'), 25 | CRV_API: z.string().url().optional(), 26 | CRV_KEY: z.string().optional(), 27 | ZINC_URL: z.string().url().optional(), 28 | ZINC_USERNAME: z.string().optional(), 29 | ZINC_PASSWORD: z.string().optional(), 30 | BAIDU_APP_ID: z.string().optional(), 31 | BAIDU_API_KEY: z.string().optional(), 32 | BAIDU_SECRET_KEY: z.string().optional(), 33 | DISABLE_FILE_UPLOAD_TIP: z.string().transform((v) => ['true', '1', 'yes'].includes(v.toLowerCase())).default('false'), 34 | LISTEN_PORT: z.string().regex(/^\d+$/).transform(Number).default('8080'), 35 | UI_PATH: z.string().optional(), 36 | UI_PROXY: z.string().url().optional(), 37 | WEB_ENDPOINT: z.string().url().optional(), 38 | }).safeParse(process.env); 39 | 40 | if (!configParsed.success) { 41 | console.error('环境变量解析错误:', (configParsed as any).error); 42 | process.exit(1); 43 | } 44 | 45 | export default configParsed.data; 46 | -------------------------------------------------------------------------------- /main/src/controllers/DeleteMessageController.ts: -------------------------------------------------------------------------------- 1 | import DeleteMessageService from '../services/DeleteMessageService'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | import { Api } from 'telegram'; 5 | import { FriendRecallEvent, GroupRecallEvent } from '@icqqjs/icqq'; 6 | import { DeletedMessageEvent } from 'telegram/events/DeletedMessage'; 7 | import Instance from '../models/Instance'; 8 | 9 | export default class DeleteMessageController { 10 | private readonly deleteMessageService: DeleteMessageService; 11 | 12 | constructor(private readonly instance: Instance, 13 | private readonly tgBot: Telegram, 14 | private readonly oicq: OicqClient) { 15 | this.deleteMessageService = new DeleteMessageService(this.instance, tgBot); 16 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 17 | tgBot.addEditedMessageEventHandler(this.onTelegramEditMessage); 18 | // tgUser.addDeletedMessageEventHandler(this.onTgDeletedMessage); 19 | oicq.on('notice.friend.recall', this.onQqRecall); 20 | oicq.on('notice.group.recall', this.onQqRecall); 21 | } 22 | 23 | private onTelegramMessage = async (message: Api.Message) => { 24 | const pair = this.instance.forwardPairs.find(message.chat); 25 | if (!pair) return false; 26 | if (message.message?.startsWith('/rm')) { 27 | // 撤回消息 28 | await this.deleteMessageService.handleTelegramMessageRm(message, pair); 29 | return true; 30 | } 31 | }; 32 | 33 | private onTelegramEditMessage = async (message: Api.Message) => { 34 | if (message.senderId?.eq(this.instance.botMe.id)) return true; 35 | const pair = this.instance.forwardPairs.find(message.chat); 36 | if (!pair) return; 37 | if (await this.deleteMessageService.isInvalidEdit(message, pair)) { 38 | return true; 39 | } 40 | await this.deleteMessageService.telegramDeleteMessage(message.id, pair); 41 | return await this.onTelegramMessage(message); 42 | }; 43 | 44 | private onQqRecall = async (event: FriendRecallEvent | GroupRecallEvent) => { 45 | const pair = this.instance.forwardPairs.find('friend' in event ? event.friend : event.group); 46 | if (!pair) return; 47 | await this.deleteMessageService.handleQqRecall(event, pair); 48 | }; 49 | 50 | } 51 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | volumes: 4 | postgresql: 5 | zinc-data: 6 | q2tg: 7 | 8 | services: 9 | # 如果有现成的 Postgresql 实例,可以删除这一小节 10 | postgres: 11 | image: postgres:14-alpine 12 | restart: unless-stopped 13 | environment: 14 | POSTGRES_DB: db_name 15 | POSTGRES_USER: user 16 | POSTGRES_PASSWORD: password 17 | volumes: 18 | - postgresql:/var/lib/postgresql/data 19 | zinclabs: 20 | volumes: 21 | - 'zinc-data:/data' 22 | environment: 23 | - ZINC_DATA_PATH=/data 24 | - ZINC_FIRST_ADMIN_USER=admin 25 | - ZINC_FIRST_ADMIN_PASSWORD=password 26 | - ZINC_PLUGIN_GSE_ENABLE=true 27 | - ZINC_PLUGIN_GSE_DICT_EMBED=big 28 | container_name: zincsearch 29 | image: 'public.ecr.aws/zinclabs/zinc:latest' 30 | restart: unless-stopped 31 | sign: 32 | image: xzhouqd/qsign:core-1.1.9 33 | restart: unless-stopped 34 | environment: 35 | # 需要与下方的 SIGN_VER 同步 36 | # 配置请参考 https://hub.docker.com/r/xzhouqd/qsign 37 | - BASE_PATH=/srv/qsign/qsign/txlib/8.9.71 38 | q2tg: 39 | image: ghcr.io/nofated095/q2tg:rainbowcat 40 | container_name: main_q2tg 41 | restart: unless-stopped 42 | depends_on: 43 | - postgres 44 | - zinclabs 45 | - sign 46 | ports: 47 | # 如果要使用 RICH_HEADER 需要将端口发布到公网 48 | - 8080:8080 49 | volumes: 50 | - q2tg:/app/data 51 | - /var/run/docker.sock:/var/run/docker.sock 52 | environment: 53 | - TG_API_ID= 54 | - TG_API_HASH= 55 | - TG_BOT_TOKEN= 56 | - DATABASE_URL=postgres://user:password@postgres/db_name 57 | - CRV_API= 58 | - CRV_KEY= 59 | - ZINC_URL=http://zinclabs:4080 60 | - ZINC_USERNAME=admin 61 | - ZINC_PASSWORD=password 62 | - SIGN_API=http://sign:8080/sign?key=114514 63 | - SIGN_VER=8.9.71 # 与上方 sign 容器的配置同步 64 | - TG_CONNECTION=tcp # 连接 Telegram 的方式,也可以改成 websocket 65 | # 要关闭文件上传提示,请取消注释以下变量 https://github.com/clansty/Q2TG/issues/153 66 | #- DISABLE_FILE_UPLOAD_TIP=1 67 | # 要支持转发时自动识别语音,请设置以下参数 68 | - BAIDU_APP_ID= 69 | - BAIDU_API_KEY= 70 | - BAIDU_SECRET_KEY= 71 | # 如果你需要使用 /flags set RICH_HEADER 来显示头像,则需将 q2tg 8080 端口发布到公网,可以使用 cloudflare tunnel 72 | #- WEB_ENDPOINT=https://yourichheader.com 填写你发布到公网的域名 73 | # 如果需要通过代理联网,那么设置下面两个变量 74 | #- PROXY_IP= 75 | #- PROXY_PORT= 76 | # 代理联网认证,有需要请修改下面两个变量 77 | #- PROXY_USERNAME= 78 | #- PROXY_PASSWORD= 79 | -------------------------------------------------------------------------------- /README_backup.md: -------------------------------------------------------------------------------- 1 | # Q2TG 2 | QQ 群与 Telegram 群相互转发的 bot 3 | 4 | ## 安装方法 5 | 6 | 请看 [Wiki](https://github.com/Clansty/Q2TG/wiki/%E5%AE%89%E8%A3%85%E9%83%A8%E7%BD%B2) 7 | 8 | v2.x 版本同时需要机器人账号以及登录 Telegram 个人账号,需要自己注册 Telegram API ID,并且还需要配置一些辅助服务。如果没有条件,可以使用 [v1.x](https://github.com/Clansty/Q2TG/tree/main) 版本 9 | 10 | ## 支持的消息类型 11 | 12 | - [x] 文字(双向) 13 | - [x] 图片(双向) 14 | - [x] GIF 15 | - [x] 闪照 16 | 17 | 闪照每个 TG 用户也只能查看 5 秒 18 | - [x] 图文混排消息(双向) 19 | - [x] 大表情(双向) 20 | - [x] TG 中的动态 Sticker 21 | 22 | 目前是[转换成 GIF](https://github.com/ed-asriyan/tgs-to-gif) 发送的,并且可能有些[问题](https://github.com/ed-asriyan/tgs-to-gif/issues/13#issuecomment-633244547) 23 | - [x] 视频(双向) 24 | - [x] 语音(双向) 25 | - [x] 小表情(可显示为文字) 26 | - [x] 链接(双向) 27 | - [x] JSON/XML 卡片 28 | 29 | (包括部分转化为小程序的链接) 30 | - [x] 位置(TG -> QQ) 31 | - [x] 群公告 32 | - [x] 回复(双平台原生回复) 33 | - [x] 文件 34 | 35 | QQ -> TG 按需获取下载地址 36 | 37 | TG -> QQ 将自动转发 20M 一下的小文件 38 | - [x] 转发多条消息记录 39 | - [x] TG 编辑消息(撤回再重发) 40 | - [x] 双向撤回消息 41 | - [x] 戳一戳 42 | 43 | ## 关于模式 44 | 45 | ### 群组模式 46 | 47 | 群组模式就是 1.x 版本唯一的模式,是给群主使用的。如果群组想要使自己的 QQ 群和 Telegram 群联通起来,就使用这个模式。群组模式只可以给群聊配置转发,并且转发消息时会带上用户在当前平台的发送者名称。 48 | 49 | ### 个人模式 50 | 51 | 个人模式适合 QQ 轻度使用者,TG 重度使用者。可以把 QQ 的好友和群聊搬到 Telegram 中。个人模式一定要登录机器人主人自己的 Telegram 账号作为 UserBot。可以自动为 QQ 中的好友和群组创建对应的 Telegram 群组,并同步头像简介等信息。当有没有创建关联的好友发起私聊的时候会自动创建 Telegram 中的对应群组。个人模式在初始化的时候会自动在 Telegram 个人账号中创建一个文件夹来存储所有来自 QQ 的对应群组。消息在从 TG 转发到 QQ 时不会带上发送者昵称,因为默认发送者只有一个人。 52 | 53 | ## 如何撤回消息 54 | 55 | 在 QQ 中,直接撤回相应的消息,撤回操作会同步到 TG 56 | 57 | 在 TG 中,可以选择以下操作之一: 58 | 59 | - 将消息内容编辑为 `/rm` 60 | - 回复要撤回的消息,内容为 `/rm`。如果操作者在 TG 群组中没有「删除消息」权限,则只能撤回自己的消息 61 | - 如果正确配置了个人账号的 User Bot,可以直接删除消息 62 | 63 | 为了使撤回功能正常工作,TG 机器人需要具有「删除消息」权限,QQ 机器人需要为管理员或群主 64 | 65 | 即使 QQ 机器人为管理员,也无法撤回其他管理员在 QQ 中发送的消息 66 | 67 | ## 免责声明 68 | 69 | 一切开发旨在学习,请勿用于非法用途。本项目完全免费开源,不会收取任何费用,无任何担保。请勿将本项目用于商业用途。由于使用本程序造成的任何问题,由使用者自行承担,项目开发者不承担任何责任。 70 | 71 | 本项目基于 AGPL 发行。修改、再发行和运行服务需要遵守 AGPL 许可证,源码需要和服务一起提供。 72 | 73 | ## 许可证 74 | 75 | ``` 76 | This program is free software: you can redistribute it and/or modify 77 | it under the terms of the GNU Affero General Public License as 78 | published by the Free Software Foundation, either version 3 of the 79 | License, or (at your option) any later version. 80 | 81 | This program is distributed in the hope that it will be useful, 82 | but WITHOUT ANY WARRANTY; without even the implied warranty of 83 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 84 | GNU Affero General Public License for more details. 85 | 86 | You should have received a copy of the GNU Affero General Public License 87 | along with this program. If not, see . 88 | ``` 89 | -------------------------------------------------------------------------------- /main/src/models/Pair.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from 'log4js'; 2 | import { Friend, Group } from '@icqqjs/icqq'; 3 | import TelegramChat from '../client/TelegramChat'; 4 | import getAboutText from '../utils/getAboutText'; 5 | import { md5 } from '../utils/hashing'; 6 | import { getAvatar } from '../utils/urls'; 7 | import db from './db'; 8 | 9 | const log = getLogger('ForwardPair'); 10 | 11 | export class Pair { 12 | private static readonly apiKeyMap = new Map(); 13 | 14 | public static getByApiKey(key: string) { 15 | return this.apiKeyMap.get(key); 16 | } 17 | 18 | // 群成员的 tg 账号对应它对应的 QQ 账号获取到的 Group 对象 19 | // 只有群组模式有效 20 | // public readonly instanceMapForTg = {} as { [tgUserId: string]: Group }; 21 | 22 | constructor( 23 | public readonly qq: Friend | Group, 24 | private _tg: TelegramChat, 25 | // public readonly tgUser: TelegramChat, 26 | public dbId: number, 27 | private _flags: number, 28 | public readonly apiKey: string, 29 | ) { 30 | if (apiKey) { 31 | Pair.apiKeyMap.set(apiKey, this); 32 | } 33 | } 34 | 35 | // 更新 TG 群组的头像和简介 36 | public async updateInfo() { 37 | const avatarCache = await db.avatarCache.findFirst({ 38 | where: { forwardPairId: this.dbId }, 39 | }); 40 | const lastHash = avatarCache ? avatarCache.hash : null; 41 | const avatar = await getAvatar(this.qqRoomId); 42 | const newHash = md5(avatar); 43 | if (!lastHash || lastHash.compare(newHash) !== 0) { 44 | log.debug(`更新群头像: ${this.qqRoomId}`); 45 | await this._tg.setProfilePhoto(avatar); 46 | await db.avatarCache.upsert({ 47 | where: { forwardPairId: this.dbId }, 48 | update: { hash: newHash }, 49 | create: { forwardPairId: this.dbId, hash: newHash }, 50 | }); 51 | } 52 | await this._tg.editAbout(await getAboutText(this.qq, false)); 53 | } 54 | 55 | get qqRoomId() { 56 | return this.qq instanceof Friend ? this.qq.user_id : -this.qq.group_id; 57 | } 58 | 59 | get tgId() { 60 | return Number(this._tg.id); 61 | } 62 | 63 | get tg() { 64 | return this._tg; 65 | } 66 | 67 | set tg(value: TelegramChat) { 68 | this._tg = value; 69 | db.forwardPair 70 | .update({ 71 | where: { id: this.dbId }, 72 | data: { tgChatId: Number(value.id) }, 73 | }) 74 | .then(() => log.info(`出现了到超级群组的转换: ${value.id}`)); 75 | } 76 | 77 | get flags() { 78 | return this._flags; 79 | } 80 | 81 | set flags(value) { 82 | this._flags = value; 83 | db.forwardPair 84 | .update({ 85 | where: { id: this.dbId }, 86 | data: { flags: value }, 87 | }) 88 | .then(() => 0); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /patches/@icqqjs__icqq@1.2.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/common.d.ts b/lib/common.d.ts 2 | index d27f6298a041607768ee58b0d1e75c8bdcedafe1..d4d90b2ef8b63baf1edb84b0ab3118bbf421c515 100644 3 | --- a/lib/common.d.ts 4 | +++ b/lib/common.d.ts 5 | @@ -54,5 +54,11 @@ export interface UserProfile { 6 | signature: string; 7 | /** 自定义的QID */ 8 | QID: string; 9 | + nickname: string; 10 | + country: string; 11 | + province: string; 12 | + city: string; 13 | + email: string; 14 | + birthday: [number, number, number]; 15 | } 16 | export * from "./core/constants"; 17 | diff --git a/lib/internal/internal.js b/lib/internal/internal.js 18 | index ee137c44c92b947dcc7d851bb04f319c9a070f68..4bb7d5d082156f76974269e220051539162792dd 100644 19 | --- a/lib/internal/internal.js 20 | +++ b/lib/internal/internal.js 21 | @@ -86,9 +86,17 @@ async function getUserProfile(uin = this.uin) { 22 | }); 23 | // 有需要自己加! 24 | return { 25 | + nickname: String(profile[20002]), 26 | + country: String(profile[20003]), 27 | + province: String(profile[20004]), 28 | + city: String(profile[20020]), 29 | + email: String(profile[20011]), 30 | signature: String(profile[102]), 31 | regTimestamp: profile[20026], 32 | - QID: String(profile[27394]) 33 | + QID: String(profile[27394]), 34 | + birthday: profile[20031].toBuffer().length === 4 ? 35 | + [profile[20031].toBuffer().slice(0,2).readUInt16BE(), profile[20031].toBuffer().slice(2,3).readUInt8(), profile[20031].toBuffer().slice(3).readUInt8()] : 36 | + undefined, 37 | }; 38 | } 39 | exports.getUserProfile = getUserProfile; 40 | diff --git a/lib/message/converter.js b/lib/message/converter.js 41 | index 27a659a3290fadd990a1a980918515a6ded4978f..e1bbe1470f302c30e7adea92f433a6e3929064e3 100644 42 | --- a/lib/message/converter.js 43 | +++ b/lib/message/converter.js 44 | @@ -111,7 +111,7 @@ class Converter { 45 | return; 46 | } 47 | if (qq === "all") { 48 | - var q = 0, flag = 1, display = "全体成员"; 49 | + var q = 0, flag = 1, display = text || "全体成员"; 50 | } 51 | else { 52 | var q = Number(qq), flag = 0, display = text || String(qq); 53 | @@ -121,7 +121,6 @@ class Converter { 54 | display = member?.card || member?.nickname || display; 55 | } 56 | } 57 | - display = "@" + display; 58 | if (dummy) 59 | return this._text(display); 60 | const buf = Buffer.allocUnsafe(6); 61 | @@ -535,10 +534,6 @@ class Converter { 62 | quote(source) { 63 | const elems = new Converter(source.message || "", this.ext).elems; 64 | const tmp = this.brief; 65 | - if (!this.ext?.dm) { 66 | - this.at({ type: "at", qq: source.user_id }); 67 | - this.elems.unshift(this.elems.pop()); 68 | - } 69 | this.elems.unshift({ 70 | 45: { 71 | 1: [source.seq], 72 | -------------------------------------------------------------------------------- /main/src/controllers/RequestController.ts: -------------------------------------------------------------------------------- 1 | import { getLogger, Logger } from 'log4js'; 2 | import Instance from '../models/Instance'; 3 | import Telegram from '../client/Telegram'; 4 | import OicqClient from '../client/OicqClient'; 5 | import { FriendRequestEvent, GroupInviteEvent } from '@icqqjs/icqq'; 6 | import { getAvatar } from '../utils/urls'; 7 | import { CustomFile } from 'telegram/client/uploads'; 8 | import { Button } from 'telegram/tl/custom/button'; 9 | 10 | export default class RequestController { 11 | private readonly log: Logger; 12 | 13 | constructor(private readonly instance: Instance, 14 | private readonly tgBot: Telegram, 15 | private readonly oicq: OicqClient) { 16 | this.log = getLogger(`RequestController - ${instance.id}`); 17 | oicq.on('request.friend', this.handleRequest); 18 | oicq.on('request.group.invite', this.handleRequest); 19 | } 20 | 21 | private handleRequest = async (event: FriendRequestEvent | GroupInviteEvent) => { 22 | this.log.info(`收到申请:${event.nickname} (${event.user_id})`); 23 | let avatar: Buffer; 24 | let messageText = ''; 25 | if (event.request_type === 'friend') { 26 | avatar = await getAvatar(event.user_id); 27 | messageText = `收到好友申请\n` + 28 | `昵称:${event.nickname}\n` + 29 | `账号:${event.user_id}\n` + 30 | `年龄:${event.age}\n` + 31 | `性别:${event.sex}\n` + 32 | `来源:${event.source}\n` + 33 | `附言:${event.comment}`; 34 | } 35 | else { 36 | avatar = await getAvatar(-event.group_id); 37 | messageText = `收到加群邀请\n` + 38 | `邀请人:${event.nickname} (${event.user_id})\n` + 39 | `群名称:${event.group_name}\n` + 40 | `群号:${event.group_id}\n` + 41 | `邀请者身份:${event.role}`; 42 | } 43 | const message = await this.instance.ownerChat.sendMessage({ 44 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 45 | message: messageText, 46 | buttons: [[ 47 | Button.inline('同意', this.tgBot.registerCallback(async () => { 48 | try { 49 | if (!await event.approve(true)) { 50 | await message.edit({ text: '同意失败', buttons: Button.clear() }); 51 | } 52 | } 53 | catch (e) { 54 | await message.edit({ text: `同意失败:${e.message}`, buttons: Button.clear() }); 55 | } 56 | await message.edit({ text: '已同意请求', buttons: Button.clear() }); 57 | })), 58 | Button.inline('拒绝', this.tgBot.registerCallback(async () => { 59 | try { 60 | if (!await event.approve(false)) { 61 | await message.edit({ text: '拒绝失败', buttons: Button.clear() }); 62 | } 63 | } 64 | catch (e) { 65 | await message.edit({ text: `拒绝失败:${e.message}`, buttons: Button.clear() }); 66 | } 67 | await message.edit({ text: '已拒绝请求', buttons: Button.clear() }); 68 | })), 69 | ]], 70 | }); 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /main/src/models/ForwardPairs.ts: -------------------------------------------------------------------------------- 1 | import { Friend, Group } from '@icqqjs/icqq'; 2 | import TelegramChat from '../client/TelegramChat'; 3 | import OicqClient from '../client/OicqClient'; 4 | import Telegram from '../client/Telegram'; 5 | import db from './db'; 6 | import { Entity } from 'telegram/define'; 7 | import { BigInteger } from 'big-integer'; 8 | import { Pair } from './Pair'; 9 | import { getLogger, Logger } from 'log4js'; 10 | import Instance from './Instance'; 11 | 12 | export default class ForwardPairs { 13 | private pairs: Pair[] = []; 14 | private readonly log: Logger; 15 | 16 | private constructor(private readonly instanceId: number) { 17 | this.log = getLogger(`ForwardPairs - ${instanceId}`); 18 | } 19 | 20 | // 在 forwardController 创建时初始化 21 | private async init(oicq: OicqClient, tgBot: Telegram, tgUser: Telegram) { 22 | const dbValues = await db.forwardPair.findMany({ 23 | where: { instanceId: this.instanceId }, 24 | }); 25 | for (const i of dbValues) { 26 | try { 27 | const qq = oicq.getChat(Number(i.qqRoomId)); 28 | const tg = await tgBot.getChat(Number(i.tgChatId)); 29 | const tgUserChat = await tgUser.getChat(Number(i.tgChatId)); 30 | if (qq && tg && tgUserChat) { 31 | this.pairs.push(new Pair(qq, tg, tgUserChat, i.id, i.flags, i.apiKey)); 32 | } 33 | } 34 | catch (e) { 35 | this.log.warn(`初始化遇到问题,QQ: ${i.qqRoomId} TG: ${i.tgChatId}`); 36 | } 37 | } 38 | } 39 | 40 | public static async load(instanceId: number, oicq: OicqClient, tgBot: Telegram, tgUser: Telegram) { 41 | const instance = new this(instanceId); 42 | await instance.init(oicq, tgBot, tgUser); 43 | return instance; 44 | } 45 | 46 | public async add(qq: Friend | Group, tg: TelegramChat, tgUser: TelegramChat) { 47 | const dbEntry = await db.forwardPair.create({ 48 | data: { 49 | qqRoomId: qq instanceof Friend ? qq.user_id : -qq.group_id, 50 | tgChatId: Number(tg.id), 51 | instanceId: this.instanceId, 52 | }, 53 | }); 54 | this.pairs.push(new Pair(qq, tg, tgUser, dbEntry.id, dbEntry.flags, dbEntry.apiKey)); 55 | return dbEntry; 56 | } 57 | 58 | public async remove(pair: Pair) { 59 | this.pairs.splice(this.pairs.indexOf(pair), 1); 60 | await db.forwardPair.delete({ 61 | where: { id: pair.dbId }, 62 | }); 63 | } 64 | 65 | public find(target: Friend | Group | TelegramChat | Entity | number | BigInteger) { 66 | if (!target) return null; 67 | if (target instanceof Friend) { 68 | return this.pairs.find(e => e.qq instanceof Friend && e.qq.user_id === target.user_id); 69 | } 70 | else if (target instanceof Group) { 71 | return this.pairs.find(e => e.qq instanceof Group && e.qq.group_id === target.group_id); 72 | } 73 | else if (typeof target === 'number' || 'eq' in target) { 74 | return this.pairs.find(e => e.qqRoomId === target || e.tg.id.eq(target)); 75 | } 76 | else { 77 | return this.pairs.find(e => e.tg.id.eq(target.id)); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /main/src/controllers/FileAndFlashPhotoController.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import OicqClient from '../client/OicqClient'; 3 | import { Api } from 'telegram'; 4 | import db from '../models/db'; 5 | import { Button } from 'telegram/tl/custom/button'; 6 | import { getLogger, Logger } from 'log4js'; 7 | import { CustomFile } from 'telegram/client/uploads'; 8 | import { fetchFile, getImageUrlByMd5 } from '../utils/urls'; 9 | import Instance from '../models/Instance'; 10 | 11 | const REGEX = /^\/start (file|flash)-(\d+)$/; 12 | 13 | export default class FileAndFlashPhotoController { 14 | private readonly log: Logger; 15 | 16 | constructor(private readonly instance: Instance, 17 | private readonly tgBot: Telegram, 18 | private readonly oicq: OicqClient) { 19 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 20 | this.log = getLogger(`FileAndFlashPhotoController - ${instance.id}`); 21 | } 22 | 23 | private onTelegramMessage = async (message: Api.Message) => { 24 | if (!message.isPrivate || !message.message) return false; 25 | if (!REGEX.test(message.message)) return false; 26 | const exec = REGEX.exec(message.message); 27 | switch (exec[1]) { 28 | case 'file': 29 | await this.handleFile(message, Number(exec[2])); 30 | break; 31 | case 'flash': 32 | await this.handleFlashPhoto(message, Number(exec[2])); 33 | break; 34 | } 35 | return true; 36 | }; 37 | 38 | private async handleFile(message: Api.Message, id: number) { 39 | try { 40 | const fileInfo = await db.file.findFirst({ 41 | where: { id }, 42 | }); 43 | const downloadUrl = await this.oicq.getChat(Number(fileInfo.roomId)).getFileUrl(fileInfo.fileId); 44 | await message.reply({ 45 | message: fileInfo.info + `\n下载`, 46 | }); 47 | } 48 | catch (e) { 49 | this.log.error('获取文件下载地址失败', e); 50 | await message.reply({ 51 | message: `获取文件下载地址失败:${e.message}\n${e}`, 52 | }); 53 | } 54 | } 55 | 56 | private async handleFlashPhoto(message: Api.Message, id: number) { 57 | try { 58 | const photoInfo = await db.flashPhoto.findFirst({ 59 | where: { id }, 60 | }); 61 | const viewInfo = await db.flashPhotoView.findFirst({ 62 | where: { flashPhotoId: id, viewerId: message.senderId.valueOf() }, 63 | }); 64 | if (viewInfo) { 65 | await message.reply({ message: '你已经查看过了' }); 66 | return; 67 | } 68 | const file = await fetchFile(getImageUrlByMd5(photoInfo.photoMd5)); 69 | const user = await this.tgBot.getChat(message.senderId); 70 | await user.sendSelfDestructingPhoto({}, 71 | new CustomFile('photo.jpg', file.length, '', file), 72 | 5); 73 | await db.flashPhotoView.create({ 74 | data: { flashPhotoId: id, viewerId: message.senderId.valueOf() }, 75 | }); 76 | } 77 | catch (e) { 78 | this.log.error('获取闪照失败', e); 79 | await message.reply({ 80 | message: `获取闪照失败:${e.message}\n${e}`, 81 | }); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:labs 2 | 3 | FROM node:18-slim AS base 4 | RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache 5 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 6 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 7 | apt update && apt-get --no-install-recommends install -y \ 8 | fonts-wqy-microhei \ 9 | libpixman-1-0 libcairo2 libpango1.0-0 libgif7 libjpeg62-turbo libpng16-16 librsvg2-2 libvips42 ffmpeg librlottie0-1 10 | ENV PNPM_HOME="/pnpm" 11 | ENV PATH="$PNPM_HOME:$PATH" 12 | RUN corepack enable 13 | WORKDIR /app 14 | 15 | FROM base AS build 16 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 17 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 18 | apt update && apt-get --no-install-recommends install -y \ 19 | python3 build-essential pkg-config \ 20 | libpixman-1-dev libcairo2-dev libpango1.0-dev libgif-dev libjpeg62-turbo-dev libpng-dev librsvg2-dev libvips-dev 21 | COPY pnpm-workspace.yaml package.json pnpm-lock.yaml /app/ 22 | COPY patches /app/patches 23 | COPY main/package.json /app/main/ 24 | 25 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store,sharing=locked \ 26 | --mount=type=secret,id=npmrc,target=/root/.npmrc \ 27 | pnpm install --frozen-lockfile 28 | COPY main/src main/tsconfig.json /app/main/ 29 | COPY main/prisma /app/main/ 30 | RUN cd main && pnpm exec prisma generate 31 | RUN cd main && pnpm run build 32 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store,sharing=locked \ 33 | --mount=type=secret,id=npmrc,target=/root/.npmrc \ 34 | pnpm deploy --filter=q2tg-main --prod deploy 35 | 36 | FROM debian:bookworm-slim AS tgs-to-gif-build 37 | ADD https://github.com/conan-io/conan/releases/download/1.61.0/conan-ubuntu-64.deb /tmp/conan.deb 38 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 39 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 40 | apt update && apt-get --no-install-recommends install -y \ 41 | python3 build-essential pkg-config cmake librlottie-dev zlib1g-dev /tmp/conan.deb 42 | 43 | ADD https://github.com/ed-asriyan/lottie-converter.git#f626548ced4492235b535552e2449be004a3a435 /app 44 | WORKDIR /app 45 | RUN sed -i 's@zlib/1.2.11@@g' conanfile.txt 46 | RUN conan install . 47 | RUN sed -i 's/\${CONAN_LIBS}/z/g' CMakeLists.txt 48 | RUN cmake CMakeLists.txt && make 49 | 50 | FROM base AS build-front 51 | COPY pnpm-workspace.yaml package.json pnpm-lock.yaml /app/ 52 | COPY patches /app/patches 53 | COPY ui/package.json /app/ui/ 54 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store,sharing=locked pnpm install --frozen-lockfile 55 | COPY ui/index.html ui/tsconfig.json ui/vite.config.ts /app/ui/ 56 | COPY ui/src /app/ui/src 57 | RUN cd ui && pnpm run build 58 | 59 | FROM base 60 | 61 | COPY --from=tgs-to-gif-build /app/bin/tgs_to_gif /usr/local/bin/tgs_to_gif 62 | ENV TGS_TO_GIF=/usr/local/bin/tgs_to_gif 63 | 64 | COPY main/assets /app/assets 65 | 66 | COPY --from=build /app/deploy /app 67 | COPY main/prisma /app/ 68 | RUN pnpm exec prisma generate 69 | COPY --from=build-front /app/ui/dist /app/front 70 | ENV UI_PATH=/app/front 71 | 72 | ENV DATA_DIR=/app/data 73 | EXPOSE 8080 74 | CMD pnpm start 75 | -------------------------------------------------------------------------------- /main/src/constants/commands.ts: -------------------------------------------------------------------------------- 1 | import { Api } from 'telegram'; 2 | 3 | const preSetupCommands = [ 4 | new Api.BotCommand({ 5 | command: 'setup', 6 | description: '执行初始化配置', 7 | }), 8 | ]; 9 | 10 | // 这里的 group 指群组模式,Private 指在与机器人的私聊会话中 11 | const groupPrivateCommands = [ 12 | new Api.BotCommand({ 13 | command: 'add', 14 | description: '添加新的群转发', 15 | }), 16 | ]; 17 | 18 | const personalPrivateCommands = [ 19 | new Api.BotCommand({ 20 | command: 'addfriend', 21 | description: '添加新的好友转发', 22 | }), 23 | new Api.BotCommand({ 24 | command: 'addgroup', 25 | description: '添加新的群转发', 26 | }), 27 | new Api.BotCommand({ 28 | command: 'login', 29 | description: '当 QQ 处于下线状态时,使用此命令重新登录 QQ', 30 | }), 31 | new Api.BotCommand({ 32 | command: 'flags', 33 | description: 'WARNING: EXPERIMENTAL FEATURES AHEAD!', 34 | }), 35 | ]; 36 | 37 | // 服务器零号实例的管理员 38 | const groupPrivateSuperAdminCommands = [ 39 | ...groupPrivateCommands, 40 | new Api.BotCommand({ 41 | command: 'newinstance', 42 | description: '创建一个新的转发机器人实例', 43 | }), 44 | ]; 45 | 46 | const personalPrivateSuperAdminCommands = [ 47 | ...personalPrivateCommands, 48 | new Api.BotCommand({ 49 | command: 'newinstance', 50 | description: '创建一个新的转发机器人实例', 51 | }), 52 | ]; 53 | 54 | // inChat 表示在关联了的转发群组中的命令 55 | const inChatCommands = [ 56 | new Api.BotCommand({ 57 | command: 'info', 58 | description: '查看本群或选定消息的详情', 59 | }), 60 | new Api.BotCommand({ 61 | command: 'search', 62 | description: '搜索消息', 63 | }), 64 | new Api.BotCommand({ 65 | command: 'q', 66 | description: '生成 QuotLy 图片', 67 | }), 68 | ]; 69 | 70 | const groupInChatCommands = [ 71 | ...inChatCommands, 72 | new Api.BotCommand({ 73 | command: 'forwardoff', 74 | description: '暂停消息转发', 75 | }), 76 | new Api.BotCommand({ 77 | command: 'forwardon', 78 | description: '恢复消息转发', 79 | }), 80 | new Api.BotCommand({ command: 'disable_qq_forward', description: '停止从QQ转发至TG' }), 81 | new Api.BotCommand({ command: 'enable_qq_forward', description: '恢复从QQ转发至TG' }), 82 | new Api.BotCommand({ command: 'disable_tg_forward', description: '停止从TG转发至QQ' }), 83 | new Api.BotCommand({ command: 'enable_tg_forward', description: '恢复从TG转发至QQ' }), 84 | new Api.BotCommand({ 85 | command: 'recover', 86 | description: '恢复离线期间的 QQ 消息记录到 TG(不稳定功能,管理员专用)', 87 | }), 88 | ]; 89 | 90 | const personalInChatCommands = [ 91 | ...inChatCommands, 92 | new Api.BotCommand({ 93 | command: 'refresh', 94 | description: '刷新头像和简介', 95 | }), 96 | new Api.BotCommand({ 97 | command: 'poke', 98 | description: '戳一戳', 99 | }), 100 | new Api.BotCommand({ 101 | command: 'nick', 102 | description: '获取/设置群名片', 103 | }), 104 | new Api.BotCommand({ 105 | command: 'mute', 106 | description: '设置 QQ 成员禁言', 107 | }), 108 | ]; 109 | 110 | export default { 111 | preSetupCommands, 112 | groupPrivateCommands, 113 | personalPrivateCommands, 114 | groupPrivateSuperAdminCommands, 115 | personalPrivateSuperAdminCommands, 116 | groupInChatCommands, 117 | personalInChatCommands, 118 | }; 119 | -------------------------------------------------------------------------------- /main/assets/richHeader.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% const now = new Date() %> 6 | 8 | <% if (!!title) { %> 9 | 10 | <% }else { %> 11 | 12 | <% } %> 13 | 14 | 群成员:<%= name %> 15 | 91 | 92 | 93 |
94 | 头像 95 |
96 |
97 | <%= title || role %> 98 | <%= name %> 99 |
100 | <% if(nickname !== name) { %> 101 |
<%= nickname %>
102 | <% } %> 103 |
<%= userId %> 104 | <% if(qid){ %> 105 | QID: <%= qid %> 106 | <% } %> 107 | <% if(email){ %> 108 | <%= email %> 109 | <% } %> 110 |
111 | <% if(location) { %> 112 |
<%= location %>
113 | <% } %> 114 | <% if(birthday) { %> 115 |
116 |
生日
117 | <%= birthday %> 118 |
119 | <% } %> 120 |
121 |
加入时间
122 | <%= joinTime %> 123 |
124 |
125 |
上次发言时间
126 | <%= lastSentTime %> 127 |
128 |
129 |
注册时间
130 | <%= regTime %> 131 |
132 |
133 |
134 | 135 | 136 | -------------------------------------------------------------------------------- /main/src/helpers/convert.ts: -------------------------------------------------------------------------------- 1 | import dataPath from './dataPath'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import sharp from 'sharp'; 5 | import { file as createTempFile } from 'tmp-promise'; 6 | import fsP from 'fs/promises'; 7 | import convertWithFfmpeg from '../encoding/convertWithFfmpeg'; 8 | import tgsToGif from '../encoding/tgsToGif'; 9 | 10 | const CACHE_PATH = dataPath('cache'); 11 | fs.mkdirSync(CACHE_PATH, { recursive: true }); 12 | 13 | // 首先查找缓存,要是缓存中没有的话执行第二个参数的方法转换到缓存的文件 14 | const cachedConvert = async (key: string, convert: (outputPath: string) => Promise) => { 15 | const convertedPath = path.join(CACHE_PATH, key); 16 | if (!fs.existsSync(convertedPath)) { 17 | await convert(convertedPath); 18 | } 19 | return convertedPath; 20 | }; 21 | 22 | const convert = { 23 | cached: cachedConvert, 24 | cachedBuffer: (key: string, buf: () => Promise) => 25 | cachedConvert(key, async (convertedPath) => { 26 | await fsP.writeFile(convertedPath, await buf()); 27 | }), 28 | // webp2png,这里 webpData 是方法因为不需要的话就不获取了 29 | png: (key: string, webpData: () => Promise) => 30 | cachedConvert(key + '.png', async (convertedPath) => { 31 | await sharp(await webpData()).png().toFile(convertedPath); 32 | }), 33 | webm2gif: (key: string, webmData: () => Promise) => 34 | cachedConvert(key + '.gif', async (convertedPath) => { 35 | const temp = await createTempFile(); 36 | await fsP.writeFile(temp.path, await webmData()); 37 | await convertWithFfmpeg(temp.path, convertedPath, 'gif'); 38 | await temp.cleanup(); 39 | }), 40 | tgs2gif: (key: string, tgsData: () => Promise) => 41 | cachedConvert(key + '.gif', async (convertedPath) => { 42 | const tempTgsPath = path.join(CACHE_PATH, key); 43 | await fsP.writeFile(tempTgsPath, await tgsData()); 44 | await tgsToGif(tempTgsPath); 45 | await fsP.rm(tempTgsPath); 46 | }), 47 | webp: (key: string, imageData: () => Promise) => 48 | cachedConvert(key + '.webp', async (convertedPath) => { 49 | await sharp(await imageData()).webp().toFile(convertedPath); 50 | }), 51 | customEmoji: async (key: string, imageData: () => Promise, useSmallSize: boolean) => { 52 | if (useSmallSize) { 53 | const pathPng = path.join(CACHE_PATH, key + '@50.png'); 54 | const pathGif = path.join(CACHE_PATH, key + '@50.gif'); 55 | if (fs.existsSync(pathPng)) return pathPng; 56 | if (fs.existsSync(pathGif)) return pathGif; 57 | } 58 | else { 59 | const pathPng = path.join(CACHE_PATH, key + '.png'); 60 | const pathGif = path.join(CACHE_PATH, key + '.gif'); 61 | if (fs.existsSync(pathPng)) return pathPng; 62 | if (fs.existsSync(pathGif)) return pathGif; 63 | } 64 | // file not found 65 | const data = await imageData() as Buffer; 66 | const { fileTypeFromBuffer } = await (Function('return import("file-type")')() as Promise); 67 | const fileType = (await fileTypeFromBuffer(data))?.mime || 'image/'; 68 | let pathPngOrig: string, pathGifOrig: string; 69 | if (fileType.startsWith('image/')) { 70 | pathPngOrig = await convert.png(key, () => Promise.resolve(data)); 71 | } 72 | else { 73 | pathGifOrig = await convert.tgs2gif(key, () => Promise.resolve(data)); 74 | } 75 | if (!useSmallSize) return pathPngOrig || pathGifOrig; 76 | if (pathPngOrig) { 77 | return await cachedConvert(key + '@50.png', async (convertedPath) => { 78 | await sharp(pathPngOrig).resize(50).toFile(convertedPath); 79 | }); 80 | } 81 | else { 82 | return await cachedConvert(key + '@50.gif', async (convertedPath) => { 83 | await sharp(pathGifOrig).resize(50).toFile(convertedPath); 84 | }); 85 | } 86 | }, 87 | }; 88 | 89 | export default convert; 90 | -------------------------------------------------------------------------------- /main/src/services/SetupService.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import { getLogger, Logger } from 'log4js'; 3 | import { BigInteger } from 'big-integer'; 4 | import { Platform } from '@icqqjs/icqq'; 5 | import { MarkupLike } from 'telegram/define'; 6 | import OicqClient from '../client/OicqClient'; 7 | import { Button } from 'telegram/tl/custom/button'; 8 | import { CustomFile } from 'telegram/client/uploads'; 9 | import { WorkMode } from '../types/definitions'; 10 | import TelegramChat from '../client/TelegramChat'; 11 | import Instance from '../models/Instance'; 12 | import db from '../models/db'; 13 | 14 | export default class SetupService { 15 | private owner: TelegramChat; 16 | private readonly log: Logger; 17 | 18 | constructor(private readonly instance: Instance, 19 | private readonly tgBot: Telegram) { 20 | this.log = getLogger(`SetupService - ${instance.id}`); 21 | } 22 | 23 | public setWorkMode(mode: WorkMode) { 24 | this.instance.workMode = mode; 25 | } 26 | 27 | /** 28 | * 在设置阶段,第一个 start bot 的用户成为 bot 主人 29 | * @param userId 申请成为主人的用户 ID 30 | * @return {boolean} 是否成功,false 的话就是被占用了 31 | */ 32 | public async claimOwner(userId: number | BigInteger) { 33 | userId = Number(userId); 34 | if (!this.owner) { 35 | this.instance.owner = userId; 36 | await this.setupOwner(); 37 | this.log.info(`用户 ID: ${userId} 成为了 Bot 主人`); 38 | return true; 39 | } 40 | return false; 41 | } 42 | 43 | private async setupOwner() { 44 | if (!this.owner && this.instance.owner) { 45 | this.owner = await this.tgBot.getChat(this.instance.owner); 46 | } 47 | } 48 | 49 | public async informOwner(message: string, buttons?: MarkupLike) { 50 | if (!this.owner) { 51 | throw new Error('应该不会运行到这里'); 52 | } 53 | return await this.owner.sendMessage({ message, buttons: buttons || Button.clear(), linkPreview: false }); 54 | } 55 | 56 | public async waitForOwnerInput(message?: string, buttons?: MarkupLike, remove = false) { 57 | if (!this.owner) { 58 | throw new Error('应该不会运行到这里'); 59 | } 60 | message && await this.informOwner(message, buttons); 61 | const reply = await this.owner.waitForInput(); 62 | remove && await reply.delete({ revoke: true }); 63 | return reply.message; 64 | } 65 | 66 | public async createUserBot(phoneNumber: string) { 67 | if (!this.owner) { 68 | throw new Error('应该不会运行到这里'); 69 | } 70 | return await Telegram.create({ 71 | phoneNumber, 72 | password: async (hint?: string) => { 73 | return await this.waitForOwnerInput( 74 | `请输入你的二步验证密码${hint ? '\n密码提示:' + hint : ''}`, undefined, true); 75 | }, 76 | phoneCode: async (isCodeViaApp?: boolean) => { 77 | await this.informOwner(`请输入你${isCodeViaApp ? ' Telegram APP 中' : '手机上'}收到的验证码\n` + 78 | '👇请使用下面的按钮输入,不要在文本框输入,否则验证码会发不出去并立即失效', 79 | Button.text('👆请使用上面的按钮输入', true, true)); 80 | return await this.owner.inlineDigitInput(5); 81 | }, 82 | onError: (err) => this.log.error(err), 83 | }); 84 | } 85 | 86 | public async createOicq(uin: number, password: string, platform: Platform, signApi: string, signVer: string) { 87 | const dbQQBot = await db.qqBot.create({ data: { uin, password, platform, signApi, signVer } }); 88 | return await OicqClient.create({ 89 | id: dbQQBot.id, 90 | uin, password, platform, signApi, signVer, 91 | onVerifyDevice: async (phone) => { 92 | return await this.waitForOwnerInput(`请输入手机 ${phone} 收到的验证码`); 93 | }, 94 | onVerifySlider: async (url) => { 95 | return await this.waitForOwnerInput(`收到滑块验证码 ${url}\n` + 96 | '请使用此软件验证并输入 Ticket'); 97 | }, 98 | }); 99 | } 100 | 101 | public async finishConfig() { 102 | this.instance.isSetup = true; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /main/src/models/TelegramSession.ts: -------------------------------------------------------------------------------- 1 | import { MemorySession } from 'telegram/sessions'; 2 | import db from './db'; 3 | import { AuthKey } from 'telegram/crypto/AuthKey'; 4 | import { getLogger, Logger } from 'log4js'; 5 | import env from './env'; 6 | 7 | const PASS = () => 0; 8 | 9 | export default class TelegramSession extends MemorySession { 10 | private log: Logger; 11 | 12 | constructor(private _dbId?: number) { 13 | super(); 14 | this.log = getLogger(`TelegramSession - ${_dbId}`); 15 | } 16 | 17 | public get dbId() { 18 | return this._dbId; 19 | } 20 | 21 | async load() { 22 | this.log.trace('load'); 23 | if (env.TG_INITIAL_DCID) { 24 | this._dcId = env.TG_INITIAL_DCID; 25 | } 26 | if (env.TG_INITIAL_SERVER) { 27 | this._serverAddress = env.TG_INITIAL_SERVER; 28 | } 29 | if (!this._dbId) { 30 | this.log.debug('Session 不存在,创建'); 31 | // 创建并返回 32 | const newDbEntry = await db.session.create({ 33 | data: { 34 | dcId: env.TG_INITIAL_DCID, 35 | serverAddress: env.TG_INITIAL_SERVER, 36 | }, 37 | }); 38 | this._dbId = newDbEntry.id; 39 | this.log = getLogger(`TelegramSession - ${this._dbId}`); 40 | return; 41 | } 42 | const dbEntry = await db.session.findFirst({ 43 | where: { id: this._dbId }, 44 | include: { entities: true }, 45 | }); 46 | 47 | const { authKey, dcId, port, serverAddress } = dbEntry; 48 | 49 | if (authKey && typeof authKey === 'object') { 50 | this._authKey = new AuthKey(); 51 | await this._authKey.setKey(authKey); 52 | } 53 | if (dcId) { 54 | this._dcId = dcId; 55 | } 56 | if (port) { 57 | this._port = port; 58 | } 59 | if (serverAddress) { 60 | this._serverAddress = serverAddress; 61 | } 62 | 63 | // id, hash, username, phone, name 64 | this._entities = new Set( 65 | dbEntry.entities.map(e => [e.entityId, e.hash, e.username, e.phone, e.name])); 66 | } 67 | 68 | setDC(dcId: number, serverAddress: string, port: number) { 69 | this.log.trace('setDC', dcId, serverAddress, port); 70 | super.setDC(dcId, serverAddress, port); 71 | db.session.update({ 72 | where: { id: this._dbId }, 73 | data: { dcId, serverAddress, port }, 74 | }) 75 | .then(e => this.log.trace('DC update result', e)) 76 | .catch(PASS); 77 | } 78 | 79 | set authKey(value: AuthKey | undefined) { 80 | this.log.trace('authKey', value); 81 | this._authKey = value; 82 | db.session.update({ 83 | where: { id: this._dbId }, 84 | data: { authKey: value?.getKey() || null }, 85 | }) 86 | .then(e => this.log.trace('authKey update result', e)) 87 | .catch(PASS); 88 | } 89 | 90 | get authKey() { 91 | return this._authKey; 92 | } 93 | 94 | processEntities(tlo: any) { 95 | this.log.trace('processEntities'); 96 | const entitiesSet = this._entitiesToRows(tlo); 97 | for (const e of entitiesSet) { 98 | this.log.trace('processEntity', e); 99 | this._entities.add(e); 100 | db.entity.upsert({ 101 | // id, hash, username, phone, name 102 | where: { 103 | entityId_sessionId: { sessionId: this._dbId, entityId: e[0].toString() }, 104 | }, 105 | create: { 106 | sessionId: this._dbId, 107 | entityId: e[0] && e[0].toString(), 108 | hash: e[1] && e[1].toString(), 109 | username: e[2] && e[2].toString(), 110 | phone: e[3] && e[3].toString(), 111 | name: e[4] && e[4].toString(), 112 | }, 113 | update: { 114 | hash: e[1] && e[1].toString(), 115 | username: e[2] && e[2].toString(), 116 | phone: e[3] && e[3].toString(), 117 | name: e[4] && e[4].toString(), 118 | }, 119 | }) 120 | .then(e => this.log.trace('Entity update result', e)) 121 | .catch(PASS); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /main/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Session { 14 | id Int @id @default(autoincrement()) 15 | dcId Int? 16 | port Int? 17 | serverAddress String? 18 | authKey Bytes? 19 | entities Entity[] 20 | } 21 | 22 | model Entity { 23 | id Int @id @default(autoincrement()) 24 | // 源代码里面大概支持 string 和 BigInteger,不如先全都存 String 25 | entityId String 26 | sessionId Int 27 | session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) 28 | hash String? 29 | username String? 30 | phone String? 31 | name String? 32 | 33 | @@unique([entityId, sessionId]) 34 | } 35 | 36 | model Instance { 37 | id Int @id @default(autoincrement()) 38 | owner BigInt @default(0) 39 | workMode String @default("") 40 | isSetup Boolean @default(false) 41 | Message Message[] 42 | ForwardPair ForwardPair[] 43 | botSessionId Int? 44 | userSessionId Int? 45 | qqBotId Int? 46 | qqBot QqBot? @relation(fields: [qqBotId], references: [id], onDelete: Cascade) 47 | reportUrl String? 48 | flags Int @default(0) 49 | } 50 | 51 | model QqBot { 52 | id Int @id @default(autoincrement()) 53 | uin BigInt @default(0) 54 | password String @default("") 55 | platform Int @default(0) 56 | Instance Instance[] 57 | signApi String? 58 | signVer String? 59 | signDockerId String? 60 | } 61 | 62 | model Message { 63 | id Int @id @default(autoincrement()) 64 | qqRoomId BigInt @db.BigInt 65 | qqSenderId BigInt @db.BigInt 66 | time Int 67 | brief String? 68 | seq Int 69 | rand BigInt @db.BigInt 70 | pktnum Int 71 | tgChatId BigInt @db.BigInt 72 | tgMsgId Int 73 | instanceId Int @default(0) 74 | instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) 75 | tgFileId BigInt? @db.BigInt 76 | tgMessageText String? 77 | nick String? // /抱 的时候会用到 78 | tgSenderId BigInt? @db.BigInt 79 | richHeaderUsed Boolean @default(false) 80 | 81 | @@index([qqRoomId, qqSenderId, seq, rand, pktnum, time, instanceId]) 82 | @@index([tgChatId, tgMsgId, instanceId]) 83 | } 84 | 85 | model ForwardPair { 86 | id Int @id @default(autoincrement()) 87 | qqRoomId BigInt @db.BigInt 88 | tgChatId BigInt @db.BigInt 89 | avatarCache AvatarCache[] 90 | instanceId Int @default(0) 91 | instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) 92 | flags Int @default(0) 93 | apiKey String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid 94 | 95 | @@unique([qqRoomId, instanceId]) 96 | @@unique([tgChatId, instanceId]) 97 | } 98 | 99 | model File { 100 | id Int @id @default(autoincrement()) 101 | roomId BigInt @db.BigInt 102 | fileId String 103 | info String 104 | } 105 | 106 | model FlashPhoto { 107 | id Int @id @default(autoincrement()) 108 | photoMd5 String 109 | views FlashPhotoView[] 110 | } 111 | 112 | model FlashPhotoView { 113 | id Int @id @default(autoincrement()) 114 | flashPhotoId Int 115 | flashPhoto FlashPhoto @relation(fields: [flashPhotoId], references: [id]) 116 | viewerId BigInt @db.BigInt 117 | 118 | @@unique([flashPhotoId, viewerId]) 119 | } 120 | 121 | model AvatarCache { 122 | id Int @id @default(autoincrement()) 123 | forwardPair ForwardPair @relation(fields: [forwardPairId], references: [id], onDelete: Cascade) 124 | forwardPairId Int @unique 125 | hash Bytes 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Q2TG - without User Bot 2 | 3 | A **fork** of [Clansty/Q2TG](https://github.com/Clansty/Q2TG). Deleted UserBot function. 4 | 5 | 交流 6 | 7 | QQ 群与 Telegram 群相互转发的 bot,但是去除了 _UserBot_ 模式,再也不用担心杜叔叔封 UserBot 啦! 8 | 9 | ## 为什么不需要 User Bot 10 | 11 | [Clansty/Q2TG#74](https://github.com/Clansty/Q2TG/issues/74) [Clansty/Q2TG#80](https://github.com/Clansty/Q2TG/issues/80) [Clansty/Q2TG#83](https://github.com/Clansty/Q2TG/issues/83) 12 | 13 | 首先,并不是说 User Bot 不好,~~如果杜叔叔不瞎几把封号那其实无所谓,但我已经被封了两个 `+1` 的 Telegram Account 了。~~ 但是对于那些不需要个人模式,愿意舍弃 Telegram 消息撤回检测,且想体验 `rainbowcat` 的新功能的用户来说,User Bot 的配置略显多余,但 User Bot 在 `rainbowcat` 中被写死在代码中,而不是像 v1 中一样作为可选功能安装,而 `rainbowcat` 中在部署时必须配置 User Bot,于是便有了这个 fork。 14 | 15 | 需要注意的是,此 fork 中个人模式**几乎不可用**,而群聊模式中需要 User Bot 功能实现的功能也都无法使用。 16 | 17 | ![image](https://user-images.githubusercontent.com/49985975/213389640-350764fc-8932-4db3-bd83-f4c80df34912.png) 18 | 19 | ## 安装 / 迁移 20 | 21 | 请看 [Wiki](https://github.com/Clansty/Q2TG/wiki/%E5%AE%89%E8%A3%85%E9%83%A8%E7%BD%B2),与上游相同。 22 | 23 | 请注意修改 [`docker-compose.yaml`](https://raw.githubusercontent.com/Nofated095/Q2TG/rainbowcat/docker-compose.yaml),启动命令 `docker compose up -d`。 24 | 25 | 如果你事先部署过上游的 Q2TG 实例,建议通过 `docker stop main_q2tg` 停止服务。你可以直接修改原先的 `docker-compose.yaml` 中 _services - q2tg - image_ 为 `ghcr.io/nofated095/q2tg:rainbowcat` 26 | 27 | ```yaml 28 | version: "3.8" 29 | services: 30 | # 如果有现成的 Postgresql 实例,可以删除这一小节 31 | postgres: 32 | image: postgres 33 | container_name: postgresql_q2tg 34 | restart: unless-stopped 35 | environment: 36 | POSTGRES_DB: db_name 37 | POSTGRES_USER: user 38 | POSTGRES_PASSWORD: password 39 | volumes: 40 | - ./postgresql:/var/lib/postgresql/data 41 | q2tg: 42 | image: ghcr.io/nofated095/q2tg:rainbowcat 43 | container_name: main_q2tg 44 | restart: unless-stopped 45 | depends_on: 46 | - postgres 47 | volumes: 48 | - ./data:/app/data 49 | environment: 50 | - TG_API_ID= 51 | - TG_API_HASH= 52 | - TG_BOT_TOKEN= 53 | - DATABASE_URL=postgres://user:password@postgres/db_name 54 | - CRV_API= 55 | - CRV_KEY= 56 | # 如果需要通过代理联网,那么设置下面两个变量 57 | #- PROXY_IP= 58 | #- PROXY_PORT= 59 | ``` 60 | 61 | ## 支持的消息类型 62 | 63 | - [x] 文字(双向) 64 | - [x] 图片(双向) 65 | - [x] GIF 66 | - [x] 闪照 67 | 68 | 闪照每个 TG 用户也只能查看 5 秒 69 | - [x] 图文混排消息(双向) 70 | - [x] 大表情(双向) 71 | - [x] TG 中的动态 Sticker 72 | 73 | 目前是[转换成 GIF](https://github.com/ed-asriyan/tgs-to-gif) 发送的,并且可能有些[问题](https://github.com/ed-asriyan/tgs-to-gif/issues/13#issuecomment-633244547) 74 | - [x] 视频(双向) 75 | - [x] 语音(双向) 76 | - [x] 小表情(可显示为文字) 77 | - [x] 链接(双向) 78 | - [x] JSON/XML 卡片 79 | 80 | (包括部分转化为小程序的链接) 81 | - [x] 位置(TG -> QQ) 82 | - [x] 群公告 83 | - [x] 回复(双平台原生回复) 84 | - [x] 文件 85 | 86 | QQ -> TG 按需获取下载地址 87 | 88 | TG -> QQ 将自动转发 20M 以下的小文件 89 | - [x] 转发多条消息记录 90 | - [x] TG 编辑消息(撤回再重发) 91 | - [x] 双向撤回消息 92 | - [x] 戳一戳 93 | 94 | ## 关于模式 95 | 96 | ### 群组模式 97 | 98 | 群组模式就是 1.x 版本唯一的模式,是给群主使用的。如果群组想要使自己的 QQ 群和 Telegram 群联通起来,就使用这个模式。群组模式只可以给群聊配置转发,并且转发消息时会带上用户在当前平台的发送者名称。 99 | 100 | >### 个人模式 101 | > 102 | >个人模式适合 QQ 轻度使用者,TG 重度使用者。可以把 QQ 的好友和群聊搬到 Telegram 中。个人模式一定要登录机器人主人自己的 Telegram 账号作为 UserBot。可以自动为 QQ 中的好友和群组创建对应的 Telegram 群组,并同步头像简介等信息。当有没有创建关联的好友发起私聊的时候会自动创建 Telegram 中的对应群组。个人模式在初始化的时候会自动在 Telegram 个人账号中创建一个文件夹来存储所有来自 QQ 的对应群组。消息在从 TG 转发到 QQ 时不会带上发送者昵称,因为默认发送者只有一个人。 103 | > 104 | >不幸的,因为 User Bot 在此分支被残忍的删除,所以虽然没有测试个人模式,但是想想就知道个人模式在没有 User Bot 的情况下是几乎完全废的。 105 | 106 | ## 如何撤回消息 107 | 108 | 在 QQ 中,直接撤回相应的消息,撤回操作会同步到 TG 109 | 110 | 在 TG 中,可以选择以下操作之一: 111 | 112 | - 将消息内容编辑为 `/rm` 113 | - 回复要撤回的消息,内容为 `/rm`。如果操作者在 TG 群组中没有「删除消息」权限,则只能撤回自己的消息 114 | >- 如果正确配置了个人账号的 User Bot,可以直接删除消息 115 | > 116 | >正确的,但由于此分支删除了 User Bot 功能,所以无法直接删除。 117 | 118 | 为了使撤回功能正常工作,TG 机器人需要具有「删除消息」权限,QQ 机器人需要为管理员或群主 119 | 120 | 即使 QQ 机器人为管理员,也无法撤回其他管理员在 QQ 中发送的消息 121 | 122 | ## 免责声明 123 | 124 | 一切开发旨在学习,请勿用于非法用途。本项目完全免费开源,不会收取任何费用,无任何担保。请勿将本项目用于商业用途。由于使用本程序造成的任何问题,由使用者自行承担,项目开发者不承担任何责任。 125 | 126 | 本项目基于 AGPL 发行。修改、再发行和运行服务需要遵守 AGPL 许可证,源码需要和服务一起提供。 127 | 128 | ## 许可证 129 | 130 | ``` 131 | This program is free software: you can redistribute it and/or modify 132 | it under the terms of the GNU Affero General Public License as 133 | published by the Free Software Foundation, either version 3 of the 134 | License, or (at your option) any later version. 135 | 136 | This program is distributed in the hope that it will be useful, 137 | but WITHOUT ANY WARRANTY; without even the implied warranty of 138 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 139 | GNU Affero General Public License for more details. 140 | 141 | You should have received a copy of the GNU Affero General Public License 142 | along with this program. If not, see . 143 | ``` 144 | -------------------------------------------------------------------------------- /main/src/controllers/InChatCommandsController.ts: -------------------------------------------------------------------------------- 1 | import InChatCommandsService from '../services/InChatCommandsService'; 2 | import { getLogger, Logger } from 'log4js'; 3 | import Instance from '../models/Instance'; 4 | import Telegram from '../client/Telegram'; 5 | import OicqClient from '../client/OicqClient'; 6 | import { Api } from 'telegram'; 7 | import { Group } from '@icqqjs/icqq'; 8 | import RecoverMessageHelper from '../helpers/RecoverMessageHelper'; 9 | import flags from '../constants/flags'; 10 | import { editFlags } from '../utils/flagControl'; 11 | 12 | export default class InChatCommandsController { 13 | private readonly service: InChatCommandsService; 14 | private readonly log: Logger; 15 | 16 | constructor( 17 | private readonly instance: Instance, 18 | private readonly tgBot: Telegram, 19 | private readonly oicq: OicqClient, 20 | ) { 21 | this.log = getLogger(`InChatCommandsController - ${instance.id}`); 22 | this.service = new InChatCommandsService(instance, tgBot, oicq); 23 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 24 | } 25 | 26 | private onTelegramMessage = async (message: Api.Message) => { 27 | if (!message.message) return; 28 | const messageParts = message.message.split(' '); 29 | if (!messageParts.length || !messageParts[0].startsWith('/')) return; 30 | let command: string = messageParts.shift(); 31 | const params = messageParts.join(' '); 32 | if (command.includes('@')) { 33 | let target: string; 34 | [command, target] = command.split('@'); 35 | if (target !== this.tgBot.me.username) return false; 36 | } 37 | const pair = this.instance.forwardPairs.find(message.chat); 38 | if (!pair) return false; 39 | switch (command) { 40 | case '/info': 41 | await this.service.info(message, pair); 42 | return true; 43 | case '/poke': 44 | await this.service.poke(message, pair); 45 | return true; 46 | case '/unmute': 47 | messageParts.unshift('0'); 48 | case '/mute': 49 | if (this.instance.workMode !== 'personal' || !message.senderId?.eq(this.instance.owner)) return false; 50 | if (!(pair.qq instanceof Group)) return true; 51 | await this.service.mute(message, pair, messageParts); 52 | return true; 53 | case '/forwardoff': 54 | pair.flags |= flags.DISABLE_Q2TG | flags.DISABLE_TG2Q; 55 | await message.reply({ message: '转发已禁用' }); 56 | return true; 57 | case '/forwardon': 58 | pair.flags &= ~flags.DISABLE_Q2TG & ~flags.DISABLE_TG2Q; 59 | await message.reply({ message: '转发已启用' }); 60 | return true; 61 | case '/disable_qq_forward': 62 | pair.flags |= flags.DISABLE_Q2TG; 63 | await message.reply({ message: 'QQ->TG已禁用' }); 64 | return true; 65 | case '/enable_qq_forward': 66 | pair.flags &= ~flags.DISABLE_Q2TG; 67 | await message.reply({ message: 'QQ->TG已启用' }); 68 | return true; 69 | case '/disable_tg_forward': 70 | pair.flags |= flags.DISABLE_TG2Q; 71 | await message.reply({ message: 'TG->QQ已禁用' }); 72 | return true; 73 | case '/enable_tg_forward': 74 | pair.flags &= ~flags.DISABLE_TG2Q; 75 | await message.reply({ message: 'TG->QQ已启用' }); 76 | return true; 77 | case '/flags': 78 | case '/flag': 79 | if (!message.senderId.eq(this.instance.owner)) { 80 | await message.reply({ message: '权限不够' }); 81 | return true; 82 | } 83 | await message.reply({ 84 | message: await editFlags(messageParts, pair), 85 | }); 86 | return true; 87 | case '/refresh': 88 | if (this.instance.workMode !== 'personal' || !message.senderId?.eq(this.instance.owner)) return false; 89 | await pair.updateInfo(); 90 | await message.reply({ message: '刷新成功' }); 91 | return true; 92 | case '/nick': 93 | if (this.instance.workMode !== 'personal' || !message.senderId?.eq(this.instance.owner)) return false; 94 | if (!(pair.qq instanceof Group)) return true; 95 | if (!params) { 96 | await message.reply({ 97 | message: `群名片:${pair.qq.pickMember(this.instance.qqUin, true).card}`, 98 | }); 99 | return true; 100 | } 101 | const result = await pair.qq.setCard(this.instance.qqUin, params); 102 | await message.reply({ 103 | message: '设置' + (result ? '成功' : '失败'), 104 | }); 105 | return true; 106 | case '/recover': 107 | if (!message.senderId.eq(this.instance.owner)) return true; 108 | const helper = new RecoverMessageHelper(this.instance, this.tgBot, this.oicq, pair, message); 109 | helper.startRecover().then(() => this.log.info('恢复完成')); 110 | return true; 111 | case '/search': 112 | await message.reply({ 113 | message: await this.service.search(messageParts, pair), 114 | }); 115 | return true; 116 | } 117 | }; 118 | } 119 | -------------------------------------------------------------------------------- /main/src/controllers/SetupController.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import SetupService from '../services/SetupService'; 3 | import { Api } from 'telegram'; 4 | import { getLogger, Logger } from 'log4js'; 5 | import { Button } from 'telegram/tl/custom/button'; 6 | import setupHelper from '../helpers/setupHelper'; 7 | import commands from '../constants/commands'; 8 | import { WorkMode } from '../types/definitions'; 9 | import OicqClient from '../client/OicqClient'; 10 | import { md5Hex } from '../utils/hashing'; 11 | import Instance from '../models/Instance'; 12 | import env from '../models/env'; 13 | 14 | export default class SetupController { 15 | private readonly setupService: SetupService; 16 | private readonly log: Logger; 17 | private isInProgress = false; 18 | private waitForFinishCallbacks: Array<(ret: { oicq: OicqClient }) => unknown> = []; 19 | // 创建的 UserBot 20 | private oicq: OicqClient; 21 | 22 | constructor(private readonly instance: Instance, 23 | private readonly tgBot: Telegram) { 24 | this.log = getLogger(`SetupController - ${instance.id}`); 25 | this.setupService = new SetupService(this.instance, tgBot); 26 | tgBot.addNewMessageEventHandler(this.handleMessage); 27 | tgBot.setCommands(commands.preSetupCommands, new Api.BotCommandScopeUsers()); 28 | } 29 | 30 | private handleMessage = async (message: Api.Message) => { 31 | if (this.isInProgress || !message.isPrivate) { 32 | return false; 33 | } 34 | 35 | if (message.message === '/setup' || message.message === '/start setup' || message.message === '/start') { 36 | this.isInProgress = true; 37 | await this.doSetup(Number(message.sender.id)); 38 | await this.finishSetup(); 39 | return true; 40 | } 41 | 42 | return false; 43 | }; 44 | 45 | private async doSetup(ownerId: number) { 46 | // 设置 owner 47 | try { 48 | await this.setupService.claimOwner(ownerId); 49 | } 50 | catch (e) { 51 | this.log.error('Claim Owner 失败', e); 52 | this.isInProgress = false; 53 | throw e; 54 | } 55 | // 设置工作模式 56 | let workMode: WorkMode | '' = ''; 57 | try { 58 | while (!workMode) { 59 | const workModeText = await this.setupService.waitForOwnerInput('欢迎使用 Q2TG v2\n' + 60 | '请选择工作模式,关于工作模式的区别请查看这里', [ 61 | [Button.text('个人模式', true, true)], 62 | [Button.text('群组模式', true, true)], 63 | ]); 64 | workMode = setupHelper.convertTextToWorkMode(workModeText); 65 | } 66 | this.setupService.setWorkMode(workMode); 67 | } 68 | catch (e) { 69 | this.log.error('设置工作模式失败', e); 70 | this.isInProgress = false; 71 | throw e; 72 | } 73 | // 登录 oicq 74 | if (this.instance.qq) { 75 | await this.setupService.informOwner('正在登录已设置好的 QQ'); 76 | this.oicq = await OicqClient.create({ 77 | id: this.instance.qq.id, 78 | uin: Number(this.instance.qq.uin), 79 | password: this.instance.qq.password, 80 | platform: this.instance.qq.platform, 81 | signApi: this.instance.qq.signApi, 82 | signVer: this.instance.qq.signVer, 83 | signDockerId: this.instance.qq.signDockerId, 84 | onVerifyDevice: async (phone) => { 85 | return await this.setupService.waitForOwnerInput(`请输入手机 ${phone} 收到的验证码`); 86 | }, 87 | onVerifySlider: async (url) => { 88 | return await this.setupService.waitForOwnerInput(`收到滑块验证码 ${url}\n` + 89 | '请使用此软件验证并输入 Ticket', 90 | ); 91 | }, 92 | }); 93 | } 94 | else 95 | try { 96 | let uin = NaN; 97 | while (isNaN(uin)) { 98 | uin = Number(await this.setupService.waitForOwnerInput('请输入要登录 QQ 号')); 99 | } 100 | const platformText = await this.setupService.waitForOwnerInput('请选择登录协议', [ 101 | [Button.text('安卓手机', true, true)], 102 | [Button.text('安卓平板', true, true)], 103 | [Button.text('iPad', true, true)], 104 | ]); 105 | const platform = setupHelper.convertTextToPlatform(platformText); 106 | 107 | let signApi: string; 108 | 109 | if (!env.SIGN_API) { 110 | signApi = await this.setupService.waitForOwnerInput('请输入签名服务器地址', [ 111 | [Button.text('不需要签名服务器', true, true)], 112 | ]); 113 | signApi = setupHelper.checkSignApiAddress(signApi); 114 | } 115 | 116 | let signVer: string; 117 | 118 | if (signApi && !env.SIGN_VER) { 119 | signVer = await this.setupService.waitForOwnerInput('请输入签名服务器版本', [ 120 | [Button.text('8.9.63', true, true), 121 | Button.text('8.9.68', true, true)], 122 | [Button.text('8.9.70', true, true), 123 | Button.text('8.9.71', true, true), 124 | Button.text('8.9.73', true, true)], 125 | [Button.text('8.9.78', true, true), 126 | Button.text('8.9.83', true, true)], 127 | ]); 128 | } 129 | 130 | let password = await this.setupService.waitForOwnerInput('请输入密码', undefined, true); 131 | password = md5Hex(password); 132 | this.oicq = await this.setupService.createOicq(uin, password, platform, signApi, signVer); 133 | this.instance.qqBotId = this.oicq.id; 134 | await this.setupService.informOwner(`登录成功`); 135 | } 136 | catch (e) { 137 | this.log.error('登录 OICQ 失败', e); 138 | await this.setupService.informOwner(`登录失败\n${e.message}`); 139 | this.isInProgress = false; 140 | throw e; 141 | } 142 | // 登录 tg UserBot 143 | if (this.instance.userSessionId) { 144 | await this.setupService.informOwner(`UserBot 创建被跳过`); 145 | } 146 | else { 147 | await this.setupService.informOwner(`UserBot 创建被跳过`); 148 | } 149 | } 150 | 151 | private async finishSetup() { 152 | this.tgBot.removeNewMessageEventHandler(this.handleMessage); 153 | this.isInProgress = false; 154 | await this.setupService.finishConfig(); 155 | this.waitForFinishCallbacks.forEach(e => e({ 156 | oicq: this.oicq, 157 | })); 158 | } 159 | 160 | public waitForFinish() { 161 | return new Promise<{ oicq: OicqClient }>(resolve => { 162 | this.waitForFinishCallbacks.push(resolve); 163 | }); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /main/src/services/DeleteMessageService.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import { getLogger, Logger } from 'log4js'; 3 | import { Api } from 'telegram'; 4 | import db from '../models/db'; 5 | import { Friend, FriendRecallEvent, Group, GroupRecallEvent } from '@icqqjs/icqq'; 6 | import Instance from '../models/Instance'; 7 | import { Pair } from '../models/Pair'; 8 | import { consumer } from '../utils/highLevelFunces'; 9 | import forwardHelper from '../helpers/forwardHelper'; 10 | import flags from '../constants/flags'; 11 | 12 | export default class DeleteMessageService { 13 | private readonly log: Logger; 14 | private readonly lockIds = new Set(); 15 | 16 | constructor(private readonly instance: Instance, 17 | private readonly tgBot: Telegram) { 18 | this.log = getLogger(`DeleteMessageService - ${instance.id}`); 19 | } 20 | 21 | private lock(lockId: string) { 22 | if (this.lockIds.has(lockId)) { 23 | this.log.debug('重复执行消息撤回', lockId); 24 | return true; 25 | } 26 | this.lockIds.add(lockId); 27 | setTimeout(() => this.lockIds.delete(lockId), 5000); 28 | } 29 | 30 | // 500ms 内只撤回一条消息,防止频繁导致一部分消息没有成功撤回。不过这样的话,会得不到返回的结果 31 | private recallQqMessage = consumer(async (qq: Friend | Group, seq: number, rand: number, timeOrPktnum: number, pair: Pair, isOthersMsg: boolean, noSendError = false) => { 32 | try { 33 | const result = await qq.recallMsg(seq, rand, timeOrPktnum); 34 | if (!result) throw new Error('撤回失败'); 35 | } 36 | catch (e) { 37 | this.log.error('撤回失败', e); 38 | if (noSendError) return; 39 | const tipMsg = await pair.tg.sendMessage({ 40 | message: '撤回 QQ 中对应的消息失败' + 41 | (this.instance.workMode === 'group' ? ',QQ Bot 需要是管理员' : '') + 42 | (isOthersMsg ? ',而且无法撤回其他管理员的消息' : '') + 43 | '' + 44 | (e.message ? '\n' + e.message : ''), 45 | silent: true, 46 | }); 47 | this.instance.workMode === 'group' && setTimeout(async () => await tipMsg.delete({ revoke: true }), 5000); 48 | } 49 | }, 1000); 50 | 51 | /** 52 | * 删除 QQ 对应的消息 53 | * @param messageId 54 | * @param pair 55 | * @param isOthersMsg 56 | */ 57 | async telegramDeleteMessage(messageId: number, pair: Pair, isOthersMsg = false) { 58 | // 删除的时候会返回记录 59 | if (this.lock(`tg-${pair.tgId}-${messageId}`)) return; 60 | try { 61 | const messageInfo = await db.message.findFirst({ 62 | where: { 63 | tgChatId: pair.tgId, 64 | tgMsgId: messageId, 65 | instanceId: this.instance.id, 66 | }, 67 | }); 68 | if (messageInfo) { 69 | try { 70 | if (this.lock(`qq-${pair.qqRoomId}-${messageInfo.seq}`)) return; 71 | const mapQq = pair.instanceMapForTg[messageInfo.tgSenderId.toString()]; 72 | mapQq && this.recallQqMessage(mapQq, messageInfo.seq, Number(messageInfo.rand), messageInfo.pktnum, pair, false, true); 73 | // 假如 mapQQ 是普通成员,机器人是管理员,上面撤回失败了也可以由机器人撤回 74 | // 所以撤回两次 75 | // 不知道哪次会成功,所以就都不发失败提示了 76 | this.recallQqMessage(pair.qq, messageInfo.seq, Number(messageInfo.rand), 77 | pair.qq instanceof Friend ? messageInfo.time : messageInfo.pktnum, 78 | pair, isOthersMsg, !!mapQq); 79 | await db.message.delete({ 80 | where: { id: messageInfo.id }, 81 | }); 82 | } 83 | catch (e) { 84 | this.log.error(e); 85 | } 86 | } 87 | } 88 | catch (e) { 89 | } 90 | } 91 | 92 | /** 93 | * 处理 TG 里面发送的 /rm 94 | * @param message 95 | * @param pair 96 | */ 97 | async handleTelegramMessageRm(message: Api.Message, pair: Pair) { 98 | const replyMessage = await message.getReplyMessage(); 99 | if (replyMessage instanceof Api.Message) { 100 | // 检查权限并撤回被回复的消息 101 | let hasPermission = this.instance.workMode === 'personal' || replyMessage.senderId?.eq(message.senderId); 102 | if (!hasPermission && message.chat instanceof Api.Channel) { 103 | // 可能是超级群 104 | try { 105 | const member = (await pair.tg.getMember(message.sender)).participant; 106 | hasPermission = member instanceof Api.ChannelParticipantCreator || 107 | (member instanceof Api.ChannelParticipantAdmin && member.adminRights.deleteMessages); 108 | } 109 | catch (e) { 110 | // 不管了 111 | } 112 | } 113 | if (!hasPermission && message.chat instanceof Api.Chat) { 114 | // 不是超级群,我也不知道怎么判断,而且应该用不到 115 | } 116 | if (hasPermission) { 117 | // 双平台撤回被回复的消息 118 | // 撤回 QQ 的 119 | await this.telegramDeleteMessage(message.replyToMsgId, pair, replyMessage.senderId?.eq(this.tgBot.me.id)); 120 | try { 121 | // 撤回 TG 的 122 | await pair.tg.deleteMessages(message.replyToMsgId); 123 | } 124 | catch (e) { 125 | await pair.tg.sendMessage(`删除消息失败:${e.message}`); 126 | } 127 | } 128 | else { 129 | const tipMsg = await pair.tg.sendMessage({ 130 | message: '不能撤回别人的消息', 131 | silent: true, 132 | }); 133 | setTimeout(async () => await tipMsg.delete({ revoke: true }), 5000); 134 | } 135 | } 136 | // 撤回消息本身 137 | try { 138 | await message.delete({ revoke: true }); 139 | } 140 | catch (e) { 141 | const tipMsg = await message.reply({ 142 | message: 'Bot 目前无法撤回其他用户的消息,Bot 需要「删除消息」权限', 143 | silent: true, 144 | }); 145 | setTimeout(async () => await tipMsg.delete({ revoke: true }), 5000); 146 | } 147 | } 148 | 149 | public async handleQqRecall(event: FriendRecallEvent | GroupRecallEvent, pair: Pair) { 150 | if (this.lock(`qq-${pair.qqRoomId}-${event.seq}`)) return; 151 | try { 152 | const message = await db.message.findFirst({ 153 | where: { 154 | seq: event.seq, 155 | rand: event.rand, 156 | qqRoomId: pair.qqRoomId, 157 | instanceId: this.instance.id, 158 | }, 159 | }); 160 | if (!message) return; 161 | if (this.lock(`tg-${pair.tgId}-${message.tgMsgId}`)) return; 162 | if ((pair.flags | this.instance.flags) & flags.NO_DELETE_MESSAGE) { 163 | await pair.tg.editMessages({ 164 | message: message.tgMsgId, 165 | text: `${message.tgMessageText}\n此消息已删除`, 166 | parseMode: 'html', 167 | }); 168 | } 169 | else { 170 | await pair.tg.deleteMessages(message.tgMsgId); 171 | await db.message.delete({ 172 | where: { id: message.id }, 173 | }); 174 | } 175 | } 176 | catch (e) { 177 | this.log.error('处理 QQ 消息撤回失败', e); 178 | } 179 | } 180 | 181 | public async isInvalidEdit(message: Api.Message, pair: Pair) { 182 | const messageInfo = await db.message.findFirst({ 183 | where: { 184 | tgChatId: pair.tgId, 185 | tgMsgId: message.id, 186 | instanceId: this.instance.id, 187 | }, 188 | }); 189 | if (!messageInfo) return false; 190 | const isTextSame = messageInfo.tgMessageText === message.message; 191 | if (forwardHelper.getMessageDocumentId(message)) { 192 | return forwardHelper.getMessageDocumentId(message) === messageInfo.tgFileId && isTextSame; 193 | } 194 | return isTextSame; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /main/src/controllers/ConfigController.ts: -------------------------------------------------------------------------------- 1 | import { Api } from 'telegram'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | import ConfigService from '../services/ConfigService'; 5 | import regExps from '../constants/regExps'; 6 | import { 7 | FriendIncreaseEvent, 8 | GroupMessageEvent, 9 | MemberDecreaseEvent, 10 | MemberIncreaseEvent, 11 | PrivateMessageEvent, 12 | } from '@icqqjs/icqq'; 13 | import Instance from '../models/Instance'; 14 | import { getLogger, Logger } from 'log4js'; 15 | import { editFlags } from '../utils/flagControl'; 16 | import flags from '../constants/flags'; 17 | 18 | export default class ConfigController { 19 | private readonly configService: ConfigService; 20 | private readonly createPrivateMessageGroupBlockList = new Map>(); 21 | private readonly log: Logger; 22 | 23 | constructor(private readonly instance: Instance, 24 | private readonly tgBot: Telegram, 25 | private readonly oicq: OicqClient) { 26 | this.log = getLogger(`ConfigController - ${instance.id}`); 27 | this.configService = new ConfigService(this.instance, tgBot, oicq); 28 | tgBot.addNewMessageEventHandler(this.handleMessage); 29 | // tgBot.addNewServiceMessageEventHandler(this.handleServiceMessage); 30 | tgBot.addChannelParticipantEventHandler(this.handleChannelParticipant); 31 | oicq.addNewMessageEventHandler(this.handleQqMessage); 32 | oicq.on('notice.group.decrease', this.handleGroupDecrease); 33 | this.instance.workMode === 'personal' && oicq.on('notice.group.increase', this.handleMemberIncrease); 34 | this.instance.workMode === 'personal' && oicq.on('notice.friend.increase', this.handleFriendIncrease); 35 | /* this.instance.workMode === 'personal' && this.configService.setupFilter(); */ 36 | } 37 | 38 | private handleMessage = async (message: Api.Message) => { 39 | if (!message.sender.id.eq(this.instance.owner)) { 40 | return false; 41 | } 42 | const messageSplit = message.message.split(' '); 43 | if (message.isGroup) { 44 | if (messageSplit.length === 2 && messageSplit[0] === `/start@${this.tgBot.me.username}` && regExps.roomId.test(messageSplit[1])) { 45 | await this.configService.createLinkGroup(Number(messageSplit[1]), Number(message.chat.id)); 46 | return true; 47 | } 48 | else 49 | return false; 50 | } 51 | else if (message.isPrivate) { 52 | switch (messageSplit[0]) { 53 | case '/flag': 54 | case '/flags': 55 | messageSplit.shift(); 56 | await message.reply({ 57 | message: await editFlags(messageSplit, this.instance), 58 | }); 59 | return true; 60 | } 61 | if (this.instance.workMode === 'personal') { 62 | switch (messageSplit[0]) { 63 | case '/addfriend': 64 | await this.configService.addFriend(); 65 | return true; 66 | case '/addgroup': 67 | await this.configService.addGroup(); 68 | return true; 69 | /* case '/migrate': 70 | await this.configService.migrateAllChats(); 71 | return true; */ 72 | case '/login': 73 | await this.oicq.login(); 74 | return true; 75 | } 76 | } 77 | else { 78 | switch (messageSplit[0]) { 79 | case '/add': 80 | // 加的参数永远是正的群号 81 | if (messageSplit.length === 3 && regExps.qq.test(messageSplit[1]) && !isNaN(Number(messageSplit[2]))) { 82 | await this.configService.createLinkGroup(-Number(messageSplit[1]), Number(messageSplit[2])); 83 | } 84 | else if (messageSplit[1] && regExps.qq.test(messageSplit[1])) { 85 | await this.configService.addExact(Number(messageSplit[1])); 86 | } 87 | else { 88 | await this.configService.addGroup(); 89 | } 90 | return true; 91 | } 92 | } 93 | } 94 | }; 95 | 96 | // private handleServiceMessage = async (message: Api.MessageService) => { 97 | // // 用于检测群升级为超级群的情况 98 | // if (message.action instanceof Api.MessageActionChatMigrateTo) { 99 | // const pair = this.instance.forwardPairs.find((message.peerId as Api.PeerChat).chatId); 100 | // if (!pair) return; 101 | // // 会自动写入数据库 102 | // pair.tg = await this.tgBot.getChat(message.action.channelId); 103 | // // 升级之后 bot 的管理权限可能没了,需要修复一下 104 | // if (this.instance.workMode === 'personal') { 105 | // const chatForUser = await this.tgUser.getChat(message.action.channelId); 106 | // await chatForUser.setAdmin(this.tgBot.me.username); 107 | // } 108 | // else { 109 | // await pair.tg.sendMessage({ 110 | // message: '本群已升级为超级群,可能需要重新设置一下管理员权限', 111 | // silent: true, 112 | // }); 113 | // } 114 | // } 115 | // }; 116 | 117 | private handleQqMessage = async (message: GroupMessageEvent | PrivateMessageEvent) => { 118 | if (message.message_type !== 'private' || this.instance.workMode === 'group') return false; 119 | if (this.instance.flags & flags.NO_AUTO_CREATE_PM) return false; 120 | const pair = this.instance.forwardPairs.find(message.friend); 121 | if (pair) return false; 122 | // 如果正在创建中,应该阻塞 123 | let promise = this.createPrivateMessageGroupBlockList.get(message.from_id); 124 | if (promise) { 125 | await promise; 126 | return false; 127 | } 128 | // 有未创建转发群的新私聊消息时自动创建 129 | promise = this.configService.createGroupAndLink(message.from_id, message.friend.remark || message.friend.nickname, true); 130 | this.createPrivateMessageGroupBlockList.set(message.from_id, promise); 131 | await promise; 132 | return false; 133 | }; 134 | 135 | private handleMemberIncrease = async (event: MemberIncreaseEvent) => { 136 | if (event.user_id !== this.oicq.uin || this.instance.forwardPairs.find(event.group)) return; 137 | // 是新群并且是自己加入了 138 | await this.configService.promptNewQqChat(event.group); 139 | }; 140 | 141 | private handleFriendIncrease = async (event: FriendIncreaseEvent) => { 142 | if (this.instance.forwardPairs.find(event.friend)) return; 143 | await this.configService.promptNewQqChat(event.friend); 144 | }; 145 | 146 | private handleChannelParticipant = async (event: Api.UpdateChannelParticipant) => { 147 | if (event.prevParticipant && 'userId' in event.prevParticipant && 148 | event.prevParticipant.userId.eq(this.tgBot.me.id) && 149 | !event.newParticipant) { 150 | this.log.warn(`群 ${event.channelId.toString()} 删除了`); 151 | const pair = this.instance.forwardPairs.find(event.channelId); 152 | if (pair) { 153 | await this.instance.forwardPairs.remove(pair); 154 | this.log.info(`已删除关联 ID: ${pair.dbId}`); 155 | } 156 | } 157 | }; 158 | 159 | private handleGroupDecrease = async (event: MemberDecreaseEvent) => { 160 | // 如果是自己被踢出群,则删除对应的配置 161 | // 如果是群主解散群,则删除对应的配置 162 | if (event.user_id !== this.oicq.uin) return; 163 | const pair = this.instance.forwardPairs.find(event.group); 164 | if (!pair) return; 165 | await this.instance.forwardPairs.remove(pair); 166 | this.log.info(`已删除关联 ID: ${pair.dbId}`); 167 | if (this.instance.workMode === 'personal') { 168 | const message = await pair.tg.sendMessage(event.dismiss ? '群解散了' : '群已被踢出'); 169 | await message.pin(); 170 | } 171 | }; 172 | } 173 | -------------------------------------------------------------------------------- /main/src/services/InChatCommandsService.ts: -------------------------------------------------------------------------------- 1 | import { getLogger, Logger } from 'log4js'; 2 | import Instance from '../models/Instance'; 3 | import Telegram from '../client/Telegram'; 4 | import OicqClient from '../client/OicqClient'; 5 | import { Api } from 'telegram'; 6 | import getAboutText from '../utils/getAboutText'; 7 | import { Pair } from '../models/Pair'; 8 | import { CustomFile } from 'telegram/client/uploads'; 9 | import { getAvatar } from '../utils/urls'; 10 | import db from '../models/db'; 11 | import { Friend, Group } from '@icqqjs/icqq'; 12 | import { format } from 'date-and-time'; 13 | import ZincSearch from 'zincsearch-node'; 14 | import env from '../models/env'; 15 | 16 | export default class InChatCommandsService { 17 | private readonly log: Logger; 18 | private readonly zincSearch: ZincSearch; 19 | 20 | constructor(private readonly instance: Instance, 21 | private readonly tgBot: Telegram, 22 | private readonly oicq: OicqClient) { 23 | this.log = getLogger(`InChatCommandsService - ${instance.id}`); 24 | if (env.ZINC_URL) { 25 | this.zincSearch = new ZincSearch({ 26 | url: env.ZINC_URL, 27 | user: env.ZINC_USERNAME, 28 | password: env.ZINC_PASSWORD, 29 | }); 30 | } 31 | } 32 | 33 | public async info(message: Api.Message, pair: Pair) { 34 | const replyMessageId = message.replyToMsgId; 35 | if (replyMessageId) { 36 | const messageInfo = await db.message.findFirst({ 37 | where: { 38 | tgChatId: Number(message.chat.id), 39 | tgMsgId: replyMessageId, 40 | }, 41 | }); 42 | if (messageInfo) { 43 | let textToSend = ''; 44 | if (pair.qq instanceof Friend) { 45 | if (Number(messageInfo.qqSenderId) === pair.qqRoomId) { 46 | textToSend += `发送者:${pair.qq.remark || pair.qq.nickname}(${pair.qq.user_id})\n`; 47 | } 48 | else { 49 | textToSend += `发送者:${this.oicq.nickname}(${this.oicq.uin})\n`; 50 | } 51 | } 52 | else { 53 | const sender = pair.qq.pickMember(Number(messageInfo.qqSenderId)); 54 | await sender.renew(); 55 | textToSend += `发送者:${sender.title ? `「${sender.title}」` : ''}` + 56 | `${sender.card || sender.info.nickname}(${sender.user_id})\n`; 57 | if (sender.info.role !== 'member') { 58 | textToSend += `职务:${sender.info.role === 'owner' ? '群主' : '管理员'}\n`; 59 | } 60 | } 61 | textToSend += `发送时间:${format(new Date(messageInfo.time * 1000), 'YYYY-M-D hh:mm:ss')}`; 62 | const avatar = await getAvatar(Number(messageInfo.qqSenderId)); 63 | if (this.instance.workMode === 'personal') { 64 | await message.reply({ 65 | message: textToSend, 66 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 67 | }); 68 | } 69 | else { 70 | const sender = await this.tgBot.getChat(message.sender); 71 | try { 72 | await message.delete({ revoke: true }); 73 | await sender.sendMessage({ 74 | message: textToSend, 75 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 76 | }); 77 | } 78 | catch { 79 | } 80 | } 81 | } 82 | else { 83 | await message.reply({ 84 | message: '获取消息信息失败', 85 | }); 86 | } 87 | } 88 | else { 89 | const avatar = await getAvatar(pair.qqRoomId); 90 | await message.reply({ 91 | message: await getAboutText(pair.qq, true), 92 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 93 | }); 94 | } 95 | } 96 | 97 | public async poke(message: Api.Message, pair: Pair) { 98 | try { 99 | let target: number; 100 | if (message.replyToMsgId) { 101 | const dbEntry = await db.message.findFirst({ 102 | where: { 103 | tgChatId: pair.tgId, 104 | tgMsgId: message.replyToMsgId, 105 | }, 106 | }); 107 | if (dbEntry) { 108 | target = Number(dbEntry.qqSenderId); 109 | } 110 | } 111 | if (pair.qq instanceof Group && !target) { 112 | await message.reply({ 113 | message: '请回复一条消息', 114 | }); 115 | } 116 | else if (pair.qq instanceof Group) { 117 | await pair.qq.pokeMember(target); 118 | } 119 | else { 120 | await pair.qq.poke(target && target !== pair.qqRoomId); 121 | } 122 | } 123 | catch (e) { 124 | await message.reply({ 125 | message: `错误\n${e.message}`, 126 | }); 127 | } 128 | } 129 | 130 | public async search(keywords: string[], pair: Pair) { 131 | const queries = keywords.map((txt) => `text:${txt}`); 132 | const result = await this.zincSearch.search({ 133 | index: `q2tg-${pair.dbId}`, 134 | query: { term: queries.join(' '), terms: [] }, 135 | search_type: 'match', 136 | sort_fields: ['-_score'], 137 | max_results: 5, 138 | }); 139 | if (!result.hits?.hits?.length) { 140 | return '没有结果'; 141 | } 142 | const rpy = result.hits.hits.map((hit, index) => { 143 | const id = hit._id!; 144 | const link = `https://t.me/c/${pair.tgId}/${id}`; 145 | return `${index + 1}. ${link} score:${hit._score!.toFixed(3)}`; 146 | }); 147 | return rpy.join('\n'); 148 | } 149 | 150 | // 禁言 QQ 成员 151 | public async mute(message: Api.Message, pair: Pair, args: string[]) { 152 | try { 153 | const group = pair.qq as Group; 154 | if(!(group.is_admin||group.is_owner)){ 155 | await message.reply({ 156 | message: '无管理员权限', 157 | }); 158 | return; 159 | } 160 | let target: number; 161 | if (message.replyToMsgId) { 162 | const dbEntry = await db.message.findFirst({ 163 | where: { 164 | tgChatId: pair.tgId, 165 | tgMsgId: message.replyToMsgId, 166 | }, 167 | }); 168 | if (dbEntry) { 169 | target = Number(dbEntry.qqSenderId); 170 | } 171 | } 172 | if (!target) { 173 | await message.reply({ 174 | message: '请回复一条消息', 175 | }); 176 | return; 177 | } 178 | if (!args.length) { 179 | await message.reply({ 180 | message: '请输入禁言的时间', 181 | }); 182 | return; 183 | } 184 | let time = Number(args[0]); 185 | if (isNaN(time)) { 186 | const unit = args[0].substring(args[0].length - 1, args[0].length); 187 | time = Number(args[0].substring(0, args[0].length - 1)); 188 | 189 | switch (unit) { 190 | case 'd': 191 | time *= 24; 192 | case 'h': 193 | time *= 60; 194 | case 'm': 195 | time *= 60; 196 | break; 197 | default: 198 | time = NaN; 199 | } 200 | } 201 | if (isNaN(time)) { 202 | await message.reply({ 203 | message: '请输入正确的时间', 204 | }); 205 | return; 206 | } 207 | await group.muteMember(target, time); 208 | await message.reply({ 209 | message: '成功', 210 | }); 211 | } 212 | catch (e) { 213 | await message.reply({ 214 | message: `错误\n${e.message}`, 215 | }); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /main/src/helpers/forwardHelper.ts: -------------------------------------------------------------------------------- 1 | import { fetchFile } from '../utils/urls'; 2 | import { CustomFile } from 'telegram/client/uploads'; 3 | import { base64decode } from 'nodejs-base64'; 4 | import { getLogger } from 'log4js'; 5 | import { Entity } from 'telegram/define'; 6 | import { ForwardMessage } from '@icqqjs/icqq'; 7 | import { Api } from 'telegram'; 8 | import { imageSize } from 'image-size'; 9 | import env from '../models/env'; 10 | import { md5Hex } from '../utils/hashing'; 11 | 12 | const log = getLogger('ForwardHelper'); 13 | 14 | const htmlEscape = (text: string) => 15 | text.replace(/&/g, '&') 16 | .replace(//g, '>'); 18 | 19 | export default { 20 | async downloadToCustomFile(url: string, allowWebp = false, filename?: string) { 21 | const { fileTypeFromBuffer } = await (Function('return import("file-type")')() as Promise); 22 | const file = await fetchFile(url); 23 | if (filename) { 24 | return new CustomFile(filename, file.length, '', file); 25 | } 26 | const type = await fileTypeFromBuffer(file); 27 | // The photo must be at most 10 MB in size. The photo's width and height must not exceed 10000 in total. Width and height ratio must be at most 20 28 | if (type.ext === 'png' || type.ext === 'jpg') { 29 | const dimensions = imageSize(file); 30 | const aspectRatio = dimensions.width / dimensions.height; 31 | if (aspectRatio > 20 || aspectRatio < 1 / 20 32 | || dimensions.width + dimensions.height > 10000 33 | ) { 34 | // 让 Telegram 服务器下载 35 | return url; 36 | } 37 | } 38 | if (allowWebp) { 39 | return new CustomFile(`image.${type.ext}`, file.length, '', file); 40 | } 41 | else { 42 | // 防止 webp 作为贴纸发送时丢失发送者信息 43 | return new CustomFile(`image.${type.ext === 'webp' ? 'png' : type.ext}`, file.length, '', file); 44 | } 45 | }, 46 | 47 | hSize(size: number) { 48 | const BYTE = 1024; 49 | 50 | if (size < BYTE) 51 | return size + 'B'; 52 | if (size < Math.pow(BYTE, 2)) 53 | return (size / BYTE).toFixed(1) + 'KB'; 54 | if (size < Math.pow(BYTE, 3)) 55 | return (size / Math.pow(BYTE, 2)).toFixed(1) + 'MB'; 56 | if (size < Math.pow(BYTE, 4)) 57 | return (size / Math.pow(BYTE, 3)).toFixed(1) + 'GB'; 58 | return (size / Math.pow(BYTE, 4)).toFixed(1) + 'TB'; 59 | }, 60 | 61 | htmlEscape, 62 | 63 | processJson(json: string) { 64 | const jsonObj = JSON.parse(json); 65 | if (jsonObj.app === 'com.tencent.mannounce') { 66 | try { 67 | const title = base64decode(jsonObj.meta.mannounce.title); 68 | const content = base64decode(jsonObj.meta.mannounce.text); 69 | return { type: 'text', text: title + '\n\n' + content }; 70 | } 71 | catch (err) { 72 | log.error('解析群公告时出错', err); 73 | return { type: 'text', text: '[群公告]' }; 74 | } 75 | } 76 | else if (jsonObj.app === 'com.tencent.multimsg') { 77 | try { 78 | const resId = jsonObj.meta?.detail?.resid; 79 | const fileName = jsonObj.meta?.detail?.uniseq; 80 | if (resId) { 81 | return { type: 'forward', resId }; 82 | } 83 | else { 84 | return { type: 'text', text: '[解析转发消息时出错:没有 resId]' }; 85 | } 86 | } 87 | catch (err) { 88 | } 89 | } 90 | let appurl: string; 91 | const biliRegex = /(https?:\\?\/\\?\/b23\.tv\\?\/\w*)\??/; 92 | const zhihuRegex = /(https?:\\?\/\\?\/\w*\.?zhihu\.com\\?\/[^?"=]*)\??/; 93 | const biliRegex2 = /(https?:\\?\/\\?\/\w*\.?bilibili\.com\\?\/[^?"=]*)\??/; 94 | const jsonLinkRegex = /{.*"app":"com.tencent.structmsg".*"jumpUrl":"(https?:\\?\/\\?\/[^",]*)".*}/; 95 | const jsonAppLinkRegex = /"contentJumpUrl": ?"(https?:\\?\/\\?\/[^",]*)"/; 96 | if (biliRegex.test(json)) 97 | appurl = json.match(biliRegex)[1].replace(/\\\//g, '/'); 98 | else if (biliRegex2.test(json)) 99 | appurl = json.match(biliRegex2)[1].replace(/\\\//g, '/'); 100 | else if (zhihuRegex.test(json)) 101 | appurl = json.match(zhihuRegex)[1].replace(/\\\//g, '/'); 102 | else if (jsonLinkRegex.test(json)) 103 | appurl = json.match(jsonLinkRegex)[1].replace(/\\\//g, '/'); 104 | else if (jsonAppLinkRegex.test(json)) 105 | appurl = json.match(jsonAppLinkRegex)[1].replace(/\\\//g, '/'); 106 | if (appurl) { 107 | return { type: 'text', text: appurl }; 108 | } 109 | else { 110 | // TODO 记录无法解析的 JSON 111 | return { type: 'text', text: '[JSON]' }; 112 | } 113 | }, 114 | 115 | processXml(xml: string): 116 | { type: 'forward', resId: string } | { type: 'text', text: string } | { type: 'image', md5: string } { 117 | const urlRegex = /url="([^"]+)"/; 118 | const md5ImageRegex = /image md5="([A-F\d]{32})"/; 119 | let text: string; 120 | if (urlRegex.test(xml)) 121 | text = xml.match(urlRegex)[1].replace(/\\\//g, '/'); 122 | if (xml.includes('action="viewMultiMsg"')) { 123 | text = '[Forward multiple messages]'; 124 | const resIdRegex = /m_resid="([\w+=/]+)"/; 125 | if (resIdRegex.test(xml)) { 126 | const resId = xml.match(resIdRegex)![1]; 127 | return { 128 | type: 'forward', 129 | resId, 130 | }; 131 | } 132 | } 133 | else if (text) { 134 | text = text.replace(/&/g, '&'); 135 | return { 136 | type: 'text', 137 | text, 138 | }; 139 | } 140 | else if (md5ImageRegex.test(xml)) { 141 | const imgMd5 = xml.match(md5ImageRegex)![1]; 142 | return { 143 | type: 'image', 144 | md5: imgMd5, 145 | }; 146 | } 147 | else { 148 | return { 149 | type: 'text', 150 | text: '[XML]', 151 | }; 152 | } 153 | }, 154 | 155 | getUserDisplayName(user: Entity) { 156 | if (!user) { 157 | return '未知'; 158 | } 159 | if ('firstName' in user) { 160 | return user.firstName + 161 | (user.lastName ? ' ' + user.lastName : ''); 162 | } 163 | else if ('title' in user) { 164 | return user.title; 165 | } 166 | else if ('id' in user) { 167 | return user.id.toString(); 168 | } 169 | return '未知'; 170 | }, 171 | 172 | generateForwardBrief(messages: ForwardMessage[]) { 173 | const count = messages.length; 174 | // 取前四条 175 | messages = messages.slice(0, 4); 176 | let result = '转发的消息记录'; 177 | for (const message of messages) { 178 | result += `\n${message.nickname}: ` + 179 | `${htmlEscape(message.raw_message.length > 10 ? message.raw_message.substring(0, 10) + '…' : message.raw_message)}`; 180 | } 181 | if (count > messages.length) { 182 | result += `\n共 ${count} 条消息记录`; 183 | } 184 | return result; 185 | }, 186 | 187 | getMessageDocumentId(message: Api.Message) { 188 | if (message.document) { 189 | return BigInt(message.document.id.toString()); 190 | } 191 | if (message.file) { 192 | const media = Reflect.get(message.file, 'media'); 193 | return BigInt(media.id.toString()); 194 | } 195 | return null; 196 | }, 197 | 198 | generateRichHeaderUrl(apiKey: string, userId: number, messageHeader = '') { 199 | const url = new URL(`${env.WEB_ENDPOINT}/richHeader/${apiKey}/${userId}`); 200 | // 防止群名片刷新慢 201 | messageHeader && url.searchParams.set('hash', md5Hex(messageHeader).substring(0, 10)); 202 | return url.toString(); 203 | }, 204 | 205 | generateTelegramAvatarUrl(instanceId: number, userId: number) { 206 | if (!env.WEB_ENDPOINT) return ''; 207 | const url = new URL(`${env.WEB_ENDPOINT}/telegramAvatar/${instanceId}/${userId}`); 208 | return url.toString(); 209 | }, 210 | }; 211 | -------------------------------------------------------------------------------- /main/src/controllers/HugController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | import { AtElem, Group, GroupMessageEvent, PrivateMessageEvent, Sendable } from '@icqqjs/icqq'; 5 | import { Pair } from '../models/Pair'; 6 | import { Api } from 'telegram'; 7 | import db from '../models/db'; 8 | import BigInteger from 'big-integer'; 9 | import helper from '../helpers/forwardHelper'; 10 | import { getLogger, Logger } from 'log4js'; 11 | import flags from '../constants/flags'; 12 | 13 | type ActionSubjectTg = { 14 | name: string; 15 | id: Api.TypeInputUser | Api.InputPeerUser; 16 | from: 'tg'; 17 | } 18 | 19 | type ActionSubjectQq = { 20 | name: string; 21 | id: number; 22 | from: 'qq'; 23 | } 24 | 25 | type ActionSubject = ActionSubjectTg | ActionSubjectQq; 26 | 27 | const COMMAND_REGEX = /(^\/([^\w\s$¥]\S*)|^\/[$¥](\w\S*))( (\S*))?/; // /抱 /$rua 28 | 29 | export default class { 30 | private readonly log: Logger; 31 | 32 | constructor(private readonly instance: Instance, 33 | private readonly tgBot: Telegram, 34 | private readonly oicq: OicqClient) { 35 | this.log = getLogger(`HugController - ${instance.id}`); 36 | oicq.addNewMessageEventHandler(this.onQqMessage); 37 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 38 | } 39 | 40 | private onQqMessage = async (event: PrivateMessageEvent | GroupMessageEvent) => { 41 | if (event.message_type !== 'group') return; 42 | const pair = this.instance.forwardPairs.find(event.group); 43 | if (!pair) return; 44 | if ((pair.flags | this.instance.flags) & flags.DISABLE_SLASH_COMMAND) return; 45 | const chain = [...event.message]; 46 | while (chain.length && chain[0].type !== 'text') { 47 | chain.shift(); 48 | } 49 | const firstElem = chain[0]; 50 | if (firstElem?.type !== 'text') return; 51 | const exec = COMMAND_REGEX.exec(firstElem.text.trim()); 52 | if (!exec) return; 53 | const action = exec[2] || exec[3]; 54 | if (!action) return; 55 | const from: ActionSubject = { 56 | from: 'qq', 57 | name: event.nickname, 58 | id: event.sender.user_id, 59 | }; 60 | let to: ActionSubject; 61 | const ats = chain.filter(it => it.type === 'at') as AtElem[]; 62 | if (ats.length) { 63 | const atName = ats[0].text.startsWith('@') ? ats[0].text.substring(1) : ats[0].text; 64 | to = { 65 | from: 'qq', 66 | name: atName, 67 | id: ats[0].qq as number, 68 | }; 69 | } 70 | else if (event.source && event.source.user_id === this.oicq.uin) { 71 | // 来自 tg 72 | const sourceMessage = await db.message.findFirst({ 73 | where: { 74 | instanceId: this.instance.id, 75 | qqRoomId: pair.qqRoomId, 76 | qqSenderId: event.source.user_id, 77 | seq: event.source.seq, 78 | // rand: event.source.rand, 79 | }, 80 | }); 81 | if (!sourceMessage) { 82 | this.log.error('找不到 sourceMessage'); 83 | return true; 84 | } 85 | to = { 86 | from: 'tg', 87 | id: (await this.tgBot.getChat(BigInteger(sourceMessage.tgSenderId))).inputPeer as Api.InputPeerUser, 88 | name: sourceMessage.nick, 89 | }; 90 | } 91 | else if (event.source) { 92 | const sourceMember = (pair.qq as Group).pickMember(event.source.user_id); 93 | to = { 94 | from: 'qq', 95 | name: sourceMember.card || (await sourceMember.getSimpleInfo()).nickname, 96 | id: event.source.user_id, 97 | }; 98 | } 99 | else { 100 | to = { 101 | from: 'qq', 102 | name: '自己', 103 | id: event.sender.user_id, 104 | }; 105 | } 106 | await this.sendAction(pair, from, to, action, exec[5]); 107 | return true; 108 | }; 109 | 110 | private onTelegramMessage = async (message: Api.Message) => { 111 | const pair = this.instance.forwardPairs.find(message.chat); 112 | if (!pair) return; 113 | if ((pair.flags | this.instance.flags) & flags.DISABLE_SLASH_COMMAND) return; 114 | const exec = COMMAND_REGEX.exec(message.message); 115 | if (!exec) return; 116 | const action = exec[2] || exec[3]; 117 | if (!action) return; 118 | const from: ActionSubject = { 119 | from: 'tg', 120 | name: helper.getUserDisplayName(message.sender), 121 | id: (await this.tgBot.getChat(message.senderId)).inputPeer as Api.InputPeerUser, 122 | }; 123 | let to: ActionSubject; 124 | if (message.replyTo) { 125 | const sourceMessage = await db.message.findFirst({ 126 | where: { 127 | instanceId: this.instance.id, 128 | tgChatId: pair.tgId, 129 | tgMsgId: message.replyToMsgId, 130 | }, 131 | }); 132 | if (!sourceMessage) { 133 | this.log.error('找不到 sourceMessage'); 134 | return true; 135 | } 136 | if (this.tgBot.me.id.eq(sourceMessage.tgSenderId)) { 137 | // from qq 138 | to = { 139 | from: 'qq', 140 | name: sourceMessage.nick, 141 | id: Number(sourceMessage.qqSenderId), 142 | }; 143 | } 144 | else { 145 | to = { 146 | from: 'tg', 147 | id: (await this.tgBot.getChat(BigInteger(sourceMessage.tgSenderId))).inputPeer as Api.InputPeerUser, 148 | name: sourceMessage.nick, 149 | }; 150 | } 151 | } 152 | else { 153 | to = { 154 | from: 'tg', 155 | name: '自己', 156 | id: (await this.tgBot.getChat(message.senderId)).inputPeer as Api.InputPeerUser, 157 | }; 158 | } 159 | await this.sendAction(pair, from, to, action, exec[5]); 160 | return true; 161 | }; 162 | 163 | private async sendAction(pair: Pair, from: ActionSubject, to: ActionSubject, action: string, suffix?: string) { 164 | let tgText = ''; 165 | const tgEntities: Api.TypeMessageEntity[] = []; 166 | const qqMessageContent: Sendable = []; 167 | 168 | const addSubject = (subject: ActionSubject) => { 169 | if (subject.from === 'tg') { 170 | tgEntities.push(new Api.InputMessageEntityMentionName({ 171 | offset: tgText.length, 172 | length: subject.name.length, 173 | userId: subject.id as Api.TypeInputUser, 174 | })); 175 | qqMessageContent.push(subject.name); 176 | } 177 | else { 178 | qqMessageContent.push({ 179 | type: 'at', 180 | text: subject.name, 181 | qq: subject.id, 182 | }); 183 | } 184 | tgText += subject.name; 185 | }; 186 | const addText = (text: string) => { 187 | tgText += text; 188 | qqMessageContent.push(text); 189 | }; 190 | 191 | addSubject(from); 192 | addText(' '); 193 | addText(action); 194 | if (!/[\u4e00-\u9fa5]$/.test(action)) { 195 | // 英文之后加上空格 196 | addText(' '); 197 | } 198 | addText('了 '); 199 | addSubject(to); 200 | if (suffix) { 201 | tgText += ' ' + suffix; 202 | } 203 | addText('!'); 204 | 205 | const tgMessage = await pair.tg.sendMessage({ 206 | message: tgText, 207 | formattingEntities: tgEntities, 208 | }); 209 | const qqMessage = await pair.qq.sendMsg(qqMessageContent); 210 | 211 | await db.message.create({ 212 | data: { 213 | qqRoomId: pair.qqRoomId, 214 | qqSenderId: this.oicq.uin, 215 | time: qqMessage.time, 216 | brief: tgText, 217 | seq: qqMessage.seq, 218 | rand: qqMessage.rand, 219 | pktnum: 1, 220 | tgChatId: pair.tgId, 221 | tgMsgId: tgMessage.id, 222 | instanceId: this.instance.id, 223 | tgMessageText: tgMessage.message, 224 | nick: '系统', 225 | tgSenderId: BigInt(this.tgBot.me.id.toString()), 226 | }, 227 | }); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /main/src/client/TelegramChat.ts: -------------------------------------------------------------------------------- 1 | import { BigInteger } from 'big-integer'; 2 | import { Api, TelegramClient, utils } from 'telegram'; 3 | import { ButtonLike, Entity, EntityLike, MessageIDLike } from 'telegram/define'; 4 | import WaitForMessageHelper from '../helpers/WaitForMessageHelper'; 5 | import { EditMessageParams, SendMessageParams } from 'telegram/client/messages'; 6 | import { CustomFile } from 'telegram/client/uploads'; 7 | import Telegram from './Telegram'; 8 | import createPaginatedInlineSelector from '../utils/paginatedInlineSelector'; 9 | import inlineDigitInput from '../utils/inlineDigitInput'; 10 | import { TelegramImportSession } from './TelegramImportSession'; 11 | 12 | export default class TelegramChat { 13 | public readonly inputPeer: Api.TypeInputPeer; 14 | public readonly id: BigInteger; 15 | 16 | constructor(public readonly parent: Telegram, 17 | private readonly client: TelegramClient, 18 | // Api.Chat 是上限 200 人的普通群组 19 | // 超级群组和频道都是 Api.Channel 20 | // 有 Channel.broadcast 和 Channel.megagroup 标识 21 | public readonly entity: Entity, 22 | private readonly waitForInputHelper: WaitForMessageHelper) { 23 | this.inputPeer = utils.getInputPeer(entity); 24 | this.id = entity.id; 25 | } 26 | 27 | public async sendMessage(params: SendMessageParams | string) { 28 | if (typeof params === 'string') { 29 | params = { message: params }; 30 | } 31 | return await this.client.sendMessage(this.entity, params); 32 | } 33 | 34 | public async getMessage(params: Parameters[1]) { 35 | const messages = await this.client.getMessages(this.entity, params); 36 | return messages[0]; 37 | } 38 | 39 | public async sendSelfDestructingPhoto(params: SendMessageParams, photo: CustomFile, ttlSeconds: number) { 40 | // @ts-ignore 定义不好好写的?你家 `FileLike` 明明可以是 `TypeInputMedia` 41 | params.file = new Api.InputMediaUploadedPhoto({ 42 | file: await this.client.uploadFile({ 43 | file: photo, 44 | workers: 1, 45 | }), 46 | ttlSeconds, 47 | }); 48 | return await this.client.sendMessage(this.entity, params); 49 | } 50 | 51 | public async waitForInput() { 52 | return this.waitForInputHelper.waitForMessage(this.entity.id); 53 | } 54 | 55 | public cancelWait() { 56 | this.waitForInputHelper.cancel(this.entity.id); 57 | } 58 | 59 | public createPaginatedInlineSelector(message: string, choices: ButtonLike[][]) { 60 | return createPaginatedInlineSelector(this, message, choices); 61 | } 62 | 63 | public inlineDigitInput(length: number) { 64 | return inlineDigitInput(this, length); 65 | } 66 | 67 | public async setProfilePhoto(photo: Buffer) { 68 | if (!(this.entity instanceof Api.Chat || this.entity instanceof Api.Channel)) 69 | throw new Error('不是群组,无法设置头像'); 70 | if (this.entity instanceof Api.Chat) { 71 | return await this.client.invoke( 72 | new Api.messages.EditChatPhoto({ 73 | chatId: this.id, 74 | photo: new Api.InputChatUploadedPhoto({ 75 | file: await this.client.uploadFile({ 76 | file: new CustomFile('photo.jpg', photo.length, '', photo), 77 | workers: 1, 78 | }), 79 | }), 80 | }), 81 | ); 82 | } 83 | else { 84 | return await this.client.invoke( 85 | new Api.channels.EditPhoto({ 86 | channel: this.entity, 87 | photo: new Api.InputChatUploadedPhoto({ 88 | file: await this.client.uploadFile({ 89 | file: new CustomFile('photo.jpg', photo.length, '', photo), 90 | workers: 1, 91 | }), 92 | }), 93 | }), 94 | ); 95 | } 96 | } 97 | 98 | public async setAdmin(user: EntityLike) { 99 | if (!(this.entity instanceof Api.Chat || this.entity instanceof Api.Channel)) 100 | throw new Error('不是群组,无法设置管理员'); 101 | if (this.entity instanceof Api.Chat) { 102 | return await this.client.invoke( 103 | new Api.messages.EditChatAdmin({ 104 | chatId: this.id, 105 | userId: user, 106 | isAdmin: true, 107 | }), 108 | ); 109 | } 110 | else { 111 | return await this.client.invoke( 112 | new Api.channels.EditAdmin({ 113 | channel: this.entity, 114 | userId: user, 115 | adminRights: new Api.ChatAdminRights({ 116 | changeInfo: true, 117 | postMessages: true, 118 | editMessages: true, 119 | deleteMessages: true, 120 | banUsers: true, 121 | inviteUsers: true, 122 | pinMessages: true, 123 | addAdmins: true, 124 | anonymous: true, 125 | manageCall: true, 126 | other: true, 127 | }), 128 | rank: '转发姬', 129 | }), 130 | ); 131 | } 132 | } 133 | 134 | public async editAbout(about: string) { 135 | if (!(this.entity instanceof Api.Chat || this.entity instanceof Api.Channel)) 136 | throw new Error('不是群组,无法设置描述'); 137 | return await this.client.invoke( 138 | new Api.messages.EditChatAbout({ 139 | peer: this.entity, 140 | about, 141 | }), 142 | ); 143 | } 144 | 145 | public async getInviteLink() { 146 | if (!(this.entity instanceof Api.Chat || this.entity instanceof Api.Channel)) 147 | throw new Error('不是群组,无法邀请'); 148 | const links = await this.client.invoke( 149 | new Api.messages.GetExportedChatInvites({ 150 | peer: this.entity, 151 | adminId: this.parent.me, 152 | limit: 1, 153 | revoked: false, 154 | }), 155 | ); 156 | return links.invites[0]; 157 | } 158 | 159 | public async hidePeerSettingsBar() { 160 | return await this.client.invoke( 161 | new Api.messages.HidePeerSettingsBar({ 162 | peer: this.entity, 163 | }), 164 | ); 165 | } 166 | 167 | public async setNotificationSettings(params: ConstructorParameters[0]) { 168 | return await this.client.invoke( 169 | new Api.account.UpdateNotifySettings({ 170 | peer: new Api.InputNotifyPeer({ peer: this.inputPeer }), 171 | settings: new Api.InputPeerNotifySettings(params), 172 | }), 173 | ); 174 | } 175 | 176 | public async getMember(user: EntityLike) { 177 | if (!(this.entity instanceof Api.Channel)) 178 | throw new Error('不是超级群,无法获取成员信息'); 179 | return await this.client.invoke( 180 | new Api.channels.GetParticipant({ 181 | channel: this.entity, 182 | participant: user, 183 | }), 184 | ); 185 | } 186 | 187 | public async deleteMessages(messageId: MessageIDLike | MessageIDLike[]) { 188 | if (!Array.isArray(messageId)) { 189 | messageId = [messageId]; 190 | } 191 | return await this.client.deleteMessages(this.entity, messageId, { revoke: true }); 192 | } 193 | 194 | public async editMessages(params: EditMessageParams) { 195 | return await this.client.editMessage(this.entity, params); 196 | } 197 | 198 | public async inviteMember(member: EntityLike | EntityLike[]) { 199 | if (!Array.isArray(member)) { 200 | member = [member]; 201 | } 202 | return await this.client.invoke( 203 | new Api.channels.InviteToChannel({ 204 | channel: this.entity, 205 | users: member, 206 | }), 207 | ); 208 | } 209 | 210 | public async migrate() { 211 | return await this.client.invoke( 212 | new Api.messages.MigrateChat({ 213 | chatId: this.id, 214 | }), 215 | ); 216 | } 217 | 218 | public async startImportSession(textFile: CustomFile, mediaCount: number) { 219 | await this.client.invoke( 220 | new Api.messages.CheckHistoryImportPeer({ 221 | peer: this.entity, 222 | }), 223 | ); 224 | const init = await this.client.invoke( 225 | new Api.messages.InitHistoryImport({ 226 | peer: this.entity, 227 | file: await this.client.uploadFile({ 228 | file: textFile, 229 | workers: 1, 230 | }), 231 | mediaCount, 232 | }), 233 | ); 234 | return new TelegramImportSession(this, this.client, init.id); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /main/src/client/OicqClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Client, 3 | DiscussMessageEvent, Forwardable, 4 | Friend, 5 | Group, 6 | GroupMessageEvent, 7 | LogLevel, Platform, PrivateMessage, 8 | PrivateMessageEvent, 9 | } from '@icqqjs/icqq'; 10 | import random from '../utils/random'; 11 | import fs from 'fs'; 12 | import fsP from 'fs/promises'; 13 | import { Config } from '@icqqjs/icqq/lib/client'; 14 | import dataPath from '../helpers/dataPath'; 15 | import os from 'os'; 16 | import { Converter, Image, rand2uuid } from '@icqqjs/icqq/lib/message'; 17 | import { randomBytes } from 'crypto'; 18 | import { gzip, timestamp } from '@icqqjs/icqq/lib/common'; 19 | import { pb } from '@icqqjs/icqq/lib/core'; 20 | import env from '../models/env'; 21 | 22 | const LOG_LEVEL: LogLevel = env.OICQ_LOG_LEVEL; 23 | 24 | type MessageHandler = (event: PrivateMessageEvent | GroupMessageEvent) => Promise 25 | 26 | interface CreateOicqParams { 27 | id: number; 28 | uin: number; 29 | password: string; 30 | platform: Platform; 31 | signApi?: string; 32 | signVer?: string; 33 | // 当需要验证手机时调用此方法,应该返回收到的手机验证码 34 | onVerifyDevice: (phone: string) => Promise; 35 | // 当滑块时调用此方法,返回 ticker,也可以返回假值改用扫码登录 36 | onVerifySlider: (url: string) => Promise; 37 | signDockerId?: string; 38 | } 39 | 40 | // OicqExtended?? 41 | export default class OicqClient extends Client { 42 | private readonly onMessageHandlers: Array = []; 43 | 44 | private constructor(uin: number, public readonly id: number, conf?: Config, 45 | public readonly signDockerId?: string) { 46 | super(conf); 47 | } 48 | 49 | private static existedBots = {} as { [id: number]: OicqClient }; 50 | 51 | private isOnMessageCreated = false; 52 | 53 | public static create(params: CreateOicqParams) { 54 | if (this.existedBots[params.id]) { 55 | return Promise.resolve(this.existedBots[params.id]); 56 | } 57 | return new Promise(async (resolve, reject) => { 58 | const loginDeviceHandler = async ({ phone }: { url: string, phone: string }) => { 59 | client.sendSmsCode(); 60 | const code = await params.onVerifyDevice(phone); 61 | if (code === 'qrsubmit') { 62 | client.login(); 63 | } 64 | else { 65 | client.submitSmsCode(code); 66 | } 67 | }; 68 | 69 | const loginSliderHandler = async ({ url }: { url: string }) => { 70 | const res = await params.onVerifySlider(url); 71 | if (res) { 72 | client.submitSlider(res); 73 | } 74 | else { 75 | client.login(); 76 | } 77 | }; 78 | 79 | const loginErrorHandler = ({ message }: { code: number; message: string }) => { 80 | reject(message); 81 | }; 82 | 83 | const successLoginHandler = () => { 84 | client.offTrap('system.login.device', loginDeviceHandler); 85 | client.offTrap('system.login.slider', loginSliderHandler); 86 | client.offTrap('system.login.error', loginErrorHandler); 87 | client.offTrap('system.online', successLoginHandler); 88 | 89 | if (!client.isOnMessageCreated) { 90 | client.trap('message', client.onMessage); 91 | client.isOnMessageCreated = true; 92 | } 93 | 94 | resolve(client); 95 | }; 96 | 97 | if (!fs.existsSync(dataPath(`${params.uin}/device.json`))) { 98 | await fsP.mkdir(dataPath(params.uin.toString()), { recursive: true }); 99 | 100 | const device = { 101 | product: 'Q2TG', 102 | device: 'ANGELKAWAII2', 103 | board: 'rainbowcat', 104 | brand: random.pick('GOOGLE', 'XIAOMI', 'HUAWEI', 'SAMSUNG', 'SONY'), 105 | model: 'rainbowcat', 106 | wifi_ssid: random.pick('OpenWrt', `Redmi-${random.hex(4).toUpperCase()}`, 107 | `MiWifi-${random.hex(4).toUpperCase()}`, `TP-LINK-${random.hex(6).toUpperCase()}`), 108 | bootloader: random.pick('U-Boot', 'GRUB', 'gummiboot'), 109 | android_id: random.hex(16), 110 | proc_version: `${os.type()} version ${os.release()}`, 111 | mac_address: `8c:85:90:${random.hex(2)}:${random.hex(2)}:${random.hex(2)}`.toUpperCase(), 112 | ip_address: `192.168.${random.int(1, 200)}.${random.int(10, 250)}`, 113 | incremental: random.int(0, 4294967295), 114 | imei: random.imei(), 115 | }; 116 | 117 | await fsP.writeFile(dataPath(`${params.uin}/device.json`), JSON.stringify(device, null, 0), 'utf-8'); 118 | } 119 | 120 | const client = new this(params.uin, params.id, { 121 | platform: params.platform, 122 | data_dir: dataPath(params.uin.toString()), 123 | log_level: LOG_LEVEL, 124 | ffmpeg_path: env.FFMPEG_PATH, 125 | ffprobe_path: env.FFPROBE_PATH, 126 | sign_api_addr: params.signApi || env.SIGN_API, 127 | ver: params.signVer || env.SIGN_VER, 128 | }, params.signDockerId); 129 | client.on('system.login.device', loginDeviceHandler); 130 | client.on('system.login.slider', loginSliderHandler); 131 | client.on('system.login.error', loginErrorHandler); 132 | client.on('system.online', successLoginHandler); 133 | 134 | this.existedBots[params.id] = client; 135 | client.login(params.uin, params.password); 136 | }); 137 | } 138 | 139 | private onMessage = async (event: PrivateMessageEvent | GroupMessageEvent | DiscussMessageEvent) => { 140 | if (event.message_type === 'discuss') return; 141 | for (const handler of this.onMessageHandlers) { 142 | const res = await handler(event); 143 | if (res) return; 144 | } 145 | }; 146 | 147 | public addNewMessageEventHandler(handler: MessageHandler) { 148 | this.onMessageHandlers.push(handler); 149 | } 150 | 151 | public removeNewMessageEventHandler(handler: MessageHandler) { 152 | this.onMessageHandlers.includes(handler) && 153 | this.onMessageHandlers.splice(this.onMessageHandlers.indexOf(handler), 1); 154 | } 155 | 156 | public getChat(roomId: number): Group | Friend { 157 | if (roomId > 0) { 158 | return this.pickFriend(roomId); 159 | } 160 | else { 161 | return this.pickGroup(-roomId); 162 | } 163 | } 164 | 165 | public async makeForwardMsgSelf(msglist: Forwardable[] | Forwardable, dm?: boolean): Promise<{ 166 | resid: string, 167 | tSum: number 168 | }> { 169 | if (!Array.isArray(msglist)) 170 | msglist = [msglist]; 171 | const nodes = []; 172 | const makers: Converter[] = []; 173 | let imgs: Image[] = []; 174 | let cnt = 0; 175 | for (const fake of msglist) { 176 | const maker = new Converter(fake.message, { dm, cachedir: this.config.data_dir }); 177 | makers.push(maker); 178 | const seq = randomBytes(2).readInt16BE(); 179 | const rand = randomBytes(4).readInt32BE(); 180 | let nickname = String(fake.nickname || fake.user_id); 181 | if (!nickname && fake instanceof PrivateMessage) 182 | nickname = this.fl.get(fake.user_id)?.nickname || this.sl.get(fake.user_id)?.nickname || nickname; 183 | if (cnt < 4) { 184 | cnt++; 185 | } 186 | nodes.push({ 187 | 1: { 188 | 1: fake.user_id, 189 | 2: this.uin, 190 | 3: dm ? 166 : 82, 191 | 4: dm ? 11 : null, 192 | 5: seq, 193 | 6: fake.time || timestamp(), 194 | 7: rand2uuid(rand), 195 | 9: dm ? null : { 196 | 1: this.uin, 197 | 4: nickname, 198 | }, 199 | 14: dm ? nickname : null, 200 | 20: { 201 | 1: 0, 202 | 2: rand, 203 | }, 204 | }, 205 | 3: { 206 | 1: maker.rich, 207 | }, 208 | }); 209 | } 210 | for (const maker of makers) 211 | imgs = [...imgs, ...maker.imgs]; 212 | const contact = (dm ? this.pickFriend : this.pickGroup)(this.uin); 213 | if (imgs.length) 214 | await contact.uploadImages(imgs); 215 | const compressed = await gzip(pb.encode({ 216 | 1: nodes, 217 | 2: [{ 218 | 1: 'MultiMsg', 219 | 2: { 220 | 1: nodes, 221 | }, 222 | }], 223 | })); 224 | const _uploadMultiMsg = Reflect.get(contact, '_uploadMultiMsg') as Function; 225 | const resid = await _uploadMultiMsg.apply(contact, [compressed]); 226 | return { 227 | tSum: nodes.length, 228 | resid, 229 | }; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /main/src/controllers/ForwardController.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import OicqClient from '../client/OicqClient'; 3 | import ForwardService from '../services/ForwardService'; 4 | import { 5 | Friend, 6 | FriendPokeEvent, 7 | GroupMessageEvent, 8 | GroupPokeEvent, 9 | MemberIncreaseEvent, 10 | PrivateMessageEvent, 11 | } from '@icqqjs/icqq'; 12 | import db from '../models/db'; 13 | import { Api } from 'telegram'; 14 | import { getLogger, Logger } from 'log4js'; 15 | import Instance from '../models/Instance'; 16 | import { getAvatar } from '../utils/urls'; 17 | import { CustomFile } from 'telegram/client/uploads'; 18 | import forwardHelper from '../helpers/forwardHelper'; 19 | import helper from '../helpers/forwardHelper'; 20 | import flags from '../constants/flags'; 21 | 22 | export default class ForwardController { 23 | private readonly forwardService: ForwardService; 24 | private readonly log: Logger; 25 | 26 | constructor( 27 | private readonly instance: Instance, 28 | private readonly tgBot: Telegram, 29 | private readonly oicq: OicqClient, 30 | ) { 31 | this.log = getLogger(`ForwardController - ${instance.id}`); 32 | this.forwardService = new ForwardService(this.instance, tgBot, oicq); 33 | oicq.addNewMessageEventHandler(this.onQqMessage); 34 | oicq.on('notice.group.increase', this.onQqGroupMemberIncrease); 35 | oicq.on('notice.friend.poke', this.onQqPoke); 36 | oicq.on('notice.group.poke', this.onQqPoke); 37 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 38 | // tgUser.addNewMessageEventHandler(this.onTelegramUserMessage); 39 | tgBot.addEditedMessageEventHandler(this.onTelegramMessage); 40 | instance.workMode === 'group' && tgBot.addChannelParticipantEventHandler(this.onTelegramParticipant); 41 | } 42 | 43 | private onQqMessage = async (event: PrivateMessageEvent | GroupMessageEvent) => { 44 | try { 45 | const target = event.message_type === 'private' ? event.friend : event.group; 46 | const pair = this.instance.forwardPairs.find(target); 47 | if (!pair) return; 48 | if ((pair.flags | this.instance.flags) & flags.DISABLE_Q2TG) return; 49 | // 如果是多张图片的话,是一整条消息,只过一次,所以不受这个判断影响 50 | let existed = event.message_type === 'private' && await db.message.findFirst({ 51 | where: { 52 | qqRoomId: pair.qqRoomId, 53 | qqSenderId: event.sender.user_id, 54 | seq: event.seq, 55 | rand: event.rand, 56 | pktnum: event.pktnum, 57 | time: event.time, 58 | instanceId: this.instance.id, 59 | }, 60 | }); 61 | if (existed) return; 62 | // 开始转发过程 63 | let { tgMessage, richHeaderUsed } = await this.forwardService.forwardFromQq(event, pair); 64 | if (!tgMessage) return; 65 | // 更新数据库 66 | await db.message.create({ 67 | data: { 68 | qqRoomId: pair.qqRoomId, 69 | qqSenderId: event.sender.user_id, 70 | time: event.time, 71 | brief: event.raw_message, 72 | seq: event.seq, 73 | rand: event.rand, 74 | pktnum: event.pktnum, 75 | tgChatId: pair.tgId, 76 | tgMsgId: tgMessage.id, 77 | instanceId: this.instance.id, 78 | tgMessageText: tgMessage.message, 79 | tgFileId: forwardHelper.getMessageDocumentId(tgMessage), 80 | nick: event.nickname, 81 | tgSenderId: BigInt(this.tgBot.me.id.toString()), 82 | richHeaderUsed, 83 | }, 84 | }); 85 | await this.forwardService.addToZinc(pair.dbId, tgMessage.id, { 86 | text: event.raw_message, 87 | nick: event.nickname, 88 | }); 89 | } 90 | catch (e) { 91 | this.log.error('处理 QQ 消息时遇到问题', e); 92 | } 93 | }; 94 | 95 | private onTelegramUserMessage = async (message: Api.Message) => { 96 | if (!message.sender) return; 97 | if (!('bot' in message.sender) || !message.sender.bot) return; 98 | const pair = this.instance.forwardPairs.find(message.chat); 99 | if (!pair) return; 100 | if ((pair.flags | this.instance.flags) & flags.NO_FORWARD_OTHER_BOT) return; 101 | await this.onTelegramMessage(message, pair); 102 | }; 103 | 104 | private onTelegramMessage = async (message: Api.Message, pair = this.instance.forwardPairs.find(message.chat)) => { 105 | try { 106 | if (message.senderId?.eq(this.instance.botMe.id)) return true; 107 | if (!pair) return false; 108 | if ((pair.flags | this.instance.flags) & flags.DISABLE_TG2Q) return; 109 | const qqMessagesSent = await this.forwardService.forwardFromTelegram(message, pair); 110 | if (qqMessagesSent) { 111 | // 更新数据库 112 | for (const qqMessageSent of qqMessagesSent) { 113 | await db.message.create({ 114 | data: { 115 | qqRoomId: pair.qqRoomId, 116 | qqSenderId: qqMessageSent.senderId, 117 | time: qqMessageSent.time, 118 | brief: qqMessageSent.brief, 119 | seq: qqMessageSent.seq, 120 | rand: qqMessageSent.rand, 121 | pktnum: 1, 122 | tgChatId: pair.tgId, 123 | tgMsgId: message.id, 124 | instanceId: this.instance.id, 125 | tgMessageText: message.message, 126 | tgFileId: forwardHelper.getMessageDocumentId(message), 127 | nick: helper.getUserDisplayName(message.sender), 128 | tgSenderId: BigInt((message.senderId || message.sender?.id).toString()), 129 | }, 130 | }); 131 | await this.forwardService.addToZinc(pair.dbId, message.id, { 132 | text: qqMessageSent.brief, 133 | nick: helper.getUserDisplayName(message.sender), 134 | }); 135 | } 136 | } 137 | } 138 | catch (e) { 139 | this.log.error('处理 Telegram 消息时遇到问题', e); 140 | } 141 | }; 142 | 143 | private onQqGroupMemberIncrease = async (event: MemberIncreaseEvent) => { 144 | try { 145 | const pair = this.instance.forwardPairs.find(event.group); 146 | if ((pair?.flags | this.instance.flags) & flags.DISABLE_JOIN_NOTICE) return false; 147 | const avatar = await getAvatar(event.user_id); 148 | await pair.tg.sendMessage({ 149 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 150 | message: `${event.nickname} (${event.user_id}) 加入了本群`, 151 | silent: true, 152 | }); 153 | } 154 | catch (e) { 155 | this.log.error('处理 QQ 群成员增加事件时遇到问题', e); 156 | } 157 | }; 158 | 159 | private onTelegramParticipant = async (event: Api.UpdateChannelParticipant) => { 160 | try { 161 | const pair = this.instance.forwardPairs.find(event.channelId); 162 | if ((pair?.flags | this.instance.flags) & flags.DISABLE_JOIN_NOTICE) return false; 163 | if ( 164 | !(event.newParticipant instanceof Api.ChannelParticipantAdmin) && 165 | !(event.newParticipant instanceof Api.ChannelParticipantCreator) && 166 | !(event.newParticipant instanceof Api.ChannelParticipant) 167 | ) 168 | return false; 169 | const member = await this.tgBot.getChat(event.newParticipant.userId); 170 | await pair.qq.sendMsg(`${forwardHelper.getUserDisplayName(member.entity)} 加入了本群`); 171 | } 172 | catch (e) { 173 | this.log.error('处理 TG 群成员增加事件时遇到问题', e); 174 | } 175 | }; 176 | 177 | private onQqPoke = async (event: FriendPokeEvent | GroupPokeEvent) => { 178 | const target = event.notice_type === 'friend' ? event.friend : event.group; 179 | const pair = this.instance.forwardPairs.find(target); 180 | if (!pair) return; 181 | if ((pair?.flags | this.instance.flags) & flags.DISABLE_POKE) return; 182 | let operatorName: string, targetName: string; 183 | if (target instanceof Friend) { 184 | if (event.operator_id === target.user_id) { 185 | operatorName = target.remark || target.nickname; 186 | } 187 | else { 188 | operatorName = '你'; 189 | } 190 | if (event.operator_id === event.target_id) { 191 | targetName = '自己'; 192 | } 193 | else if (event.target_id === target.user_id) { 194 | targetName = target.remark || target.nickname; 195 | } 196 | else { 197 | targetName = '你'; 198 | } 199 | } 200 | else { 201 | const operator = target.pickMember(event.operator_id); 202 | await operator.renew(); 203 | operatorName = operator.card || operator.info.nickname; 204 | if (event.operator_id === event.target_id) { 205 | targetName = '自己'; 206 | } 207 | else { 208 | const targetUser = target.pickMember(event.target_id); 209 | await targetUser.renew(); 210 | targetName = targetUser.card || targetUser.info.nickname; 211 | } 212 | } 213 | await pair.tg.sendMessage({ 214 | message: `${operatorName}${event.action}${targetName}${event.suffix}`, 215 | silent: true, 216 | }); 217 | }; 218 | } 219 | -------------------------------------------------------------------------------- /main/src/client/Telegram.ts: -------------------------------------------------------------------------------- 1 | import { Api, TelegramClient } from 'telegram'; 2 | import { BotAuthParams, UserAuthParams } from 'telegram/client/auth'; 3 | import { NewMessage, NewMessageEvent, Raw } from 'telegram/events'; 4 | import { EditedMessage, EditedMessageEvent } from 'telegram/events/EditedMessage'; 5 | import { DeletedMessage, DeletedMessageEvent } from 'telegram/events/DeletedMessage'; 6 | import { EntityLike } from 'telegram/define'; 7 | import WaitForMessageHelper from '../helpers/WaitForMessageHelper'; 8 | import CallbackQueryHelper from '../helpers/CallbackQueryHelper'; 9 | import { CallbackQuery, CallbackQueryEvent } from 'telegram/events/CallbackQuery'; 10 | import os from 'os'; 11 | import TelegramChat from './TelegramChat'; 12 | import TelegramSession from '../models/TelegramSession'; 13 | import { LogLevel } from 'telegram/extensions/Logger'; 14 | import { BigInteger } from 'big-integer'; 15 | import { IterMessagesParams } from 'telegram/client/messages'; 16 | import { PromisedNetSockets, PromisedWebSockets } from 'telegram/extensions'; 17 | import { ConnectionTCPFull, ConnectionTCPObfuscated } from 'telegram/network'; 18 | import env from '../models/env'; 19 | 20 | type MessageHandler = (message: Api.Message) => Promise; 21 | type ServiceMessageHandler = (message: Api.MessageService) => Promise; 22 | 23 | export default class Telegram { 24 | private readonly client: TelegramClient; 25 | private waitForMessageHelper: WaitForMessageHelper; 26 | private callbackQueryHelper: CallbackQueryHelper = new CallbackQueryHelper(); 27 | private readonly onMessageHandlers: Array = []; 28 | private readonly onEditedMessageHandlers: Array = []; 29 | private readonly onServiceMessageHandlers: Array = []; 30 | public me: Api.User; 31 | 32 | private static existedBots = {} as { [id: number]: Telegram }; 33 | 34 | public get sessionId() { 35 | return (this.client.session as TelegramSession).dbId; 36 | } 37 | 38 | public get isOnline() { 39 | return this.client.connected; 40 | } 41 | 42 | private constructor(appName: string, sessionId?: number) { 43 | this.client = new TelegramClient( 44 | new TelegramSession(sessionId), 45 | env.TG_API_ID, 46 | env.TG_API_HASH, 47 | { 48 | connectionRetries: 20, 49 | langCode: 'zh', 50 | deviceModel: `${appName} On ${os.hostname()}`, 51 | appVersion: 'rainbowcat', 52 | useIPV6: !!env.IPV6, 53 | proxy: env.PROXY_IP ? { 54 | socksType: 5, 55 | ip: env.PROXY_IP, 56 | port: env.PROXY_PORT, 57 | username: env.PROXY_USERNAME, 58 | password: env.PROXY_PASSWORD, 59 | } : undefined, 60 | autoReconnect: true, 61 | networkSocket: env.TG_CONNECTION === 'websocket' ? PromisedWebSockets : PromisedNetSockets, 62 | connection: env.TG_CONNECTION === 'websocket' ? ConnectionTCPObfuscated : ConnectionTCPFull, 63 | }, 64 | ); 65 | this.client.logger.setLevel(env.TG_LOG_LEVEL as LogLevel); 66 | } 67 | 68 | public static async create(startArgs: UserAuthParams | BotAuthParams, appName = 'Q2TG') { 69 | const bot = new this(appName); 70 | await bot.client.start(startArgs); 71 | this.existedBots[bot.sessionId] = bot; 72 | await bot.config(); 73 | return bot; 74 | } 75 | 76 | public static async connect(sessionId: number, appName = 'Q2TG') { 77 | if (this.existedBots[sessionId]) { 78 | // 已经创建过就不会再次创建,可用于两个 instance 共享 user bot 79 | return this.existedBots[sessionId]; 80 | } 81 | const bot = new this(appName, sessionId); 82 | this.existedBots[sessionId] = bot; 83 | await bot.client.connect(); 84 | await bot.config(); 85 | return bot; 86 | } 87 | 88 | private async config() { 89 | this.client.setParseMode('html'); 90 | this.waitForMessageHelper = new WaitForMessageHelper(this); 91 | this.client.addEventHandler(this.onMessage, new NewMessage({})); 92 | this.client.addEventHandler(this.onEditedMessage, new EditedMessage({})); 93 | this.client.addEventHandler(this.onServiceMessage, new Raw({ 94 | types: [Api.UpdateNewMessage], 95 | func: (update: Api.UpdateNewMessage) => update.message instanceof Api.MessageService, 96 | })); 97 | this.client.addEventHandler(this.callbackQueryHelper.onCallbackQuery, new CallbackQuery()); 98 | this.me = await this.client.getMe() as Api.User; 99 | } 100 | 101 | private onMessage = async (event: NewMessageEvent) => { 102 | // 能用的东西基本都在 message 里面,直接调用 event 里的会 undefined 103 | for (const handler of this.onMessageHandlers) { 104 | const res = await handler(event.message); 105 | if (res) return; 106 | } 107 | }; 108 | 109 | private onEditedMessage = async (event: EditedMessageEvent) => { 110 | for (const handler of this.onEditedMessageHandlers) { 111 | const res = await handler(event.message); 112 | if (res) return; 113 | } 114 | }; 115 | 116 | private onServiceMessage = async (event: Api.UpdateNewMessage) => { 117 | for (const handler of this.onServiceMessageHandlers) { 118 | const res = await handler(event.message as Api.MessageService); 119 | if (res) return; 120 | } 121 | }; 122 | 123 | /** 124 | * 注册消息处理器 125 | * @param handler 此方法返回 true 可以阻断下面的处理器 126 | */ 127 | public addNewMessageEventHandler(handler: MessageHandler) { 128 | this.onMessageHandlers.push(handler); 129 | } 130 | 131 | public removeNewMessageEventHandler(handler: MessageHandler) { 132 | this.onMessageHandlers.includes(handler) && 133 | this.onMessageHandlers.splice(this.onMessageHandlers.indexOf(handler), 1); 134 | } 135 | 136 | public addEditedMessageEventHandler(handler: MessageHandler) { 137 | this.onEditedMessageHandlers.push(handler); 138 | } 139 | 140 | public removeEditedMessageEventHandler(handler: MessageHandler) { 141 | this.onEditedMessageHandlers.includes(handler) && 142 | this.onEditedMessageHandlers.splice(this.onEditedMessageHandlers.indexOf(handler), 1); 143 | } 144 | 145 | public addNewServiceMessageEventHandler(handler: ServiceMessageHandler) { 146 | this.onServiceMessageHandlers.push(handler); 147 | } 148 | 149 | public removeNewServiceMessageEventHandler(handler: ServiceMessageHandler) { 150 | this.onServiceMessageHandlers.includes(handler) && 151 | this.onServiceMessageHandlers.splice(this.onServiceMessageHandlers.indexOf(handler), 1); 152 | } 153 | 154 | public addDeletedMessageEventHandler(handler: (event: DeletedMessageEvent) => any) { 155 | this.client.addEventHandler(handler, new DeletedMessage({})); 156 | } 157 | 158 | public addChannelParticipantEventHandler(handler: (event: Api.UpdateChannelParticipant) => any) { 159 | this.client.addEventHandler(handler, new Raw({ 160 | types: [Api.UpdateChannelParticipant], 161 | })); 162 | } 163 | 164 | public async getChat(entity: EntityLike) { 165 | return new TelegramChat(this, this.client, await this.client.getEntity(entity), this.waitForMessageHelper); 166 | } 167 | 168 | public async setCommands(commands: Api.BotCommand[], scope: Api.TypeBotCommandScope) { 169 | return await this.client.invoke( 170 | new Api.bots.SetBotCommands({ 171 | commands, 172 | langCode: '', 173 | scope, 174 | }), 175 | ); 176 | } 177 | 178 | public registerCallback(cb: (event: CallbackQueryEvent) => any) { 179 | return this.callbackQueryHelper.registerCallback(cb); 180 | } 181 | 182 | public async getDialogFilters() { 183 | return await this.client.invoke(new Api.messages.GetDialogFilters()); 184 | } 185 | 186 | public async updateDialogFilter(params: Partial>) { 187 | return await this.client.invoke(new Api.messages.UpdateDialogFilter(params)); 188 | } 189 | 190 | public async createChat(title: string, about = '') { 191 | const updates = await this.client.invoke(new Api.channels.CreateChannel({ 192 | title, about, 193 | megagroup: true, 194 | forImport: true, 195 | })) as Api.Updates; 196 | const newChat = updates.chats[0]; 197 | return new TelegramChat(this, this.client, newChat, this.waitForMessageHelper); 198 | } 199 | 200 | public async getCustomEmoji(documentId: BigInteger) { 201 | const ids = await this.client.invoke(new Api.messages.GetCustomEmojiDocuments({ 202 | documentId: [documentId], 203 | })); 204 | const document = ids[0] as Api.Document; 205 | return await this.client.downloadFile(new Api.InputDocumentFileLocation({ 206 | id: document.id, 207 | accessHash: document.accessHash, 208 | fileReference: document.fileReference, 209 | thumbSize: '', 210 | }), { 211 | dcId: document.dcId, 212 | }); 213 | } 214 | 215 | public async getInputPeerUserFromMessage(chatId: EntityLike, userId: BigInteger, msgId: number) { 216 | const inputPeerOfChat = await this.client.getInputEntity(chatId); 217 | return new Api.InputUserFromMessage({ 218 | peer: inputPeerOfChat, 219 | userId, msgId, 220 | }); 221 | } 222 | 223 | public getMessage(entity: EntityLike | undefined, getMessagesParams?: Partial) { 224 | return this.client.getMessages(entity, getMessagesParams); 225 | } 226 | 227 | public downloadEntityPhoto(entity: EntityLike, isBig = false) { 228 | return this.client.downloadProfilePhoto(entity, { isBig }); 229 | } 230 | 231 | public downloadThumb(document: Api.Document, thumbSize = 'm') { 232 | return this.client.downloadFile(new Api.InputDocumentFileLocation({ 233 | id: document.id, 234 | accessHash: document.accessHash, 235 | fileReference: document.fileReference, 236 | thumbSize, 237 | }), { 238 | dcId: document.dcId, 239 | }); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /main/src/models/Instance.ts: -------------------------------------------------------------------------------- 1 | import { WorkMode } from '../types/definitions'; 2 | import db from './db'; 3 | import ConfigController from '../controllers/ConfigController'; 4 | import SetupController from '../controllers/SetupController'; 5 | import ForwardController from '../controllers/ForwardController'; 6 | import DeleteMessageController from '../controllers/DeleteMessageController'; 7 | import FileAndFlashPhotoController from '../controllers/FileAndFlashPhotoController'; 8 | import Telegram from '../client/Telegram'; 9 | import OicqClient from '../client/OicqClient'; 10 | import { getLogger, Logger } from 'log4js'; 11 | import ForwardPairs from './ForwardPairs'; 12 | import InstanceManageController from '../controllers/InstanceManageController'; 13 | import InChatCommandsController from '../controllers/InChatCommandsController'; 14 | import { Api } from 'telegram'; 15 | import commands from '../constants/commands'; 16 | import TelegramChat from '../client/TelegramChat'; 17 | import RequestController from '../controllers/RequestController'; 18 | import OicqErrorNotifyController from '../controllers/OicqErrorNotifyController'; 19 | import { MarkupLike } from 'telegram/define'; 20 | import { Button } from 'telegram/tl/custom/button'; 21 | import { CustomFile } from 'telegram/client/uploads'; 22 | import { QqBot } from '@prisma/client'; 23 | import StatusReportController from '../controllers/StatusReportController'; 24 | import HugController from '../controllers/HugController'; 25 | import QuotLyController from '../controllers/QuotLyController'; 26 | import MiraiSkipFilterController from '../controllers/MiraiSkipFilterController'; 27 | import env from './env'; 28 | import AliveCheckController from '../controllers/AliveCheckController'; 29 | 30 | export default class Instance { 31 | public static readonly instances: Instance[] = []; 32 | 33 | private _owner = 0; 34 | private _isSetup = false; 35 | private _workMode = ''; 36 | private _botSessionId = 0; 37 | private _userSessionId = 0; 38 | private _qq: QqBot; 39 | private _reportUrl: string; 40 | private _flags: number; 41 | 42 | private readonly log: Logger; 43 | 44 | public tgBot: Telegram; 45 | public oicq: OicqClient; 46 | 47 | private _ownerChat: TelegramChat; 48 | 49 | public forwardPairs: ForwardPairs; 50 | private setupController: SetupController; 51 | private instanceManageController: InstanceManageController; 52 | private oicqErrorNotifyController: OicqErrorNotifyController; 53 | private requestController: RequestController; 54 | private configController: ConfigController; 55 | private deleteMessageController: DeleteMessageController; 56 | private inChatCommandsController: InChatCommandsController; 57 | private forwardController: ForwardController; 58 | private fileAndFlashPhotoController: FileAndFlashPhotoController; 59 | private statusReportController: StatusReportController; 60 | private hugController: HugController; 61 | private quotLyController: QuotLyController; 62 | private miraiSkipFilterController: MiraiSkipFilterController; 63 | private aliveCheckController: AliveCheckController; 64 | 65 | private constructor(public readonly id: number) { 66 | this.log = getLogger(`Instance - ${this.id}`); 67 | } 68 | 69 | private async load() { 70 | const dbEntry = await db.instance.findFirst({ 71 | where: { id: this.id }, 72 | include: { qqBot: true }, 73 | }); 74 | 75 | if (!dbEntry) { 76 | if (this.id === 0) { 77 | // 创建零号实例 78 | await db.instance.create({ 79 | data: { id: 0 }, 80 | }); 81 | return; 82 | } 83 | else 84 | throw new Error('Instance not found'); 85 | } 86 | 87 | this._owner = Number(dbEntry.owner); 88 | this._qq = dbEntry.qqBot; 89 | this._botSessionId = dbEntry.botSessionId; 90 | this._userSessionId = dbEntry.userSessionId; 91 | this._isSetup = dbEntry.isSetup; 92 | this._workMode = dbEntry.workMode; 93 | this._reportUrl = dbEntry.reportUrl; 94 | this._flags = dbEntry.flags; 95 | } 96 | 97 | private async init(botToken?: string) { 98 | this.log.debug('正在登录 TG Bot'); 99 | if (this.botSessionId) { 100 | this.tgBot = await Telegram.connect(this._botSessionId); 101 | } 102 | else { 103 | const token = this.id === 0 ? env.TG_BOT_TOKEN : botToken; 104 | if (!token) { 105 | throw new Error('botToken 未指定'); 106 | } 107 | this.tgBot = await Telegram.create({ 108 | botAuthToken: token, 109 | }); 110 | this.botSessionId = this.tgBot.sessionId; 111 | } 112 | this.log.info('TG Bot 登录完成'); 113 | (async () => { 114 | if (!this.isSetup || !this._owner) { 115 | this.log.info('当前服务器未配置,请向 Bot 发送 /setup 来设置'); 116 | this.setupController = new SetupController(this, this.tgBot); 117 | // 这会一直卡在这里,所以要新开一个异步来做,提前返回掉上面的 118 | ({ oicq: this.oicq } = await this.setupController.waitForFinish()); 119 | this._ownerChat = await this.tgBot.getChat(this.owner); 120 | } 121 | else { 122 | this.log.debug('正在登录 TG UserBot'); 123 | // this.tgUser = await Telegram.connect(this._userSessionId); 124 | this.log.info('TG UserBot 登录完成 //skip'); 125 | this._ownerChat = await this.tgBot.getChat(this.owner); 126 | this.log.debug('正在登录 OICQ'); 127 | this.oicq = await OicqClient.create({ 128 | id: this.qq.id, 129 | uin: Number(this.qq.uin), 130 | password: this.qq.password, 131 | platform: this.qq.platform, 132 | signApi: this.qq.signApi, 133 | signVer: this.qq.signVer, 134 | signDockerId: this.qq.signDockerId, 135 | onVerifyDevice: async (phone) => { 136 | return await this.waitForOwnerInput(`请输入手机 ${phone} 收到的验证码`); 137 | }, 138 | onVerifySlider: async (url) => { 139 | return await this.waitForOwnerInput(`收到滑块验证码 ${url}\n` + 140 | '请使用此软件验证并输入 Ticket', 141 | ); 142 | }, 143 | }); 144 | this.log.info('OICQ 登录完成'); 145 | } 146 | this.statusReportController = new StatusReportController(this, this.tgBot, this.oicq); 147 | this.forwardPairs = await ForwardPairs.load(this.id, this.oicq, this.tgBot); 148 | this.setupCommands() 149 | .then(() => this.log.info('命令设置成功')) 150 | .catch(e => this.log.error('命令设置错误', e)); 151 | if (this.id === 0) { 152 | this.instanceManageController = new InstanceManageController(this, this.tgBot); 153 | } 154 | this.oicqErrorNotifyController = new OicqErrorNotifyController(this, this.oicq); 155 | this.requestController = new RequestController(this, this.tgBot, this.oicq); 156 | this.configController = new ConfigController(this, this.tgBot, this.oicq); 157 | this.aliveCheckController = new AliveCheckController(this, this.tgBot, this.oicq); 158 | this.deleteMessageController = new DeleteMessageController(this, this.tgBot, this.oicq); 159 | this.miraiSkipFilterController = new MiraiSkipFilterController(this, this.tgBot, this.oicq); 160 | this.inChatCommandsController = new InChatCommandsController(this, this.tgBot, this.oicq); 161 | this.quotLyController = new QuotLyController(this, this.tgBot, this.oicq); 162 | this.forwardController = new ForwardController(this, this.tgBot, this.oicq); 163 | if (this.workMode === 'group') { 164 | this.hugController = new HugController(this, this.tgBot, this.oicq); 165 | } 166 | this.fileAndFlashPhotoController = new FileAndFlashPhotoController(this, this.tgBot, this.oicq); 167 | })() 168 | .then(() => this.log.info('初始化已完成')); 169 | } 170 | 171 | public async login(botToken?: string) { 172 | await this.load(); 173 | await this.init(botToken); 174 | } 175 | 176 | public static async start(instanceId: number, botToken?: string) { 177 | const instance = new this(instanceId); 178 | Instance.instances.push(instance); 179 | await instance.login(botToken); 180 | return instance; 181 | } 182 | 183 | public static async createNew(botToken: string) { 184 | const dbEntry = await db.instance.create({ data: {} }); 185 | return await this.start(dbEntry.id, botToken); 186 | } 187 | 188 | private async setupCommands() { 189 | await this.tgBot.setCommands([], new Api.BotCommandScopeUsers()); 190 | // 设定管理员的 191 | if (this.id === 0) { 192 | await this.tgBot.setCommands( 193 | this.workMode === 'personal' ? commands.personalPrivateSuperAdminCommands : commands.groupPrivateSuperAdminCommands, 194 | new Api.BotCommandScopePeer({ 195 | peer: (this.ownerChat).inputPeer, 196 | }), 197 | ); 198 | } 199 | else { 200 | await this.tgBot.setCommands( 201 | this.workMode === 'personal' ? commands.personalPrivateCommands : commands.groupPrivateCommands, 202 | new Api.BotCommandScopePeer({ 203 | peer: (this.ownerChat).inputPeer, 204 | }), 205 | ); 206 | } 207 | // 设定群组内的 208 | await this.tgBot.setCommands( 209 | this.workMode === 'personal' ? commands.personalInChatCommands : commands.groupInChatCommands, 210 | // 普通用户其实不需要这些命令,这样可以让用户的输入框少点东西 211 | new Api.BotCommandScopeChatAdmins(), 212 | ); 213 | } 214 | 215 | private async waitForOwnerInput(message?: string, buttons?: MarkupLike, remove = false) { 216 | if (!this.owner) { 217 | throw new Error('应该不会运行到这里'); 218 | } 219 | message && await this.ownerChat.sendMessage({ message, buttons: buttons || Button.clear(), linkPreview: false }); 220 | const reply = await this.ownerChat.waitForInput(); 221 | remove && await reply.delete({ revoke: true }); 222 | return reply.message; 223 | } 224 | 225 | get owner() { 226 | return this._owner; 227 | } 228 | 229 | get qq() { 230 | return this._qq; 231 | } 232 | 233 | get qqUin() { 234 | return this.oicq.uin; 235 | } 236 | 237 | get isSetup() { 238 | return this._isSetup; 239 | } 240 | 241 | get workMode() { 242 | return this._workMode as WorkMode; 243 | } 244 | 245 | get botMe() { 246 | return this.tgBot.me; 247 | } 248 | 249 | get ownerChat() { 250 | return this._ownerChat; 251 | } 252 | 253 | get botSessionId() { 254 | return this._botSessionId; 255 | } 256 | 257 | get userSessionId() { 258 | return this._userSessionId; 259 | } 260 | 261 | get reportUrl() { 262 | return this._reportUrl; 263 | } 264 | 265 | get flags() { 266 | return this._flags; 267 | } 268 | 269 | set owner(owner: number) { 270 | this._owner = owner; 271 | db.instance.update({ 272 | data: { owner }, 273 | where: { id: this.id }, 274 | }) 275 | .then(() => this.log.trace(owner)); 276 | } 277 | 278 | set isSetup(isSetup: boolean) { 279 | this._isSetup = isSetup; 280 | db.instance.update({ 281 | data: { isSetup }, 282 | where: { id: this.id }, 283 | }) 284 | .then(() => this.log.trace(isSetup)); 285 | } 286 | 287 | set workMode(workMode: WorkMode) { 288 | this._workMode = workMode; 289 | db.instance.update({ 290 | data: { workMode }, 291 | where: { id: this.id }, 292 | }) 293 | .then(() => this.log.trace(workMode)); 294 | } 295 | 296 | set botSessionId(sessionId: number) { 297 | this._botSessionId = sessionId; 298 | db.instance.update({ 299 | data: { botSessionId: sessionId }, 300 | where: { id: this.id }, 301 | }) 302 | .then(() => this.log.trace(sessionId)); 303 | } 304 | 305 | set userSessionId(sessionId: number) { 306 | this._userSessionId = sessionId; 307 | db.instance.update({ 308 | data: { userSessionId: sessionId }, 309 | where: { id: this.id }, 310 | }) 311 | .then(() => this.log.trace(sessionId)); 312 | } 313 | 314 | set qqBotId(id: number) { 315 | db.instance.update({ 316 | data: { qqBotId: id }, 317 | where: { id: this.id }, 318 | }) 319 | .then(() => this.log.trace(id)); 320 | } 321 | 322 | set reportUrl(reportUrl: string) { 323 | this._reportUrl = reportUrl; 324 | db.instance.update({ 325 | data: { reportUrl }, 326 | where: { id: this.id }, 327 | }) 328 | .then(() => this.log.trace(reportUrl)); 329 | } 330 | 331 | set flags(value) { 332 | this._flags = value; 333 | db.instance.update({ 334 | data: { flags: value }, 335 | where: { id: this.id }, 336 | }) 337 | .then(() => this.log.trace(value)); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /main/src/services/ConfigService.ts: -------------------------------------------------------------------------------- 1 | import Telegram from '../client/Telegram'; 2 | import { Friend, FriendInfo, Group, GroupInfo } from '@icqqjs/icqq'; 3 | import { Button } from 'telegram/tl/custom/button'; 4 | import { getLogger, Logger } from 'log4js'; 5 | import { getAvatar } from '../utils/urls'; 6 | import { CustomFile } from 'telegram/client/uploads'; 7 | import db from '../models/db'; 8 | import { Api, utils } from 'telegram'; 9 | import OicqClient from '../client/OicqClient'; 10 | import { md5 } from '../utils/hashing'; 11 | import TelegramChat from '../client/TelegramChat'; 12 | import Instance from '../models/Instance'; 13 | import getAboutText from '../utils/getAboutText'; 14 | import random from '../utils/random'; 15 | 16 | const DEFAULT_FILTER_ID = 114; // 514 17 | 18 | export default class ConfigService { 19 | private owner: Promise; 20 | private readonly log: Logger; 21 | 22 | constructor(private readonly instance: Instance, 23 | private readonly tgBot: Telegram, 24 | private readonly oicq: OicqClient) { 25 | this.log = getLogger(`ConfigService - ${instance.id}`); 26 | this.owner = tgBot.getChat(this.instance.owner); 27 | } 28 | 29 | private getAssociateLink(roomId: number) { 30 | return `https://t.me/${this.tgBot.me.username}?startgroup=${roomId}`; 31 | } 32 | 33 | // region 打开添加关联的菜单 34 | 35 | // 开始添加转发群组流程 36 | public async addGroup() { 37 | const qGroups = Array.from(this.oicq.gl).map(e => e[1]) 38 | .filter(it => !this.instance.forwardPairs.find(-it.group_id)); 39 | const buttons = qGroups.map(e => 40 | this.instance.workMode === 'personal' ? 41 | [Button.inline( 42 | `${e.group_name} (${e.group_id})`, 43 | this.tgBot.registerCallback(() => this.onSelectChatPersonal(e)), 44 | )] : 45 | [Button.url( 46 | `${e.group_name} (${e.group_id})`, 47 | this.getAssociateLink(-e.group_id), 48 | )]); 49 | await (await this.owner).createPaginatedInlineSelector( 50 | '选择 QQ 群组' + (this.instance.workMode === 'group' ? '\n然后选择在 TG 中的群组' : ''), buttons); 51 | } 52 | 53 | // 只可能是 personal 运行模式 54 | public async addFriend() { 55 | const classes = Array.from(this.oicq.classes); 56 | const friends = Array.from(this.oicq.fl).map(e => e[1]); 57 | classes.sort((a, b) => { 58 | if (a[1] < b[1]) { 59 | return -1; 60 | } 61 | else if (a[1] == b[1]) { 62 | return 0; 63 | } 64 | else { 65 | return 1; 66 | } 67 | }); 68 | await (await this.owner).createPaginatedInlineSelector('选择分组', classes.map(e => [ 69 | Button.inline(e[1], this.tgBot.registerCallback( 70 | () => this.openFriendSelection(friends.filter(f => f.class_id === e[0]), e[1]), 71 | )), 72 | ])); 73 | } 74 | 75 | private async openFriendSelection(clazz: FriendInfo[], name: string) { 76 | clazz = clazz.filter(them => !this.instance.forwardPairs.find(them.user_id)); 77 | await (await this.owner).createPaginatedInlineSelector(`选择 QQ 好友\n分组:${name}`, clazz.map(e => [ 78 | Button.inline(`${e.remark || e.nickname} (${e.user_id})`, this.tgBot.registerCallback( 79 | () => this.onSelectChatPersonal(e), 80 | )), 81 | ])); 82 | } 83 | 84 | private async onSelectChatPersonal(info: FriendInfo | GroupInfo) { 85 | const roomId = 'user_id' in info ? info.user_id : -info.group_id; 86 | const name = 'user_id' in info ? info.remark || info.nickname : info.group_name; 87 | const entity = this.oicq.getChat(roomId); 88 | const avatar = await getAvatar(roomId); 89 | const message = await (await this.owner).sendMessage({ 90 | message: await getAboutText(entity, true), 91 | buttons: [ 92 | [Button.inline('自动创建群组', this.tgBot.registerCallback( 93 | async () => { 94 | await message.delete({ revoke: true }); 95 | this.createGroupAndLink(roomId, name); 96 | }))], 97 | [Button.url('手动选择现有群组', this.getAssociateLink(roomId))], 98 | ], 99 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 100 | }); 101 | } 102 | 103 | public async addExact(gin: number) { 104 | const group = this.oicq.gl.get(gin); 105 | let avatar: Buffer; 106 | try { 107 | avatar = await getAvatar(-group.group_id); 108 | } 109 | catch (e) { 110 | avatar = null; 111 | this.log.error(`加载 ${group.group_name} (${gin}) 的头像失败`, e); 112 | } 113 | const message = `${group.group_name}\n${group.group_id}\n${group.member_count} 名成员`; 114 | await (await this.owner).sendMessage({ 115 | message, 116 | file: avatar ? new CustomFile('avatar.png', avatar.length, '', avatar) : undefined, 117 | buttons: Button.url('关联 Telegram 群组', this.getAssociateLink(-group.group_id)), 118 | }); 119 | } 120 | 121 | // endregion 122 | 123 | /** 124 | * 125 | * @param room 126 | * @param title 127 | * @param status 传入 false 的话就不显示状态信息,可以传入一条已有消息覆盖 128 | * @param chat 129 | */ 130 | public async createGroupAndLink(room: number | Friend | Group, title?: string, status: boolean | Api.Message = true, chat?: TelegramChat) { 131 | this.log.info(`创建群组并关联:${room}`); 132 | if (typeof room === 'number') { 133 | room = this.oicq.getChat(room); 134 | } 135 | if (!title) { 136 | // TS 这边不太智能 137 | if (room instanceof Friend) { 138 | title = room.remark || room.nickname; 139 | } 140 | else { 141 | title = room.name; 142 | } 143 | } 144 | let isFinish = false; 145 | try { 146 | let errorMessage = ''; 147 | // 状态信息 148 | if (status === true) { 149 | const avatar = await getAvatar(room); 150 | const statusReceiver = chat ? await this.tgBot.getChat(chat.id) : await this.owner; 151 | status = await statusReceiver.sendMessage({ 152 | message: '正在创建 Telegram 群…', 153 | file: new CustomFile('avatar.png', avatar.length, '', avatar), 154 | }); 155 | } 156 | else if (status instanceof Api.Message) { 157 | await status.edit({ text: '正在创建 Telegram 群…', buttons: Button.clear() }); 158 | } 159 | 160 | /* if (!chat) { 161 | // 创建群聊,拿到的是 user 的 chat 162 | chat = await this.tgUser.createChat(title, await getAboutText(room, false)); 163 | 164 | // 添加机器人 165 | status && await status.edit({ text: '正在添加机器人…' }); 166 | await chat.inviteMember(this.tgBot.me.id); 167 | } */ 168 | 169 | // 设置管理员 170 | status && await status.edit({ text: '正在设置管理员…' }); 171 | await chat.setAdmin(this.tgBot.me.username); 172 | 173 | // 添加到 Filter 174 | /* try { 175 | status && await status.edit({ text: '正在将群添加到文件夹…' }); 176 | const dialogFilters = await this.tgUser.getDialogFilters() as Api.DialogFilter[]; 177 | const filter = dialogFilters.find(e => e.id === DEFAULT_FILTER_ID); 178 | if (filter) { 179 | filter.includePeers.push(utils.getInputPeer(chat)); 180 | await this.tgUser.updateDialogFilter({ 181 | id: DEFAULT_FILTER_ID, 182 | filter, 183 | }); 184 | } 185 | } 186 | catch (e) { 187 | errorMessage += `\n添加到文件夹失败:${e.message}`; 188 | } */ 189 | 190 | // 关闭【添加成员】快捷条 191 | try { 192 | status && await status.edit({ text: '正在关闭【添加成员】快捷条…' }); 193 | await chat.hidePeerSettingsBar(); 194 | } 195 | catch (e) { 196 | errorMessage += `\n关闭【添加成员】快捷条失败:${e.message}`; 197 | } 198 | 199 | // 关联写入数据库 200 | const chatForBot = await this.tgBot.getChat(chat.id); 201 | status && await status.edit({ text: '正在写数据库…' }); 202 | const dbPair = await this.instance.forwardPairs.add(room, chatForBot, chat); 203 | isFinish = true; 204 | 205 | // 更新头像 206 | try { 207 | status && await status.edit({ text: '正在更新头像…' }); 208 | const avatar = await getAvatar(room); 209 | const avatarHash = md5(avatar); 210 | await chatForBot.setProfilePhoto(avatar); 211 | await db.avatarCache.create({ 212 | data: { forwardPairId: dbPair.id, hash: avatarHash }, 213 | }); 214 | } 215 | catch (e) { 216 | errorMessage += `\n更新头像失败:${e.message}`; 217 | } 218 | 219 | // 完成 220 | if (status) { 221 | await status.edit({ text: '正在获取链接…' }); 222 | const { link } = await chat.getInviteLink() as Api.ChatInviteExported; 223 | await status.edit({ 224 | text: '创建完成!' + (errorMessage ? '但发生以下错误' + errorMessage : ''), 225 | buttons: Button.url('打开', link), 226 | }); 227 | } 228 | } 229 | catch (e) { 230 | this.log.error('创建群组并关联失败', e); 231 | await (await this.owner).sendMessage(`创建群组并关联${isFinish ? '成功了但没完全成功' : '失败'}\n${e}`); 232 | } 233 | } 234 | 235 | public async promptNewQqChat(chat: Group | Friend) { 236 | const message = await (await this.owner).sendMessage({ 237 | message: '你' + 238 | (chat instanceof Group ? '加入了一个新的群' : '增加了一' + random.pick('位', '个', '只', '头') + '好友') + 239 | ':\n' + 240 | await getAboutText(chat, true) + '\n' + 241 | '要创建关联群吗', 242 | buttons: Button.inline('创建', this.tgBot.registerCallback(async () => { 243 | await message.delete({ revoke: true }); 244 | this.createGroupAndLink(chat, chat instanceof Group ? chat.name : chat.remark || chat.nickname); 245 | })), 246 | }); 247 | return message; 248 | } 249 | 250 | public async createLinkGroup(qqRoomId: number, tgChatId: number) { 251 | if (this.instance.workMode === 'group') { 252 | try { 253 | const qGroup = this.oicq.getChat(qqRoomId) as Group; 254 | const tgChat = await this.tgBot.getChat(tgChatId); 255 | const tgUserChat = await this.tgUser.getChat(tgChatId); 256 | await this.instance.forwardPairs.add(qGroup, tgChat, tgUserChat); 257 | await tgChat.sendMessage(`QQ群:${qGroup.name} (${qGroup.group_id})已与 ` + 258 | `Telegram 群 ${(tgChat.entity as Api.Channel).title} (${tgChatId})关联`); 259 | if (!(tgChat.entity instanceof Api.Channel)) { 260 | // TODO 添加一个转换为超级群组的方法链接 261 | await tgChat.sendMessage({ 262 | message: '请注意,这个群不是超级群组。一些功能,比如说同步撤回,可能会工作不正常。建议将此群组转换为超级群组', 263 | linkPreview: false, 264 | }); 265 | } 266 | } 267 | catch (e) { 268 | this.log.error(e); 269 | await (await this.owner).sendMessage(`错误:${e}`); 270 | } 271 | } 272 | else { 273 | await (await this.owner).sendMessage(`跳过`); 274 | /* const chat = await this.tgUser.getChat(tgChatId); 275 | await this.createGroupAndLink(qqRoomId, undefined, true, chat); */ 276 | } 277 | } 278 | 279 | // 创建 QQ 群组的文件夹 280 | /* public async setupFilter() { 281 | const result = await this.tgUser.getDialogFilters() as Api.DialogFilter[]; 282 | let filter = result.find(e => e.id === DEFAULT_FILTER_ID); 283 | if (!filter) { 284 | this.log.info('创建 TG 文件夹'); 285 | // 要自己计算新的 id,随意 id 也是可以的 286 | // https://github.com/morethanwords/tweb/blob/7d646bc9a87d943426d831f30b69d61b743f51e0/src/lib/storages/filters.ts#L251 287 | // 创建 288 | filter = new Api.DialogFilter({ 289 | id: DEFAULT_FILTER_ID, 290 | title: 'QQ', 291 | pinnedPeers: [ 292 | (await this.tgUser.getChat(this.tgBot.me.username)).inputPeer, 293 | ], 294 | includePeers: [], 295 | excludePeers: [], 296 | emoticon: '🐧', 297 | }); 298 | let errorText = '设置文件夹失败'; 299 | try { 300 | const isSuccess = await this.tgUser.updateDialogFilter({ 301 | id: DEFAULT_FILTER_ID, 302 | filter, 303 | }); 304 | if (!isSuccess) { 305 | this.log.error(errorText); 306 | await (await this.owner).sendMessage(errorText); 307 | } 308 | } 309 | catch (e) { 310 | this.log.error(errorText, e); 311 | await (await this.owner).sendMessage(errorText + `\n${e}`); 312 | } 313 | } 314 | } */ 315 | 316 | /* public async migrateAllChats() { 317 | const dbPairs = await db.forwardPair.findMany(); 318 | for (const forwardPair of dbPairs) { 319 | const chatForUser = await this.tgUser.getChat(Number(forwardPair.tgChatId)); 320 | if (chatForUser.entity instanceof Api.Chat) { 321 | this.log.info('升级群组 ', chatForUser.id); 322 | await chatForUser.migrate(); 323 | } 324 | } 325 | } */ 326 | } -------------------------------------------------------------------------------- /main/src/controllers/QuotLyController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import OicqClient from '../client/OicqClient'; 4 | import { getLogger, Logger } from 'log4js'; 5 | import { Group, GroupMessageEvent, PrivateMessageEvent } from '@icqqjs/icqq'; 6 | import { Api } from 'telegram'; 7 | import quotly from 'quote-api/methods/generate.js'; 8 | import { CustomFile } from 'telegram/client/uploads'; 9 | import db from '../models/db'; 10 | import { Message } from '@prisma/client'; 11 | import BigInteger from 'big-integer'; 12 | import { getAvatarUrl } from '../utils/urls'; 13 | import convert from '../helpers/convert'; 14 | import { Pair } from '../models/Pair'; 15 | import env from '../models/env'; 16 | import flags from '../constants/flags'; 17 | 18 | export default class { 19 | private readonly log: Logger; 20 | 21 | constructor(private readonly instance: Instance, 22 | private readonly tgBot: Telegram, 23 | private readonly oicq: OicqClient) { 24 | this.log = getLogger(`QuotLyController - ${instance.id}`); 25 | oicq.addNewMessageEventHandler(this.onQqMessage); 26 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 27 | } 28 | 29 | private onQqMessage = async (event: PrivateMessageEvent | GroupMessageEvent) => { 30 | if (this.instance.workMode === 'personal') return; 31 | if (event.message_type !== 'group') return; 32 | const pair = this.instance.forwardPairs.find(event.group); 33 | if (!pair) return; 34 | const chain = [...event.message]; 35 | while (chain.length && chain[0].type !== 'text') { 36 | chain.shift(); 37 | } 38 | const firstElem = chain[0]; 39 | if (firstElem?.type !== 'text') return; 40 | if (firstElem.text.trim() !== '/q') return; 41 | if (!event.source) { 42 | await event.reply('请回复一条消息', true); 43 | return true; 44 | } 45 | const sourceMessage = await db.message.findFirst({ 46 | where: { 47 | instanceId: this.instance.id, 48 | qqRoomId: pair.qqRoomId, 49 | qqSenderId: event.source.user_id, 50 | seq: event.source.seq, 51 | // rand: event.source.rand, 52 | }, 53 | }); 54 | if (!sourceMessage) { 55 | await event.reply('无法从数据库找到原消息', true); 56 | this.log.error('找不到 sourceMessage'); 57 | return true; 58 | } 59 | if (!((pair.flags | this.instance.flags) & flags.NO_QUOTE_PIN)) { 60 | this.pinMessageOnBothSide(pair, sourceMessage).then(); 61 | } 62 | // 异步发送,为了让 /q 先到达 63 | this.sendQuote(pair, sourceMessage).catch(async e => { 64 | this.log.error(e); 65 | await event.reply(e.toString(), true); 66 | }); 67 | }; 68 | 69 | private onTelegramMessage = async (message: Api.Message) => { 70 | if (message.message !== '/q') return; 71 | const pair = this.instance.forwardPairs.find(message.chat); 72 | if (!pair) return; 73 | if (!message.replyTo) { 74 | await message.reply({ 75 | message: '请回复一条消息', 76 | }); 77 | return true; 78 | } 79 | const sourceMessage = await db.message.findFirst({ 80 | where: { 81 | instanceId: this.instance.id, 82 | tgChatId: pair.tgId, 83 | tgMsgId: message.replyToMsgId, 84 | }, 85 | }); 86 | if (!sourceMessage) { 87 | await message.reply({ 88 | message: '无法从数据库找到原消息', 89 | }); 90 | this.log.error('找不到 sourceMessage'); 91 | return true; 92 | } 93 | if (!((pair.flags | this.instance.flags) & flags.NO_QUOTE_PIN)) { 94 | this.pinMessageOnBothSide(pair, sourceMessage).then(); 95 | } 96 | // 异步发送,为了让 /q 先到达 97 | this.sendQuote(pair, sourceMessage).catch(async e => { 98 | this.log.error(e); 99 | await message.reply({ 100 | message: e.toString(), 101 | }); 102 | }); 103 | 104 | // 个人模式下,/q 这条消息不转发到 QQ,怪话图只有自己可见 105 | if (this.instance.workMode === 'personal') return true; 106 | }; 107 | 108 | private async pinMessageOnBothSide(pair: Pair, sourceMessage: Awaited>) { 109 | if (pair.qq instanceof Group) { 110 | try { 111 | await pair.qq.addEssence(sourceMessage.seq, Number(sourceMessage.rand)); 112 | } 113 | catch (e) { 114 | this.log.warn('无法添加精华消息,群:', pair.qqRoomId, e); 115 | } 116 | } 117 | try { 118 | const tgMessage = await pair.tg.getMessage({ ids: sourceMessage.tgMsgId }); 119 | await tgMessage.pin({ notify: false, pmOneSide: false }); 120 | } 121 | catch (e) { 122 | this.log.warn('无法置顶消息,群:', pair.tgId, '消息 ID:', sourceMessage.tgMsgId, e); 123 | } 124 | } 125 | 126 | private async genQuote(message: Message) { 127 | const GROUP_ANONYMOUS_BOT = 1087968824n; 128 | 129 | const backgroundColor = '#292232'; 130 | const emojiBrand = 'apple'; 131 | const width = 512; 132 | const height = 512 * 1.5; 133 | const scale = 2; 134 | const type = 'quote'; 135 | const format = 'png'; 136 | 137 | const originTgMessages = await this.tgBot.getMessage(BigInteger(message.tgChatId), { 138 | ids: message.tgMsgId, 139 | }); 140 | if (!originTgMessages.length) { 141 | throw new Error('无法获取 Tg 原消息'); 142 | } 143 | const originTgMessage = originTgMessages[0]; 144 | 145 | // https://github.com/LyoSU/quote-api/blob/6e27746bb3e946205cb60607a85239747b4640ef/utils/quote-generate.js#L150 146 | // 不太能用 buffer 147 | type Media = { url: string /* | Buffer*/ }; 148 | type MessageFrom = { 149 | id: number, 150 | name: string | false, 151 | title: string, 152 | username?: string, 153 | first_name?: string, 154 | last_name?: string, 155 | photo?: Media, 156 | }; 157 | let messageFrom: MessageFrom; 158 | let quoteMessage: { 159 | entities?: any[] 160 | media?: Media[] | Media 161 | mediaType?: 'sticker' 162 | voice?: { waveform?: any } 163 | chatId: number 164 | avatar: boolean 165 | from: MessageFrom 166 | text?: string 167 | } = { 168 | chatId: Number(message.tgChatId), 169 | avatar: true, 170 | from: null, // to be added 171 | text: message.tgMessageText, 172 | }; 173 | 174 | if (this.tgBot.me.id.eq(message.tgSenderId)) { 175 | // From QQ 176 | messageFrom = { 177 | id: Number(message.qqSenderId), 178 | name: message.nick, 179 | title: message.nick, 180 | photo: { url: getAvatarUrl(message.qqSenderId) }, 181 | }; 182 | if (message.qqRoomId > 0 || message.richHeaderUsed) { 183 | quoteMessage.text = message.tgMessageText; 184 | } 185 | else if (message.tgMessageText.includes('\n')) { 186 | quoteMessage.text = message.tgMessageText.substring(message.tgMessageText.indexOf('\n')).trim(); 187 | } 188 | else { 189 | quoteMessage.text = null; 190 | } 191 | } 192 | else if (message.tgSenderId === GROUP_ANONYMOUS_BOT || message.tgSenderId === 777000n) { 193 | const chat = originTgMessage.chat as Api.Channel; 194 | let photo: string; 195 | if (chat.photo instanceof Api.ChatPhoto) { 196 | photo = await convert.cachedBuffer(`${chat.photo.photoId.toString(16)}.jpg`, () => this.tgBot.downloadEntityPhoto(chat)); 197 | } 198 | messageFrom = { 199 | id: Number(chat.id.toString()), 200 | name: chat.title, 201 | title: chat.title, 202 | username: chat.username || null, 203 | photo: photo ? { url: photo } : null, 204 | }; 205 | quoteMessage.entities = originTgMessage.entities; 206 | } 207 | else { 208 | const sender = originTgMessage.sender as Api.User; 209 | let photo: string; 210 | if (sender.photo instanceof Api.UserProfilePhoto) { 211 | photo = await convert.cachedBuffer(`${sender.photo.photoId.toString(16)}.jpg`, () => this.tgBot.downloadEntityPhoto(sender)); 212 | } 213 | messageFrom = { 214 | id: sender.color || Number(message.tgSenderId), 215 | name: message.nick, 216 | title: message.nick, 217 | username: sender.username, 218 | first_name: sender.firstName, 219 | last_name: sender.lastName, 220 | photo: photo ? { url: photo } : null, 221 | }; 222 | if (originTgMessage.entities) 223 | quoteMessage.entities = await Promise.all(originTgMessage.entities?.map?.(async it => { 224 | let type = ''; 225 | let emoji = ''; 226 | switch (it.className) { 227 | case 'MessageEntityBold': 228 | type = 'bold'; 229 | break; 230 | case 'MessageEntityItalic': 231 | type = 'italic'; 232 | break; 233 | case 'MessageEntityStrike': 234 | type = 'strikethrough'; 235 | break; 236 | case 'MessageEntityUnderline': 237 | type = 'underline'; 238 | break; 239 | case 'MessageEntitySpoiler': 240 | type = 'spoiler'; 241 | break; 242 | case 'MessageEntityCode': 243 | case 'MessageEntityPre': 244 | type = 'code'; 245 | break; 246 | case 'MessageEntityMention': 247 | case 'MessageEntityMentionName': 248 | case 'InputMessageEntityMentionName': 249 | case 'MessageEntityHashtag': 250 | case 'MessageEntityEmail': 251 | case 'MessageEntityPhone': 252 | case 'MessageEntityBotCommand': 253 | case 'MessageEntityUrl': 254 | case 'MessageEntityTextUrl': 255 | type = 'mention'; 256 | break; 257 | case 'MessageEntityCustomEmoji': 258 | type = 'custom_emoji'; 259 | emoji = await convert.customEmoji(it.documentId.toString(16), 260 | () => this.tgBot.getCustomEmoji(it.documentId), 261 | false); 262 | break; 263 | } 264 | return { 265 | type, emoji, 266 | offset: it.offset, 267 | length: it.length, 268 | }; 269 | })); 270 | } 271 | 272 | if (originTgMessage.voice) { 273 | const attribute = originTgMessage.voice.attributes.find(it => it instanceof Api.DocumentAttributeAudio) as Api.DocumentAttributeAudio; 274 | quoteMessage.voice = { waveform: attribute.waveform }; 275 | } 276 | else if (originTgMessage.photo instanceof Api.Photo || originTgMessage.document?.mimeType?.startsWith('image/')) { 277 | if (originTgMessage.document?.mimeType === 'image/webp') { 278 | quoteMessage.media = { url: await convert.cachedBuffer(`${originTgMessage.document.id.toString(16)}.webp`, () => originTgMessage.downloadMedia({})) }; 279 | } 280 | else { 281 | quoteMessage.media = { url: await convert.cachedBuffer(`${originTgMessage.photo.id.toString(16)}.jpg`, () => originTgMessage.downloadMedia({})) }; 282 | } 283 | } 284 | else if (originTgMessage.video || originTgMessage.videoNote || originTgMessage.gif) { 285 | const file = originTgMessage.video || originTgMessage.videoNote || originTgMessage.gif; 286 | quoteMessage.media = { url: await convert.cachedBuffer(`${file.id.toString(16)}-thumb.webp`, () => this.tgBot.downloadThumb(file)) }; 287 | } 288 | else if (originTgMessage.sticker) { 289 | quoteMessage.media = { url: await convert.cachedBuffer(`${originTgMessage.document.id.toString(16)}.tgs`, () => originTgMessage.downloadMedia({})) }; 290 | } 291 | if (originTgMessage.sticker) { 292 | quoteMessage.mediaType = 'sticker'; 293 | } 294 | 295 | quoteMessage.from = messageFrom; 296 | if (!quoteMessage.text && !quoteMessage.media && !quoteMessage.voice) { 297 | throw new Error('不支持的消息类型'); 298 | } 299 | const res = await quotly({ 300 | botToken: env.TG_BOT_TOKEN, 301 | type, 302 | format, 303 | backgroundColor, 304 | width, 305 | height, 306 | scale, 307 | messages: [quoteMessage], 308 | emojiBrand, 309 | }); 310 | return Buffer.from(res.image, 'base64'); 311 | } 312 | 313 | private async sendQuote(pair: Pair, message: Message) { 314 | const image = await this.genQuote(message); 315 | 316 | const tgMessage = await pair.tg.sendMessage({ 317 | file: new CustomFile('quote.webp', image.length, undefined, image), 318 | }); 319 | 320 | if (this.instance.workMode === 'personal') return; 321 | 322 | const qqMessage = await pair.qq.sendMsg({ 323 | type: 'image', 324 | file: image, 325 | asface: true, 326 | }); 327 | await db.message.create({ 328 | data: { 329 | qqRoomId: pair.qqRoomId, 330 | qqSenderId: this.oicq.uin, 331 | time: qqMessage.time, 332 | brief: '[Quote]', 333 | seq: qqMessage.seq, 334 | rand: qqMessage.rand, 335 | pktnum: 1, 336 | tgChatId: pair.tgId, 337 | tgMsgId: tgMessage.id, 338 | instanceId: this.instance.id, 339 | tgMessageText: tgMessage.message, 340 | nick: '系统', 341 | tgSenderId: BigInt(this.tgBot.me.id.toString()), 342 | }, 343 | }); 344 | } 345 | } 346 | --------------------------------------------------------------------------------