├── .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 | ///
${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/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${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 | ${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 | 交流 ${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${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${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${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${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