/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/getAboutText.ts:
--------------------------------------------------------------------------------
1 | import { Group as OicqGroup } from '@icqqjs/icqq';
2 | import { Friend, Group, GroupMemberInfo } from '../client/QQClient';
3 | import { NapCatGroup } from '../client/NapCatClient';
4 |
5 | export default async function getAboutText(entity: Friend | Group, html: boolean) {
6 | let text: string;
7 | if ('uin' in entity) {
8 | text = `备注:${entity.remark}\n` +
9 | `昵称:${entity.nickname}\n` +
10 | `账号:${entity.uin}`;
11 | }
12 | else {
13 | let owner: GroupMemberInfo;
14 | let memberCount: number;
15 | if (entity instanceof OicqGroup) {
16 | owner = await entity.pickMember(entity.info.owner_id).renew();
17 | memberCount = entity.info.member_count;
18 | }
19 | else if (entity instanceof NapCatGroup) {
20 | const membersInfo = await entity.getAllMemberInfo();
21 | owner = membersInfo.find(member => member.role === 'owner');
22 | memberCount = membersInfo.length;
23 | }
24 | const self = await entity.pickMember(entity.client.uin).renew();
25 | text = `群名称:${entity.name}\n` +
26 | `${memberCount} 名成员\n` +
27 | `群号:${entity.gid}\n` +
28 | (self ? `我的群名片:${self.title ? `「${self.title}」` : ''}${self.card}\n` : '') +
29 | (owner ? `群主:${owner.title ? `「${owner.title}」` : ''}` +
30 | `${owner.card || owner.nickname} (${owner.user_id})` : '') +
31 | ((entity.is_admin || entity.is_owner) ? '\n可管理' : '');
32 | }
33 |
34 | if (!html) {
35 | text = text.replace(/<\/?\w+>/g, '');
36 | }
37 | return text;
38 | }
39 |
--------------------------------------------------------------------------------
/ui/src/views/ChatRecord/index.tsx:
--------------------------------------------------------------------------------
1 | import { computed, defineComponent, effect, ref } from 'vue';
2 | import styles from './index.module.sass';
3 | import { useBrowserLocation } from '@vueuse/core';
4 | import Viewer from './Viewer';
5 | import client from '@/utils/client';
6 |
7 | export default defineComponent({
8 | setup() {
9 | const location = useBrowserLocation();
10 | const uuid = computed(() => {
11 | const params = new URLSearchParams(location.value.search);
12 | return params.get('tgWebAppStartParam');
13 | });
14 | const loading = ref(true);
15 | const data = ref(null);
16 | const error = ref${instance.qqUin} (${oicq.constructor.name})\t` +
48 | `${boolToStr(await oicq.isOnline())}`,
49 |
50 | ...(oicq instanceof OicqClient ? [`签名服务器\t${boolToStr(sign.length > 0)}`] : []),
51 |
52 | `TG @${tgBot.me.username}\t${boolToStr(tgBot.isOnline)}`,
53 |
54 | `TG User ${tgUserName}\t${boolToStr(tgBot.isOnline)}`,
55 | ].join('\n'));
56 | }
57 |
58 | return messageParts.join('\n\n');
59 | };
60 | }
61 |
--------------------------------------------------------------------------------
/docker-compose-examples/NapCat/with-cloudflare-tunnel/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | volumes:
4 | postgresql:
5 | q2tg:
6 | cache:
7 | napcat-data:
8 | napcat-config:
9 |
10 | services:
11 | # 如果有现成的 Postgresql 实例,可以删除这一小节
12 | postgres:
13 | image: postgres:14-alpine
14 | restart: unless-stopped
15 | environment:
16 | POSTGRES_DB: db_name
17 | POSTGRES_USER: user
18 | POSTGRES_PASSWORD: password
19 | volumes:
20 | - postgresql:/var/lib/postgresql/data
21 |
22 | tunnel:
23 | container_name: cloudflared-tunnel
24 | image: cloudflare/cloudflared
25 | restart: unless-stopped
26 | command: tunnel run
27 | environment:
28 | - TUNNEL_TOKEN= #此处填入设定Cloudflare Tunnel时产生的指令的 --token 后面那一串密钥
29 |
30 | napcat:
31 | image: mlikiowa/napcat-docker:latest
32 | environment:
33 | - ACCOUNT=要登录的 QQ 号
34 | - WS_ENABLE=true
35 | - NAPCAT_GID=1000
36 | - NAPCAT_UID=1000
37 | ports:
38 | - 6099:6099
39 | mac_address: 02:42:12:34:56:78 # 请修改为一个固定的 MAC 地址,但是不要和其他容器或你的主机重复
40 | restart: unless-stopped
41 | volumes:
42 | - napcat-data:/app/.config/QQ
43 | - napcat-config:/app/napcat/config
44 | - cache:/app/.config/QQ/NapCat/temp
45 |
46 | q2tg:
47 | image: ghcr.io/clansty/q2tg:sleepyfox
48 | restart: unless-stopped
49 | depends_on:
50 | - postgres
51 | - napcat
52 | ports:
53 | # 如果要使用 RICH_HEADER 需要将端口发布到公网
54 | - 8080:8080
55 | volumes:
56 | - q2tg:/app/data
57 | - cache:/app/.config/QQ/NapCat/temp
58 | - /var/run/docker.sock:/var/run/docker.sock
59 | environment:
60 | - TG_API_ID=
61 | - TG_API_HASH=
62 | - TG_BOT_TOKEN=
63 | - DATABASE_URL=postgres://user:password@postgres/db_name
64 | - NAPCAT_WS_URL=ws://napcat:3001
65 | - TG_CONNECTION=tcp # 连接 Telegram 的方式,也可以改成 websocket
66 | # 如果你需要使用 /flags set RICH_HEADER 来显示头像,或者正确显示合并转发的消息记录,则需将 q2tg 8080 端口发布到公网,可以使用 cloudflare tunnel
67 | # 请尽量配置这个服务
68 | - WEB_ENDPOINT= # https://yourichheader.com 填写你发布到公网的域名
69 | #- CRV_VIEWER_APP=
70 | # DEPRECATED: 请使用 WEB_ENDPOINT
71 | #- CRV_API=
72 | #- CRV_KEY=
73 | # 要关闭文件上传提示,请取消注释以下变量 https://github.com/clansty/Q2TG/issues/153
74 | #- DISABLE_FILE_UPLOAD_TIP=1
75 | # 如果需要通过代理联网,那么设置下面两个变量
76 | #- PROXY_IP=
77 | #- PROXY_PORT=
78 | # 代理联网认证,有需要请修改下面两个变量
79 | #- PROXY_USERNAME=
80 | #- PROXY_PASSWORD=
81 |
--------------------------------------------------------------------------------
/docker-compose-examples/icqq/with-nginx-certbot/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | volumes:
4 | postgresql:
5 | q2tg:
6 | cache:
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 |
20 | sign:
21 | image: ghcr.io/clansty/qsign
22 | restart: unless-stopped
23 |
24 | q2tg:
25 | image: ghcr.io/clansty/q2tg:sleepyfox
26 | restart: unless-stopped
27 | depends_on:
28 | - postgres
29 | - sign
30 | ports:
31 | # 如果要使用 RICH_HEADER 需要将端口发布到公网
32 | - 8080:8080
33 | volumes:
34 | - q2tg:/app/data
35 | # 下面这行是固定的,和你用不用 NapCat 没关系,不要动
36 | - cache:/app/.config/QQ/NapCat/temp
37 | - /var/run/docker.sock:/var/run/docker.sock
38 | environment:
39 | - TG_API_ID=
40 | - TG_API_HASH=
41 | - TG_BOT_TOKEN=
42 | - DATABASE_URL=postgres://user:password@postgres/db_name
43 | - SIGN_API=http://sign:4848/sign?key=114514
44 | - SIGN_VER=9.0.56 # 与上方 sign 容器的配置同步
45 | - TG_CONNECTION=tcp # 连接 Telegram 的方式,也可以改成 websocket
46 | # 如果你需要使用 /flags set RICH_HEADER 来显示头像,或者正确显示合并转发的消息记录,则需将 q2tg 8080 端口发布到公网,可以使用 cloudflare tunnel
47 | # 请尽量配置这个服务
48 | - WEB_ENDPOINT= # https://yourichheader.com 填写你发布到公网的域名
49 | #- CRV_VIEWER_APP=
50 | # DEPRECATED: 请使用 WEB_ENDPOINT
51 | #- CRV_API=
52 | #- CRV_KEY=
53 | # 要关闭文件上传提示,请取消注释以下变量 https://github.com/clansty/Q2TG/issues/153
54 | #- DISABLE_FILE_UPLOAD_TIP=1
55 | # 如果需要通过代理联网,那么设置下面两个变量
56 | #- PROXY_IP=
57 | #- PROXY_PORT=
58 | # 代理联网认证,有需要请修改下面两个变量
59 | #- PROXY_USERNAME=
60 | #- PROXY_PASSWORD=
61 |
62 | nginx:
63 | image: nginx:alpine
64 | restart: unless-stopped
65 | ports:
66 | - 80:80
67 | - 443:443
68 | volumes:
69 | - ./nginx.conf:/etc/nginx/nginx.conf:ro
70 | - ./certbot/www:/var/www/certbot:ro
71 | - ./certbot/cert:/etc/letsencrypt:ro
72 | depends_on:
73 | - q2tg
74 |
75 | certbot:
76 | image: certbot/certbot:latest
77 | volumes:
78 | - ./certbot/www:/var/www/certbot
79 | - ./certbot/cert:/etc/letsencrypt
80 | depends_on:
81 | - nginx
82 | command: certonly --webroot -w /var/www/certbot --force-renewal --email 你的邮箱 -d 你的域名 --agree-tos
83 |
--------------------------------------------------------------------------------
/ui/src/views/ChatRecord/Viewer/types/BilibiliMiniApp.d.ts:
--------------------------------------------------------------------------------
1 | //一般通过小程序应该也适用
2 | export default interface BilibiliMiniApp{
3 | "app": "com.tencent.miniapp_01",
4 | "config": any,
5 | "desc": "哔哩哔哩",
6 | "extra": any,
7 | "meta": {
8 | "detail_1": {
9 | "appid": string,
10 | //视频名字
11 | "desc": string,
12 | "gamePoints": "",
13 | "gamePointsUrl": "",
14 | "host": {
15 | "nick": string,
16 | "uin": number
17 | },
18 | "icon": string,
19 | //预览图
20 | "preview": string,
21 | "qqdocurl": string,
22 | "scene": number,
23 | "shareTemplateData": {},
24 | "shareTemplateId": string,
25 | "showLittleTail": "",
26 | "title": "哔哩哔哩",
27 | "url": string
28 | }
29 | },
30 | "needShareCallBack": boolean,
31 | "prompt": "[QQ小程序]哔哩哔哩",
32 | "ver": string,
33 | "view": string
34 | }
35 |
36 | /*
37 |
38 | {
39 | "app": "com.tencent.miniapp_01",
40 | "config": {
41 | "autoSize": 0,
42 | "ctime": 1626859356,
43 | "forward": 1,
44 | "height": 0,
45 | "token": "6eab53592c1e6bc9bd54282c2f67f73e",
46 | "type": "normal",
47 | "width": 0
48 | },
49 | "desc": "哔哩哔哩",
50 | "extra": {
51 | "app_type": 1,
52 | "appid": 100951776,
53 | "uin": 839827911
54 | },
55 | "meta": {
56 | "detail_1": {
57 | "appid": "1109937557",
58 | "desc": "还在买爆款劣质U盘? 教你用绝版SLC颗粒做一个 寿命用到下辈子 有手就行",
59 | "gamePoints": "",
60 | "gamePointsUrl": "",
61 | "host": {
62 | "nick": "aaa",
63 | "uin": 0
64 | },
65 | "icon": "http://miniapp.gtimg.cn/public/appicon/432b76be3a548fc128acaa6c1ec90131_200.jpg",
66 | "preview": "pubminishare-30161.picsz.qpic.cn/53ff779f-d6a9-4eb1-890a-dfd875c7d185",
67 | "qqdocurl": "https://b23.tv/0GTNIr?share_medium=android&share_source=qq&bbid=XX23888200D3F348B37BDF8716B806C3414C8&ts=1626859350951",
68 | "scene": 1036,
69 | "shareTemplateData": {},
70 | "shareTemplateId": "8C8E89B49BE609866298ADDFF2DBABA4",
71 | "showLittleTail": "",
72 | "title": "哔哩哔哩",
73 | "url": "m.q.qq.com/a/s/277b3e40dde342399bd1675555726ea4"
74 | }
75 | },
76 | "needShareCallBack": false,
77 | "prompt": "[QQ小程序]哔哩哔哩",
78 | "ver": "1.0.0.19",
79 | "view": "view_8C8E89B49BE609866298ADDFF2DBABA4"
80 | }
81 |
82 | */
83 |
--------------------------------------------------------------------------------
/main/src/controllers/DeleteMessageController.ts:
--------------------------------------------------------------------------------
1 | import DeleteMessageService from '../services/DeleteMessageService';
2 | import Telegram from '../client/Telegram';
3 | import { Api } from 'telegram';
4 | import { DeletedMessageEvent } from 'telegram/events/DeletedMessage';
5 | import Instance from '../models/Instance';
6 | import { MessageRecallEvent, QQClient } from '../client/QQClient';
7 |
8 | export default class DeleteMessageController {
9 | private readonly deleteMessageService: DeleteMessageService;
10 |
11 | constructor(private readonly instance: Instance,
12 | private readonly tgBot: Telegram,
13 | private readonly tgUser: Telegram,
14 | private readonly oicq: QQClient) {
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.addMessageRecallEventHandler(this.onQqRecall);
20 | }
21 |
22 | private onTelegramMessage = async (message: Api.Message) => {
23 | const pair = this.instance.forwardPairs.find(message.chat);
24 | if (!pair) return false;
25 | if (message.message?.split('@')?.[0] === '/rm') {
26 | // 撤回消息
27 | await this.deleteMessageService.handleTelegramMessageRm(message, pair);
28 | return true;
29 | }
30 | };
31 |
32 | private onTelegramEditMessage = async (message: Api.Message) => {
33 | if (message.senderId?.eq(this.instance.botMe.id)) return true;
34 | const pair = this.instance.forwardPairs.find(message.chat);
35 | if (!pair) return;
36 | if (await this.deleteMessageService.isInvalidEdit(message, pair)) {
37 | return true;
38 | }
39 | await this.deleteMessageService.telegramDeleteMessage(message.id, pair);
40 | return await this.onTelegramMessage(message);
41 | };
42 |
43 | private onQqRecall = async (event: MessageRecallEvent) => {
44 | const pair = this.instance.forwardPairs.find(event.chat);
45 | if (!pair) return;
46 | await this.deleteMessageService.handleQqRecall(event, pair);
47 | };
48 |
49 | private onTgDeletedMessage = async (event: DeletedMessageEvent) => {
50 | if (!(event.peer instanceof Api.PeerChannel)) return;
51 | // group anonymous bot
52 | if (event._entities?.get('1087968824')) return;
53 | const pair = this.instance.forwardPairs.find(event.peer.channelId);
54 | if (!pair) return;
55 | for (const messageId of event.deletedIds) {
56 | await this.deleteMessageService.telegramDeleteMessage(messageId, pair);
57 | }
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/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 | CACHE_DIR: z.string().default(path.join(process.env.DATA_DIR || path.resolve('./data'), 'cache')),
7 |
8 | LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark', 'off']).default('info'),
9 | OICQ_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark', 'off']).default('warn'),
10 | TG_LOG_LEVEL: z.enum(['none', 'error', 'warn', 'info', 'debug']).default('warn'),
11 |
12 | FFMPEG_PATH: z.string().optional(),
13 | FFPROBE_PATH: z.string().optional(),
14 |
15 | // 只会在实例 0 自动使用
16 | NAPCAT_WS_URL: z.string().url().optional(),
17 |
18 | SIGN_API: z.string().url().optional(),
19 | SIGN_VER: z.string().optional(),
20 |
21 | TG_API_ID: z.string().regex(/^\d+$/).transform(Number),
22 | TG_API_HASH: z.string(),
23 | TG_BOT_TOKEN: z.string(),
24 | TG_CONNECTION: z.enum(['websocket', 'tcp']).default('tcp'),
25 | TG_INITIAL_DCID: z.string().regex(/^\d+$/).transform(Number).optional(),
26 | TG_INITIAL_SERVER: z.string().ip().optional(),
27 | TG_USE_TEST_DC: z.string().transform((v) => ['true', '1', 'yes'].includes(v.toLowerCase())).default('false'),
28 | IPV6: z.string().transform((v) => ['true', '1', 'yes'].includes(v.toLowerCase())).default('false'),
29 |
30 | PROXY_IP: z.string().ip().optional(),
31 | PROXY_PORT: z.string().regex(/^\d+$/).transform(Number).optional(),
32 | PROXY_USERNAME: z.string().optional(),
33 | PROXY_PASSWORD: z.string().optional(),
34 |
35 | TGS_TO_GIF: z.string().default('tgs_to_gif'),
36 |
37 | CRV_API: z.string().url().optional(),
38 | CRV_VIEWER_APP: z.string().url().startsWith('https://t.me/').optional(),
39 | CRV_KEY: z.string().optional(),
40 |
41 | DISABLE_FILE_UPLOAD_TIP: z.string().transform((v) => ['true', '1', 'yes'].includes(v.toLowerCase())).default('false'),
42 | IMAGE_SUMMARY: z.string().optional(),
43 |
44 | LISTEN_PORT: z.string().regex(/^\d+$/).transform(Number).default('8080'),
45 |
46 | UI_PATH: z.string().optional(),
47 | UI_PROXY: z.string().url().optional(),
48 | WEB_ENDPOINT: z.string().url().optional(),
49 |
50 | POSTHOG_OPTOUT: z.string().transform((v) => ['true', '1', 'yes'].includes(v.toLowerCase())).default('false'),
51 |
52 | REPO: z.string().default('Local Build'),
53 | REF: z.string().default('Local Build'),
54 | COMMIT: z.string().default('Local Build'),
55 | }).safeParse(process.env);
56 |
57 | if (!configParsed.success) {
58 | console.error('环境变量解析错误:', (configParsed as any).error);
59 | process.exit(1);
60 | }
61 |
62 | export default configParsed.data;
63 |
--------------------------------------------------------------------------------
/main/src/client/QQClient/entity.ts:
--------------------------------------------------------------------------------
1 | import type { MessageElem, MessageRet, MfaceElem, Quotable } from '@icqqjs/icqq';
2 | import { Gender, GroupRole } from '@icqqjs/icqq/lib/common';
3 | import { AtElem, FaceElem, ForwardNode, ImageElem, PttElem, TextElem, VideoElem } from '@icqqjs/icqq/lib/message/elements';
4 | import { FaceElemEx, ImageElemEx } from '../NapCatClient/convert';
5 |
6 | // 全平台支持的 Elem
7 | export type SendableElem = TextElem | FaceElem | ImageElem | AtElem | PttElem | VideoElem | MfaceElem | ForwardNode | FaceElemEx | ImageElemEx;
8 | export type Sendable = SendableElem | string | (SendableElem | string)[];
9 |
10 | export interface QQEntity {
11 | readonly client: { uin: number };
12 | readonly dm: boolean;
13 |
14 | getForwardMsg(resid: string, fileName?: string): Promise