├── .node-version ├── .npmrc ├── pnpm-workspace.yaml ├── ui ├── src │ ├── env.d.ts │ ├── views │ │ ├── ChatRecord │ │ │ ├── Viewer │ │ │ │ ├── components │ │ │ │ │ ├── MessageElement.module.sass │ │ │ │ │ ├── SenderContainer.module.sass │ │ │ │ │ ├── DateContainer.module.sass │ │ │ │ │ ├── DateContainer.tsx │ │ │ │ │ ├── MessageBubble.tsx │ │ │ │ │ ├── Bubble.module.sass │ │ │ │ │ ├── SenderNameBubble.tsx │ │ │ │ │ ├── SenderContainer.tsx │ │ │ │ │ ├── XmlElement.tsx │ │ │ │ │ ├── MessageElement.tsx │ │ │ │ │ └── JsonElement.tsx │ │ │ │ ├── assets │ │ │ │ │ └── no-avatar.webp │ │ │ │ ├── utils │ │ │ │ │ ├── getUserAvatarUrl.ts │ │ │ │ │ ├── getImageUrlByMd5.ts │ │ │ │ │ ├── cyrb53.ts │ │ │ │ │ └── processHistory.ts │ │ │ │ ├── types │ │ │ │ │ ├── DateGroup.d.ts │ │ │ │ │ ├── MessageElemExt.d.ts │ │ │ │ │ ├── SenderGroup.d.ts │ │ │ │ │ ├── StructMessageCard.d.ts │ │ │ │ │ └── BilibiliMiniApp.d.ts │ │ │ │ └── index.tsx │ │ │ ├── index.module.sass │ │ │ └── index.tsx │ │ └── Index.tsx │ ├── utils │ │ └── client.ts │ ├── main.ts │ ├── App.tsx │ └── router.ts ├── uno.config.ts ├── vite.config.ts ├── index.html ├── tsconfig.json └── package.json ├── .idea ├── .gitignore ├── misc.xml ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml ├── webResources.xml ├── discord.xml └── Q2TG.iml ├── main ├── src │ ├── constants │ │ ├── exts.ts │ │ ├── regExps.ts │ │ ├── nameColor.ts │ │ ├── lottie.ts │ │ ├── flags.ts │ │ ├── emoji.ts │ │ ├── commands.ts │ │ ├── qfaceChannelMap.ts │ │ └── qface.ts │ ├── client │ │ ├── NapCatClient │ │ │ ├── index.ts │ │ │ ├── event.ts │ │ │ └── convert.ts │ │ ├── TelegramImportSession.ts │ │ └── QQClient │ │ │ ├── entity.ts │ │ │ └── events.ts │ ├── models │ │ ├── db.ts │ │ ├── posthog.ts │ │ ├── env.ts │ │ ├── Pair.ts │ │ ├── TelegramSession.ts │ │ └── ForwardPairs.ts │ ├── types │ │ └── definitions.d.ts │ ├── helpers │ │ ├── dataPath.ts │ │ ├── CallbackQueryHelper.ts │ │ ├── setupHelper.ts │ │ ├── memberRoleCache.ts │ │ ├── WaitForMessageHelper.ts │ │ ├── makeHeaderImage.ts │ │ └── convert.ts │ ├── utils │ │ ├── arrays.ts │ │ ├── peerId.ts │ │ ├── pastebin.ts │ │ ├── hashing.ts │ │ ├── processNestedForward.ts │ │ ├── flagControl.ts │ │ ├── highLevelFunces.ts │ │ ├── urls.ts │ │ ├── getAboutText.ts │ │ ├── random.ts │ │ ├── paginatedInlineSelector.ts │ │ └── inlineDigitInput.ts │ ├── encoding │ │ ├── tgsToGif.ts │ │ ├── convertWithFfmpeg.ts │ │ └── silk.ts │ ├── api │ │ ├── index.ts │ │ ├── ui.ts │ │ ├── q2tgServlet │ │ │ └── index.ts │ │ ├── telegramAvatar.ts │ │ └── richHeader.tsx │ ├── controllers │ │ ├── OicqErrorNotifyController.ts │ │ ├── MiraiSkipFilterController.ts │ │ ├── TypingController.ts │ │ ├── LoadingController.ts │ │ ├── InstanceManageController.ts │ │ ├── GroupNameRefreshController.ts │ │ ├── AliveCheckController.ts │ │ ├── DeleteMessageController.ts │ │ ├── RequestController.ts │ │ ├── FileAndFlashPhotoController.ts │ │ └── InChatCommandsController.ts │ ├── index.ts │ └── services │ │ ├── SetupService.ts │ │ └── DeleteMessageService.ts ├── build.ts ├── tsconfig.json ├── tools │ └── mkQFaceChannel.ts ├── package.json └── prisma │ └── schema.prisma ├── .dockerignore ├── .vscode └── settings.json ├── .devcontainer ├── devcontainer.json └── Dockerfile ├── docker-entrypoint.sh ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── package.json ├── docker-compose-examples ├── icqq │ ├── with-nginx-certbot │ │ ├── nginx.conf │ │ └── docker-compose.yaml │ └── with-cloudflare-tunnel │ │ └── docker-compose.yaml └── NapCat │ ├── with-nginx-certbot │ ├── nginx.conf │ └── docker-compose.yaml │ └── with-cloudflare-tunnel │ └── docker-compose.yaml ├── README.md ├── Dockerfile └── patches ├── @icqqjs__icqq@1.4.0.patch └── telegram@2.26.8.patch /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/MessageElement.module.sass: -------------------------------------------------------------------------------- 1 | .messageContent 2 | white-space: pre-line 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist 6 | build 7 | data 8 | .env 9 | .github 10 | -------------------------------------------------------------------------------- /main/src/client/NapCatClient/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './entity'; 3 | export * from './event'; 4 | -------------------------------------------------------------------------------- /.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/views/ChatRecord/Viewer/assets/no-avatar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clansty/Q2TG/HEAD/ui/src/views/ChatRecord/Viewer/assets/no-avatar.webp -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dev Container", 3 | "dockerFile": "Dockerfile", 4 | 5 | "postCreateCommand": "pnpm install" 6 | } 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/utils/getUserAvatarUrl.ts: -------------------------------------------------------------------------------- 1 | export default function getUserAvatarUrl(uin: number) { 2 | return `https://q1.qlogo.cn/g?b=qq&nk=${uin}&s=140` 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/utils/client.ts: -------------------------------------------------------------------------------- 1 | import { treaty } from '@elysiajs/eden'; 2 | import type { App } from '../../../main/src/api'; 3 | 4 | export default treaty('', { keepDomain: true }); 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | chown -R 1000:1000 /app 4 | 5 | gosu node ./node_modules/.bin/prisma db push --accept-data-loss --skip-generate 6 | gosu node node --enable-source-maps build/index.js 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /main/src/constants/regExps.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | qq: /^[1-9]\d{4,10}$/, 3 | roomId: /^-?[1-9]\d{4,10}$/, 4 | url: /https?:\/\/[-a-zA-Z0-9@%._+~#=]{1,256}\.[a-zA-Z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&\/=]*/, 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 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/types/DateGroup.d.ts: -------------------------------------------------------------------------------- 1 | import SenderGroup from './SenderGroup' 2 | 3 | // 同一天的消息,用于合并日期 4 | type DateGroup = { 5 | date: string, 6 | messages: SenderGroup[] 7 | } 8 | 9 | export default DateGroup 10 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App'; 3 | import router from '@/router'; 4 | import '@unocss/reset/tailwind.css'; 5 | import 'virtual:uno.css'; 6 | 7 | createApp(App) 8 | .use(router) 9 | .mount('#app'); 10 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/types/MessageElemExt.d.ts: -------------------------------------------------------------------------------- 1 | import { MessageElem } from "@icqqjs/icqq" 2 | 3 | export type MessageElemExt = MessageElem | { 4 | type: 'video-loop', 5 | url: string 6 | } | { 7 | type: 'tgs', 8 | url: string 9 | } 10 | -------------------------------------------------------------------------------- /ui/uno.config.ts: -------------------------------------------------------------------------------- 1 | // uno.config.ts 2 | import { defineConfig, presetIcons, presetTypography, presetUno } from 'unocss'; 3 | 4 | export default defineConfig({ 5 | presets: [ 6 | presetUno(), 7 | presetTypography(), 8 | presetIcons(), 9 | ], 10 | }); 11 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/utils/getImageUrlByMd5.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://gchat.qpic.cn/gchatpic_new/0/0-0-大写的Md5/0 3 | * @param md5 4 | */ 5 | export default function getImageUrlByMd5(md5: string) { 6 | return 'https://gchat.qpic.cn/gchatpic_new/0/0-0-' + md5.toUpperCase() + '/0' 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/views/Index.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue'; 2 | 3 | export default defineComponent({ 4 | render() { 5 | return
6 | Q2TG WebUI 7 |
; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/index.module.sass: -------------------------------------------------------------------------------- 1 | .tip 2 | color: var(--tg-theme-hint-color) 3 | position: absolute 4 | top: 0 5 | bottom: 0 6 | left: 0 7 | right: 0 8 | display: flex 9 | align-items: center 10 | justify-content: center 11 | 12 | .container 13 | width: 100% 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /main/build.ts: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import packageJson from './package.json'; 3 | 4 | esbuild.buildSync({ 5 | bundle: true, 6 | entryPoints: ['src/index.ts'], 7 | outdir: 'build', 8 | sourcemap: true, 9 | platform: 'node', 10 | external: Object.keys(packageJson.dependencies), 11 | }); 12 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/types/SenderGroup.d.ts: -------------------------------------------------------------------------------- 1 | // 同一个人连续的一组消息,用于合并头像 2 | import { ForwardMessage } from '@icqqjs/icqq'; 3 | 4 | type SenderGroup = { 5 | id: number 6 | username: string 7 | senderId: number | string 8 | messages: ForwardMessage[] 9 | avatar: string 10 | } 11 | 12 | export default SenderGroup 13 | -------------------------------------------------------------------------------- /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/utils/peerId.ts: -------------------------------------------------------------------------------- 1 | import { Api } from 'telegram'; 2 | 3 | export const peerToId = (peer: Api.TypePeer) => { 4 | switch (peer.className) { 5 | case 'PeerUser': 6 | return peer.userId; 7 | case 'PeerChat': 8 | return peer.chatId; 9 | case 'PeerChannel': 10 | return peer.channelId; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /main/src/constants/nameColor.ts: -------------------------------------------------------------------------------- 1 | const colors = [ 2 | '#FC5C51', // red 3 | '#FA790F', // orange 4 | '#895DD5', // purple 5 | '#0FB297', // green 6 | '#0FC9D6', // sea 7 | '#3CA5EC', // blue 8 | '#D54FAF', // pink 9 | ]; 10 | 11 | export default (id: number) => { 12 | const nameIndex = Math.abs(id) % 7; 13 | return colors[nameIndex]; 14 | }; 15 | -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue'; 2 | import { dateZhCN, NConfigProvider, zhCN } from 'naive-ui'; 3 | import { RouterView } from 'vue-router'; 4 | 5 | export default defineComponent({ 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vueJsx from '@vitejs/plugin-vue-jsx'; 3 | import UnoCSS from 'unocss/vite'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: '/ui/', 8 | plugins: [ 9 | vueJsx(), 10 | UnoCSS(), 11 | ], 12 | resolve: { 13 | alias: { 14 | '@': '/src', 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /main/src/utils/pastebin.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | async upload(data: string) { 3 | const req = await fetch('https://fars.ee', { 4 | method: 'POST', 5 | headers: { 6 | 'Content-Type': 'application/x-www-form-urlencoded', 7 | }, 8 | body: new URLSearchParams({ 9 | c: data, 10 | p: '1', 11 | }), 12 | }); 13 | return req.headers.get('Location'); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Q2TG Web UI 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/webResources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | import Index from '@/views/Index'; 3 | import ChatRecord from '@/views/ChatRecord'; 4 | 5 | export default createRouter({ 6 | history: createWebHistory(), 7 | routes: [ 8 | { 9 | path: '/ui', children: [ 10 | { path: '', component: Index }, 11 | { path: 'chatRecord', component: ChatRecord }, 12 | ], 13 | }, 14 | ], 15 | }); 16 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/SenderContainer.module.sass: -------------------------------------------------------------------------------- 1 | .container 2 | display: flex 3 | margin: 10px 0 4 | 5 | .avatarContainer 6 | display: flex 7 | width: 50px 8 | max-width: 50px 9 | min-width: 50px 10 | justify-content: center 11 | align-items: flex-end 12 | 13 | .avatar 14 | position: sticky 15 | z-index: 2 16 | bottom: 10px 17 | 18 | 19 | .mainContainer 20 | flex-grow: 1 21 | 22 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/DateContainer.module.sass: -------------------------------------------------------------------------------- 1 | .date 2 | position: sticky 3 | z-index: 2 4 | top: 0 5 | left: 0 6 | right: 0 7 | margin: auto auto 8 | padding-top: 10px 9 | width: max-content 10 | 11 | span 12 | display: block 13 | padding: 2px 8px 14 | //margin-top: 10px 15 | background-color: color-mix(in srgb, var(--tg-theme-text-color) 60%, transparent) 16 | backdrop-filter: blur(10px) 17 | border-radius: 12px 18 | color: var(--tg-theme-section-bg-color) 19 | font-size: smaller 20 | 21 | -------------------------------------------------------------------------------- /.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/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 | "plugins": [{ "name": "@kitajs/ts-html-plugin" }], 11 | "jsx": "react", 12 | "jsxFactory": "Html.createElement", 13 | "jsxFragmentFactory": "Html.Fragment", 14 | "noEmit": true, 15 | "resolveJsonModule": true 16 | }, 17 | "exclude": [ 18 | "node_modules" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/utils/cyrb53.ts: -------------------------------------------------------------------------------- 1 | export default (str: string, seed = 0) => { 2 | let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; 3 | for (let i = 0, ch; i < str.length; i++) { 4 | ch = str.charCodeAt(i); 5 | h1 = Math.imul(h1 ^ ch, 2654435761); 6 | h2 = Math.imul(h2 ^ ch, 1597334677); 7 | } 8 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 9 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 10 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 11 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 12 | 13 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 14 | }; 15 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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.6.3" 9 | }, 10 | "pnpm": { 11 | "patchedDependencies": { 12 | "telegram@2.26.8": "patches/telegram@2.26.8.patch", 13 | "@icqqjs/icqq@1.4.0": "patches/@icqqjs__icqq@1.4.0.patch" 14 | } 15 | }, 16 | "packageManager": "pnpm@9.13.2+sha512.88c9c3864450350e65a33587ab801acf946d7c814ed1134da4a924f6df5a2120fd36b46aab68f7cd1d413149112d53c7db3a4136624cfd00ff1846a0c6cef48a" 17 | } 18 | -------------------------------------------------------------------------------- /.idea/Q2TG.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/DateContainer.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType } from 'vue'; 2 | import styles from './DateContainer.module.sass'; 3 | import DateGroup from '../types/DateGroup'; 4 | import SenderContainer from './SenderContainer'; 5 | 6 | export default defineComponent({ 7 | props: { 8 | group: { required: true, type: Object as PropType }, 9 | }, 10 | setup(props) { 11 | return () =>
12 |
13 | 14 | {props.group.date} 15 | 16 |
17 | {props.group.messages.map(e => )} 18 |
; 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /main/src/constants/lottie.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | packInfo: { 3 | qlottie_2: ["364", "362", "397", "396", "360", "361", "363", "365", "367"], 4 | qlottie_3: ["413", "405", "404", "406", "411", "407", "408", "412", "409"], 5 | qlottie_4: ["403", "402", "390", "391", "388", "389", "386", "385", "384", "387"], 6 | qlottie_5: ["382", "383", "401", "400", "380", "381", "379", "376", "378", "377"], 7 | qlottie_6: ["399", "398", "373", "370", "375", "368", "369", "371", "372", "374"], 8 | QQAniSticker: ["5", "311", "312", "319", "320", "339", "137", "346", "344", "345", "181", "74", "75", "349", "350"], 9 | qq_snake: ["429", "430", "431_1", "431_2", "431_3", "431_4", "431_5", "431_6", "432"], 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /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 | "jsxImportSource": "vue", 12 | "sourceMap": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "lib": [ 16 | "esnext", 17 | "dom" 18 | ], 19 | "paths": { 20 | "@": [ 21 | "./src" 22 | ], 23 | "@/*": [ 24 | "./src/*" 25 | ] 26 | } 27 | }, 28 | "include": [ 29 | "src/**/*.ts", 30 | "src/**/*.d.ts", 31 | "src/**/*.tsx" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "@elysiajs/eden": "^1.1.2", 13 | "@icqqjs/icqq": "1.4.0", 14 | "@unocss/reset": "^0.62.0", 15 | "@vitejs/plugin-vue-jsx": "^4.0.0", 16 | "@vueuse/core": "^10.11.1", 17 | "date-fns": "^3.6.0", 18 | "elysia": "^1.1.5", 19 | "linkify-string": "^4.1.3", 20 | "naive-ui": "^2.39.0", 21 | "sass": "^1.77.8", 22 | "unocss": "^0.62.0", 23 | "vite": "^5.4.0", 24 | "vue": "^3.4.37", 25 | "vue-router": "^4.4.3", 26 | "vue-tg": "^0.8.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/MessageBubble.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType } from 'vue'; 2 | import MessageElement from './MessageElement'; 3 | import styles from './Bubble.module.sass'; 4 | import { NTime } from 'naive-ui'; 5 | import { ForwardMessage } from '@icqqjs/icqq'; 6 | 7 | export default defineComponent({ 8 | props: { 9 | message: { required: true, type: Object as PropType }, 10 | }, 11 | setup(props) { 12 | return () =>
13 | {props.message.message.map((i, k) => )} 14 |
15 | 19 |
20 |
; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, PropType } from 'vue'; 2 | import { dateZhCN, NConfigProvider, zhCN } from 'naive-ui'; 3 | import { ForwardMessage } from '@icqqjs/icqq'; 4 | import processHistory from './utils/processHistory'; 5 | import DateContainer from './components/DateContainer'; 6 | 7 | export default defineComponent({ 8 | props: { 9 | messages: { required: true, type: Object as PropType }, 10 | }, 11 | setup(props) { 12 | const groupedHistory = computed(() => processHistory(props.messages)); 13 | 14 | return () => ( 15 | 16 | {groupedHistory.value.map(e => )} 17 | 18 | ); 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /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 | DISABLE_DELETE_MESSAGE = 1 << 4, 7 | DISABLE_AUTO_CREATE_PM = 1 << 5, 8 | COLOR_EMOJI_PREFIX = 1 << 6, 9 | // RICH_HEADER = 1 << 7, 10 | DISABLE_QUOTE_PIN = 1 << 8, 11 | DISABLE_FORWARD_OTHER_BOT = 1 << 9, 12 | // USE_MARKDOWN = 1 << 10, 13 | DISABLE_SEAMLESS = 1 << 11, 14 | DISABLE_FLASH_PIC = 1 << 12, 15 | DISABLE_SLASH_COMMAND = 1 << 13, 16 | DISABLE_RICH_HEADER = 1 << 14, 17 | DISABLE_OFFLINE_NOTICE = 1 << 15, 18 | HIDE_ALL_QQ_NUMBER = 1 << 16, 19 | NAME_LOCKED = 1 << 17, 20 | ALWAYS_FORWARD_TG_FILE = 1 << 18, 21 | QQ_HEADER_IMAGE = 1 << 19, 22 | DISABLE_ERROR_NOTIFY = 1 << 20, 23 | } 24 | 25 | export default flags; 26 | -------------------------------------------------------------------------------- /docker-compose-examples/icqq/with-nginx-certbot/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 80; 10 | listen [::]:80; 11 | 12 | location /.well-known/acme-challenge/ { 13 | root /var/www/certbot; 14 | } 15 | 16 | location / { 17 | return 301 https://$host$request_uri; 18 | } 19 | } 20 | 21 | # server { 22 | # listen 443 ssl; 23 | # listen [::]:443 ssl; 24 | # server_name 你的域名; 25 | # ssl_certificate /etc/letsencrypt/live/你的域名/fullchain.pem; 26 | # ssl_certificate_key /etc/letsencrypt/live/你的域名/privkey.pem; 27 | 28 | # location / { 29 | # proxy_pass http://q2tg:8080; 30 | # } 31 | # } 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose-examples/NapCat/with-nginx-certbot/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 80; 10 | listen [::]:80; 11 | 12 | location /.well-known/acme-challenge/ { 13 | root /var/www/certbot; 14 | } 15 | 16 | location / { 17 | return 301 https://$host$request_uri; 18 | } 19 | } 20 | 21 | # server { 22 | # listen 443 ssl; 23 | # listen [::]:443 ssl; 24 | # server_name 你的域名; 25 | # ssl_certificate /etc/letsencrypt/live/你的域名/fullchain.pem; 26 | # ssl_certificate_key /etc/letsencrypt/live/你的域名/privkey.pem; 27 | 28 | # location / { 29 | # proxy_pass http://q2tg:8080; 30 | # } 31 | # } 32 | } 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/Bubble.module.sass: -------------------------------------------------------------------------------- 1 | .container 2 | border-radius: 0 10px 10px 0 3 | width: max-content 4 | min-width: 100px 5 | max-width: calc(100vw - 100px) 6 | margin: 4px 7 | padding: 4px 10px 8 | font-size: medium 9 | background-color: #88888818 10 | color: var(--tg-theme-text-color) 11 | 12 | &.senderName 13 | position: sticky 14 | z-index: 1 15 | top: 0 16 | border-radius: 10px 17 | min-width: unset 18 | background-color: color-mix(in srgb, var(--tg-theme-bg-color) 80%, transparent) 19 | backdrop-filter: blur(10px) 20 | font-size: small 21 | font-weight: bold 22 | 23 | 24 | &:nth-child(2) 25 | border-radius: 10px 10px 10px 0 26 | 27 | 28 | .time 29 | margin-top: 2px 30 | text-align: right 31 | color: var(--tg-theme-subtitle-text-color) 32 | font-size: smaller 33 | 34 | 35 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/SenderNameBubble.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent } from 'vue'; 2 | import styles from './Bubble.module.sass'; 3 | import cyrb53 from '../utils/cyrb53'; 4 | 5 | export default defineComponent({ 6 | props: { 7 | name: { required: true, type: String }, 8 | id: { required: true, type: [String, Number] }, 9 | }, 10 | setup(props) { 11 | const color = computed(() => { 12 | const id = typeof props.id === 'string' ? cyrb53(props.id) : props.id; 13 | return [ 14 | '#FF516A', 15 | '#FFA85C', 16 | '#D669ED', 17 | '#54CB68', 18 | '#28C9B7', 19 | '#2A9EF1', 20 | '#FF719A'][id % 7]; 21 | }); 22 | 23 | return () =>
24 | {props.name} 25 |
; 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /main/src/models/posthog.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from 'posthog-node'; 2 | import os from 'os'; 3 | import env from './env'; 4 | 5 | const client = new PostHog( 6 | 'phc_LmyAmIzRPk8Eoy5kMCFhwKVckY11vQS3KbGba2q4Hhm', 7 | { host: 'https://eu.i.posthog.com' }, 8 | ); 9 | 10 | const hostname = os.hostname(); 11 | 12 | if (env.POSTHOG_OPTOUT) { 13 | client.optOut(); 14 | } 15 | else { 16 | client.optIn(); 17 | } 18 | 19 | export default { 20 | capture(event: string, properties: Record) { 21 | if (typeof properties?.error === 'object' && properties.error.stack && JSON.stringify(properties.error) === '{}') { 22 | properties.error = properties.error.stack; 23 | } 24 | properties.repo = env.REPO; 25 | properties.ref = env.REF; 26 | properties.commit = env.COMMIT; 27 | 28 | client.capture({ 29 | event, properties, 30 | distinctId: hostname, 31 | }); 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /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/api/index.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from 'log4js'; 2 | import env from '../models/env'; 3 | import richHeader from './richHeader'; 4 | import telegramAvatar from './telegramAvatar'; 5 | import '@bogeychan/elysia-polyfills/node/index.js'; 6 | import { Elysia } from 'elysia'; 7 | import ui from './ui'; 8 | import q2tgServlet from './q2tgServlet'; 9 | 10 | const log = getLogger('Web Api'); 11 | 12 | let app = new Elysia() 13 | .onError(error => { 14 | log.error(error.request.method, error.request.url, error.error.message); 15 | log.debug(error.error); 16 | }) 17 | .get('/', () => { 18 | return { hello: 'Q2TG' }; 19 | }) 20 | .use(telegramAvatar) 21 | .use(richHeader) 22 | .use(ui) 23 | .use(q2tgServlet); 24 | 25 | export default { 26 | startListening() { 27 | app.listen(env.LISTEN_PORT); 28 | log.info('Listening on', env.LISTEN_PORT); 29 | }, 30 | }; 31 | 32 | export type App = typeof app; 33 | -------------------------------------------------------------------------------- /main/src/controllers/OicqErrorNotifyController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import OicqClient from '../client/OicqClient'; 3 | import { QQClient } from '../client/QQClient'; 4 | import flags from '../constants/flags'; 5 | 6 | export default class OicqErrorNotifyController { 7 | private locked = false; 8 | 9 | public constructor(private readonly instance: Instance, 10 | private readonly oicq: QQClient) { 11 | if (oicq instanceof OicqClient) { 12 | oicq.oicq.on('system.offline', async ({ message }) => { 13 | if (this.locked) return; 14 | this.locked = true; 15 | if (!(instance.flags & flags.DISABLE_OFFLINE_NOTICE)) 16 | await this.instance.ownerChat.sendMessage(`QQ 机器人掉线\n${message}`); 17 | }); 18 | oicq.oicq.on('system.online', async () => { 19 | this.locked = false; 20 | }); 21 | } 22 | // TODO: NapCat 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /main/src/utils/processNestedForward.ts: -------------------------------------------------------------------------------- 1 | import { ForwardMessage } from '../client/QQClient'; 2 | import forwardHelper from '../helpers/forwardHelper'; 3 | import db from '../models/db'; 4 | 5 | export default async (messages: ForwardMessage[], fromPairId: number) => { 6 | for (const message of messages) { 7 | for (const elem of message.message) { 8 | if (elem.type !== 'json') continue; 9 | const parsed = forwardHelper.processJson(elem.data); 10 | if (parsed.type !== 'forward') continue; 11 | let entity = await db.forwardMultiple.findFirst({ where: { resId: parsed.resId } }); 12 | if (!entity) { 13 | entity = await db.forwardMultiple.create({ 14 | data: { 15 | resId: parsed.resId, 16 | fileName: parsed.fileName, 17 | fromPairId, 18 | }, 19 | }); 20 | } 21 | elem.data = JSON.stringify({ type: 'forward', uuid: entity.id }); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /main/src/helpers/memberRoleCache.ts: -------------------------------------------------------------------------------- 1 | import { Pair } from '../models/Pair'; 2 | import { Api } from 'telegram'; 3 | 4 | const map = new Map(); 5 | 6 | export default { 7 | get(pair: Pair, member: number) { 8 | return map.get(`${pair.dbId}_${member}`); 9 | }, 10 | async getEx(pair: Pair, member: number, getter: () => Promise) { 11 | const cached = map.get(`${pair.dbId}_${member}`); 12 | if (cached) return cached; 13 | const role = await getter(); 14 | map.set(`${pair.dbId}_${member}`, role); 15 | setTimeout(() => map.delete(`${pair.dbId}_${member}`), 1000 * 60 * 60); 16 | return role; 17 | }, 18 | set(pair: Pair, member: number, role: Api.channels.ChannelParticipant) { 19 | map.set(`${pair.dbId}_${member}`, role); 20 | setTimeout(() => map.delete(`${pair.dbId}_${member}`), 1000 * 60 * 60); 21 | }, 22 | delete(pair: Pair, member: number) { 23 | map.delete(`${pair.dbId}_${member}`); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /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/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 | if (index < 0) { 12 | const str = index.toString(); 13 | if (str.startsWith('-100')) { 14 | index = Number(str.slice(4)); 15 | } 16 | else { 17 | index = -index; 18 | } 19 | } 20 | // https://github.com/telegramdesktop/tdesktop/blob/7049929a59176a996c4257d5a09df08b04ac3b22/Telegram/SourceFiles/ui/chat/chat_style.cpp#L1043 21 | // https://github.com/LyoSU/quote-api/blob/master/utils/quote-generate.js#L163 22 | const arr = [...new Intl.Segmenter().segment('❤️🧡💜💚🩵💙🩷')].map(x => x.segment); 23 | index = index % arr.length; 24 | return arr[index]; 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /main/src/controllers/MiraiSkipFilterController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import { MiraiElem } from '@icqqjs/icqq'; 4 | import { MessageEvent, QQClient } from '../client/QQClient'; 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: QQClient) { 11 | qqBot.addNewMessageEventHandler(this.onQqMessage); 12 | } 13 | 14 | // 当 mapInstance 用同服务器其他个人模式账号发送消息后,message mirai 会带 q2tgSkip=true 15 | // 防止 bot 重新收到消息再转一圈回来重新转发或者重新响应命令 16 | private onQqMessage = async (event: MessageEvent) => { 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/api/ui.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from 'elysia'; 2 | import env from '../models/env'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import mime from 'mime-types'; 6 | 7 | let app = new Elysia(); 8 | 9 | if (env.UI_PROXY) { 10 | app = app.mount('/ui/', (req) => { 11 | const url = new URL(req.url); 12 | const baseUrl = new URL(env.UI_PROXY); 13 | url.hostname = baseUrl.hostname; 14 | url.port = baseUrl.port; 15 | url.protocol = baseUrl.protocol; 16 | url.pathname = '/ui' + url.pathname; 17 | return fetch(url.toString(), req); 18 | }); 19 | } 20 | else if (env.UI_PATH) { 21 | for (const asset of fs.readdirSync(path.join(env.UI_PATH, 'assets'))) { 22 | app = app.get('/ui/assets/' + asset, ({ set }) => { 23 | set.headers['content-type'] = mime.lookup(asset) || undefined; 24 | return fs.createReadStream(path.join(env.UI_PATH, 'assets', asset)); 25 | }); 26 | } 27 | app = app.get('/ui/*', ({ set }) => { 28 | set.headers['content-type'] = 'text/html'; 29 | return fs.createReadStream(path.join(env.UI_PATH, 'index.html')); 30 | }); 31 | } 32 | 33 | export default app; 34 | -------------------------------------------------------------------------------- /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/controllers/TypingController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import { InputStatusChangeEvent, QQClient } from '../client/QQClient'; 4 | import flags from '../constants/flags'; 5 | import { Api } from 'telegram'; 6 | 7 | export default class TypingController { 8 | constructor(private readonly instance: Instance, 9 | private readonly tgBot: Telegram, 10 | private readonly tgUser: Telegram, 11 | private readonly oicq: QQClient) { 12 | oicq.addInputStatusChangeHandler(this.handleInputStatusChange); 13 | // bot 无法获取输入状态,个人账号无法获取自己在其他设备的输入状态,做不了 14 | // tgUser.addChannelUserTypingHandler(this.handleChannelUserTyping); 15 | } 16 | 17 | private handleInputStatusChange = async (event: InputStatusChangeEvent) => { 18 | const pair = this.instance.forwardPairs.find(event.chat); 19 | if (!pair) return; 20 | if ((pair.flags | this.instance.flags) & flags.DISABLE_Q2TG) return; 21 | 22 | if (event.typing) { 23 | await pair.tg.setTyping() 24 | } 25 | else { 26 | await pair.tg.setTyping(new Api.SendMessageCancelAction()); 27 | } 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/SenderContainer.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType } from 'vue'; 2 | import SenderGroup from '../types/SenderGroup'; 3 | import { NAvatar } from 'naive-ui'; 4 | import styles from './SenderContainer.module.sass'; 5 | import SenderNameBubble from './SenderNameBubble'; 6 | import MessageBubble from './MessageBubble'; 7 | import noAvatar from '../assets/no-avatar.webp'; 8 | 9 | export default defineComponent({ 10 | props: { 11 | group: { required: true, type: Object as PropType }, 12 | }, 13 | setup(props) { 14 | return () =>
15 |
16 | 24 |
25 |
26 | 27 | {props.group.messages.map((e, index) => 28 | )} 29 |
30 |
; 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/XmlElement.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue'; 2 | import getImageUrlByMd5 from '../utils/getImageUrlByMd5'; 3 | import { NImage } from 'naive-ui'; 4 | 5 | export default defineComponent({ 6 | props: { 7 | xml: { required: true, type: String }, 8 | }, 9 | setup(props) { 10 | return () => { 11 | const urlRegex = /url="([^"]+)"/; 12 | const md5ImageRegex = /image md5="([A-F\d]{32})"/; 13 | let appurl = ''; 14 | if (urlRegex.test(props.xml)) 15 | appurl = props.xml.match(urlRegex)![1].replace(/\\\//g, '/'); 16 | if (props.xml.includes('action="viewMultiMsg"')) { 17 | return
[Forward multiple messages]
; 18 | } 19 | else if (appurl) { 20 | appurl = appurl.replace(/&/g, '&'); 21 | return ; 22 | } 23 | else if (md5ImageRegex.test(props.xml)) { 24 | const imgMd5 = props.xml.match(md5ImageRegex)![1]; 25 | const url = getImageUrlByMd5(imgMd5); 26 | return ; 31 | } 32 | return
[XML 卡片]
; 33 | }; 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /main/src/api/q2tgServlet/index.ts: -------------------------------------------------------------------------------- 1 | import { Elysia, t } from 'elysia'; 2 | import db from '../../models/db'; 3 | import { Pair } from '../../models/Pair'; 4 | import OicqClient from '../../client/OicqClient'; 5 | import processNestedForward from '../../utils/processNestedForward'; 6 | 7 | const forwardCache = new Map(); 8 | 9 | let app = new Elysia() 10 | .post('/Q2tgServlet/GetForwardMultipleMessageApi', async ({ body }) => { 11 | // @ts-ignore 12 | const uuid = body.uuid; 13 | if (!forwardCache.has(uuid)) { 14 | const data = await db.forwardMultiple.findFirst({ 15 | where: { id: uuid }, 16 | }); 17 | const pair = Pair.getByDbId(data.fromPairId); 18 | const messages = await pair.qq.getForwardMsg(data.resId, data.fileName); 19 | if (pair.qqClient instanceof OicqClient) { 20 | await pair.qqClient.refreshImageRKey(messages); 21 | } 22 | await processNestedForward(messages, data.fromPairId); 23 | forwardCache.set(uuid, messages); 24 | 25 | setTimeout(() => { 26 | forwardCache.delete(uuid); 27 | }, 1000 * 60); 28 | } 29 | return forwardCache.get(uuid); 30 | }, { 31 | body: t.Object({ 32 | // 不许注入 33 | uuid: t.String({ format: 'uuid' }), 34 | }), 35 | }); 36 | 37 | export default app; 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /main/src/controllers/LoadingController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import { MiraiElem } from '@icqqjs/icqq'; 4 | import { MessageEvent, QQClient } from '../client/QQClient'; 5 | import { Api } from 'telegram'; 6 | import lottie from '../constants/lottie'; 7 | 8 | export default class { 9 | constructor(private readonly instance: Instance, 10 | private readonly tgBot: Telegram, 11 | private readonly tgUser: Telegram, 12 | private readonly qqBot: QQClient) { 13 | this.initStickerPack(); 14 | tgBot.addNewMessageEventHandler(this.onTelegramMessage); 15 | } 16 | 17 | private stickerPackHandles: Api.TypeDocument[] = null; 18 | 19 | private async initStickerPack() { 20 | const pack = await this.tgBot.getStickerSet('Clansty_WEBM'); 21 | this.stickerPackHandles = pack.documents; 22 | } 23 | 24 | private onTelegramMessage = async (message: Api.Message) => { 25 | if ((message.isGroup || message.isChannel) && this.instance.workMode === 'group') return; 26 | if (this.stickerPackHandles) 27 | await message.reply({ 28 | file: this.stickerPackHandles[0], 29 | }); 30 | await message.reply({ 31 | message: 'Q2TG 还在初始化中,所以暂时无法处理你的消息。请稍后再试', 32 | }); 33 | return true; 34 | }; 35 | 36 | public off() { 37 | this.tgBot.removeNewMessageEventHandler(this.onTelegramMessage); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /main/src/utils/urls.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Friend, Group } from '../client/QQClient'; 3 | import * as https from 'node:https'; 4 | 5 | export function getAvatarUrl(room: number | bigint | Friend | Group): string { 6 | if (!room) return ''; 7 | if (typeof room === 'object' && 'uin' in room) { 8 | room = room.uin; 9 | } 10 | if (typeof room === 'object' && 'gid' in room) { 11 | room = -room.gid; 12 | } 13 | return room < 0 ? 14 | `https://p.qlogo.cn/gh/${-room}/${-room}/0` : 15 | `https://q1.qlogo.cn/g?b=qq&nk=${room}&s=0`; 16 | } 17 | 18 | export function getImageUrlByMd5(md5: string) { 19 | return 'https://gchat.qpic.cn/gchatpic_new/0/0-0-' + md5.toUpperCase() + '/0'; 20 | } 21 | 22 | export function getBigFaceUrl(file: string) { 23 | return `https://gxh.vip.qq.com/club/item/parcel/item/${file.substring(0, 2)}/${file.substring(0, 32)}/300x300.png`; 24 | } 25 | 26 | const httpsAgent = new https.Agent({ 27 | rejectUnauthorized: false, 28 | }); 29 | 30 | export async function fetchFile(url: string): Promise { 31 | const res = await axios.get(url, { 32 | responseType: 'arraybuffer', 33 | httpsAgent, 34 | }); 35 | return res.data; 36 | } 37 | 38 | export function getAvatar(room: number | Friend | Group) { 39 | return fetchFile(getAvatarUrl(room)); 40 | } 41 | 42 | export function isContainsUrl(msg: string): boolean { 43 | return msg.includes('https://') || msg.includes('http://'); 44 | } 45 | -------------------------------------------------------------------------------- /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/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(''); 17 | effect(async () => { 18 | if (!uuid.value) { 19 | error.value = '未指定消息记录 ID'; 20 | loading.value = false; 21 | return; 22 | } 23 | try { 24 | const result = await client.Q2tgServlet.GetForwardMultipleMessageApi.post({ uuid: uuid.value! }); 25 | console.log(result); 26 | data.value = result.data; 27 | error.value = result.error?.value?.message || result.error?.message; 28 | } 29 | catch (e: any) { 30 | error.value = e.message; 31 | } 32 | loading.value = false; 33 | }); 34 | 35 | return () => { 36 | if (loading.value) 37 | return
38 | 加载中... 39 |
; 40 | if (error.value || !data.value) 41 | return
42 | {error.value || '出错了'} 43 |
; 44 | return
45 | 46 |
; 47 | }; 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /main/src/encoding/convertWithFfmpeg.ts: -------------------------------------------------------------------------------- 1 | import ffmpeg from 'fluent-ffmpeg'; 2 | import { getLogger } from 'log4js'; 3 | import fsP from 'fs/promises'; 4 | 5 | const logger = getLogger('convertWithFfmpeg'); 6 | 7 | export default function (sourcePath: string, targetPath: string, format: string, srcFormat?: string) { 8 | return new Promise(async (resolve, reject) => { 9 | try { 10 | const ff = ffmpeg(sourcePath); 11 | if (srcFormat) { 12 | ff.addInputOption('-c:v', srcFormat); 13 | } 14 | if (format === 'gif') { 15 | ff.complexFilter('[0:v] palettegen=reserve_transparent=on [p]; [0:v] [p] paletteuse=dither=floyd_steinberg'); 16 | } 17 | if (format === 'webm') { 18 | ff.videoCodec('libvpx-vp9'); 19 | } 20 | ff.toFormat(format).save(targetPath); 21 | logger.debug('正在启动 ffmpeg: ' + ff._getArguments().join(' ')); 22 | ff.on('error', async err => { 23 | logger.error('ffmpeg 转换失败', err); 24 | reject(err); 25 | const stats = await fsP.stat(targetPath); 26 | logger.debug('转换结果文件大小: ' + stats.size); 27 | if (!stats.size) { 28 | logger.error('转换结果文件为空: ' + targetPath); 29 | await fsP.rm(targetPath); 30 | } 31 | }); 32 | return ff.on('end', async () => { 33 | resolve(); 34 | }); 35 | } 36 | catch (e) { 37 | logger.error('ffmpeg 转换失败', e); 38 | reject(e); 39 | const stats = await fsP.stat(targetPath); 40 | logger.debug('转换结果文件大小: ' + stats.size); 41 | if (!stats.size) { 42 | logger.error('转换结果文件为空: ' + targetPath); 43 | await fsP.rm(targetPath); 44 | } 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim AS tgs-to-gif-build 2 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 3 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 4 | apt update && apt-get --no-install-recommends install -y \ 5 | python3 build-essential pkg-config cmake librlottie-dev zlib1g-dev 6 | 7 | ADD https://github.com/p-ranav/argparse.git#v3.0 /argparse 8 | WORKDIR /argparse/build 9 | RUN cmake -DARGPARSE_BUILD_SAMPLES=on -DARGPARSE_BUILD_TESTS=on .. && make && make install 10 | 11 | ADD https://github.com/ed-asriyan/lottie-converter.git#f626548ced4492235b535552e2449be004a3a435 /app 12 | WORKDIR /app 13 | RUN sed -i 's/\${CONAN_LIBS}/z/g' CMakeLists.txt && sed -i 's/include(conanbuildinfo.cmake)//g' CMakeLists.txt && sed -i 's/conan_basic_setup()//g' CMakeLists.txt 14 | 15 | RUN cmake CMakeLists.txt && make 16 | 17 | 18 | FROM mcr.microsoft.com/devcontainers/typescript-node:22-bookworm 19 | 20 | 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 21 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 22 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 23 | apt update && apt-get --no-install-recommends install -y \ 24 | fonts-wqy-microhei \ 25 | libpixman-1-0 libcairo2 libpango1.0-0 libgif7 libjpeg62-turbo libpng16-16 librsvg2-2 libvips42 ffmpeg librlottie0-1 \ 26 | python3 build-essential pkg-config \ 27 | libpixman-1-dev libcairo2-dev libpango1.0-dev libgif-dev libjpeg62-turbo-dev libpng-dev librsvg2-dev libvips-dev 28 | 29 | COPY --from=tgs-to-gif-build /app/tgs_to_gif /usr/local/bin/tgs_to_gif 30 | ENV TGS_TO_GIF=/usr/local/bin/tgs_to_gif 31 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish docker container 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | publish: 9 | name: Publish container image 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: OCI meta 17 | id: meta 18 | uses: docker/metadata-action@v5 19 | with: 20 | images: ghcr.io/${{ github.repository }} 21 | tags: | 22 | type=ref,event=branch 23 | type=ref,event=pr 24 | type=semver,pattern={{version}} 25 | type=sha 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: Login to GHCR 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GPR_TOKEN }} 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v5 42 | with: 43 | context: . 44 | push: ${{ github.actor != 'dependabot[bot]' }} 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | platforms: linux/amd64 48 | cache-from: type=gha 49 | cache-to: type=gha,mode=max 50 | build-args: | 51 | "REPO=${{ github.repository }}" 52 | "REF=${{ github.ref }}" 53 | "COMMIT=${{ github.sha }}" 54 | secrets: | 55 | "npmrc=//npm.pkg.github.com/:_authToken=${{ secrets.GPR_TOKEN }}" 56 | -------------------------------------------------------------------------------- /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 | import posthog from './models/posthog'; 7 | import OicqClient from './client/OicqClient'; 8 | 9 | (async () => { 10 | configure({ 11 | appenders: { 12 | console: { type: 'console' }, 13 | }, 14 | categories: { 15 | default: { level: env.LOG_LEVEL, appenders: ['console'] }, 16 | }, 17 | }); 18 | const log = getLogger('Main'); 19 | 20 | process.on('unhandledRejection', error => { 21 | log.error('UnhandledRejection: ', error); 22 | posthog.capture('UnhandledRejection', { error }); 23 | }); 24 | 25 | process.on('uncaughtException', error => { 26 | log.error('UncaughtException: ', error); 27 | posthog.capture('UncaughtException', { error }); 28 | }); 29 | 30 | api.startListening(); 31 | 32 | const instanceEntries = await db.instance.findMany(); 33 | 34 | if (!instanceEntries.length) { 35 | await Instance.start(0); 36 | } 37 | else { 38 | for (const instanceEntry of instanceEntries) { 39 | await Instance.start(instanceEntry.id); 40 | } 41 | } 42 | 43 | posthog.capture('启动完成', { instanceCount: instanceEntries.length }); 44 | 45 | setTimeout(async () => { 46 | for (const instance of Instance.instances.filter(it => it.workMode === 'group')) { 47 | if (!(instance.oicq instanceof OicqClient)) continue; 48 | try { 49 | await instance.forwardPairs.initMapInstance(Instance.instances.filter(it => it.workMode === 'personal')); 50 | } 51 | catch { 52 | } 53 | } 54 | }, 15 * 1000); 55 | })(); 56 | -------------------------------------------------------------------------------- /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/tools/mkQFaceChannel.ts: -------------------------------------------------------------------------------- 1 | import fsP from 'fs/promises'; 2 | import qface from '../src/constants/qface'; 3 | import path from 'path'; 4 | 5 | const BOT_TOKEN = process.argv[2]; 6 | const DIR = process.argv[3]; 7 | const CHANNEL = -1002431668959; 8 | 9 | (async () => { 10 | for (const file of await fsP.readdir(DIR)) { 11 | if (!file.endsWith('.webm')) continue; 12 | 13 | const id = file.replace(/\.webm$/, ''); 14 | const name = qface[id]; 15 | 16 | const resTitle = await wrap429(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { 17 | method: 'POST', 18 | headers: { 19 | 'Content-Type': 'application/json' 20 | }, 21 | body: JSON.stringify({ 22 | chat_id: CHANNEL, 23 | text: `${id}: ${name}`, 24 | }), 25 | }); 26 | const titleId = resTitle.result.message_id; 27 | console.log(`'${id}': ${titleId}`); 28 | 29 | const frm = new FormData(); 30 | frm.append('chat_id', CHANNEL.toString()); 31 | frm.append('reply_parameters', JSON.stringify({ 32 | message_id: titleId, 33 | })); 34 | frm.append('sticker', new Blob([await fsP.readFile(path.join(DIR, file))]), 'sticker.webm'); 35 | await wrap429(`https://api.telegram.org/bot${BOT_TOKEN}/sendSticker`, { 36 | method: 'POST', 37 | body: frm, 38 | }) 39 | } 40 | })(); 41 | 42 | const wrap429 = async (url: string, ext: any) => { 43 | const req = await fetch(url, ext); 44 | const res = await req.json(); 45 | if (res.ok) return res; 46 | if (res.error_code !== 429) throw res; 47 | const wait = res.parameters.retry_after; 48 | console.log(`429: waiting ${wait} seconds`); 49 | await new Promise(resolve => setTimeout(resolve, wait * 1000)); 50 | return wrap429(url, ext); 51 | }; 52 | -------------------------------------------------------------------------------- /main/src/api/telegramAvatar.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import convert from '../helpers/convert'; 3 | import Telegram from '../client/Telegram'; 4 | import { Api } from 'telegram'; 5 | import BigInteger from 'big-integer'; 6 | import { getLogger } from 'log4js'; 7 | import fs from 'fs'; 8 | import { Elysia } from 'elysia'; 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 new Elysia() 36 | .get('/telegramAvatar/:instanceId/:userId', async ({ params, error, set }) => { 37 | log.debug('请求头像', params.userId); 38 | const instance = Instance.instances.find(it => it.id.toString() === params.instanceId); 39 | const avatar = await getUserAvatarPath(instance.tgBot, params.userId); 40 | 41 | if (!avatar) { 42 | return error(404); 43 | } 44 | 45 | set.headers['content-type'] = 'image/jpeg'; 46 | return fs.createReadStream(avatar); 47 | }); 48 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/types/StructMessageCard.d.ts: -------------------------------------------------------------------------------- 1 | export default interface StructMessageCard{ 2 | "app": "com.tencent.structmsg", 3 | "config": any, 4 | "desc": "新闻", 5 | "extra": any, 6 | "meta": { 7 | "news": { 8 | "action": string, 9 | "android_pkg_name": string, 10 | "app_type": number, 11 | "appid": number, 12 | "desc": string, 13 | "jumpUrl": string, 14 | "preview": string, 15 | "source_icon": string, 16 | "source_url": string, 17 | "tag": string, 18 | "title": string 19 | } 20 | }, 21 | "prompt": string, 22 | "ver": string, 23 | "view": "news" 24 | } 25 | 26 | /* 27 | { 28 | "app": "com.tencent.structmsg", 29 | "config": { 30 | "autosize": true, 31 | "ctime": 1626859321, 32 | "forward": true, 33 | "token": "f8cbdb9882d3b0118f305fa8839f9dd0", 34 | "type": "normal" 35 | }, 36 | "desc": "新闻", 37 | "extra": { 38 | "app_type": 1, 39 | "appid": 100951776, 40 | "msg_seq": 6987307571346607220, 41 | "uin": xxx 42 | }, 43 | "meta": { 44 | "news": { 45 | "action": "", 46 | "android_pkg_name": "", 47 | "app_type": 1, 48 | "appid": 100951776, 49 | "desc": "还在买爆款劣质U盘? 教你用绝版SLC颗粒做一个 寿命用到下辈子 有手就行", 50 | "jumpUrl": "https://b23.tv/WWRIW2?share_medium=android&share_source=qq&bbid=XX23888200D3F348B37BDF8716B806C3414C8&ts=1626859315667", 51 | "preview": "https://external-30160.picsz.qpic.cn/442c3acb5e3a8365c63eb5a0a735ee77/jpg1", 52 | "source_icon": "", 53 | "source_url": "", 54 | "tag": "哔哩哔哩", 55 | "title": "哔哩哔哩" 56 | } 57 | }, 58 | "prompt": "[分享]哔哩哔哩", 59 | "ver": "0.0.0.1", 60 | "view": "news" 61 | } 62 | */ 63 | -------------------------------------------------------------------------------- /main/src/controllers/GroupNameRefreshController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import { GroupNameChangeEvent, QQClient } from '../client/QQClient'; 4 | import flags from '../constants/flags'; 5 | import { NapCatGroupMember } from '../client/NapCatClient'; 6 | import env from '../models/env'; 7 | import helper from '../helpers/forwardHelper'; 8 | import { getLogger, Logger } from 'log4js'; 9 | 10 | export default class GroupNameRefreshController { 11 | private readonly log: Logger; 12 | 13 | constructor(private readonly instance: Instance, 14 | private readonly tgBot: Telegram, 15 | private readonly tgUser: Telegram, 16 | private readonly oicq: QQClient) { 17 | oicq.addGroupNameChangeHandler(this.handleGroupNameChange.bind(this)); 18 | this.log = getLogger(`GroupNameRefreshController - ${instance.id}`); 19 | } 20 | 21 | private async handleGroupNameChange(event: GroupNameChangeEvent) { 22 | this.log.debug(event); 23 | const pair = this.instance.forwardPairs.find(event.group); 24 | if (!pair) return; 25 | 26 | if ((pair.flags | this.instance.flags) & flags.NAME_LOCKED) return; 27 | await pair.tg.editTitle(event.newName); 28 | 29 | if(event.operator instanceof NapCatGroupMember) { 30 | const operatorInfo = await event.operator.renew(); 31 | let operatorName = operatorInfo.card || operatorInfo.nickname; 32 | if (!((pair.flags | this.instance.flags) & flags.DISABLE_RICH_HEADER) && env.WEB_ENDPOINT) { 33 | const richHeaderUrl = helper.generateRichHeaderUrl(pair.apiKey, operatorInfo.user_id, operatorName); 34 | operatorName = `${operatorName}`; 35 | } 36 | 37 | await pair.tg.sendMessage({ 38 | message: `${operatorName} 修改群名为 ${event.newName}`, 39 | parseMode: 'html', 40 | silent: true, 41 | }) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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) { 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 | let display = Array.from(input); 14 | display.push(SYMBOL_INPUT); 15 | // 增大一点键盘的大小,方便按 16 | return `>>> ${display.join(SYMBOL_SPACE)} <<<`; 17 | } 18 | 19 | function refreshDisplay() { 20 | message.edit({ 21 | text: getDisplay(), 22 | }); 23 | } 24 | 25 | function resolveInput() { 26 | resolve(input); 27 | message.edit({ 28 | text: `${input}`, 29 | buttons: Button.clear(), 30 | }); 31 | return; 32 | } 33 | 34 | function inputButton(digit: number | string) { 35 | digit = digit.toString(); 36 | return Button.inline(digit, chat.parent.registerCallback(() => { 37 | input += digit; 38 | refreshDisplay(); 39 | })); 40 | } 41 | 42 | const backspaceButton = Button.inline('⌫', chat.parent.registerCallback(() => { 43 | if (!input.length) return; 44 | input = input.substring(0, input.length - 1); 45 | refreshDisplay(); 46 | })); 47 | 48 | const submitButton = Button.inline('提交', chat.parent.registerCallback(() => { 49 | if (!input.length) return; 50 | resolveInput(); 51 | })) 52 | 53 | const message = await chat.sendMessage({ 54 | message: getDisplay(), 55 | buttons: [ 56 | [inputButton(1), inputButton(2), inputButton(3)], 57 | [inputButton(4), inputButton(5), inputButton(6)], 58 | [inputButton(7), inputButton(8), inputButton(9)], 59 | [inputButton(0), backspaceButton, submitButton], 60 | ], 61 | }); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docker-compose-examples/icqq/with-cloudflare-tunnel/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 | tunnel: 21 | container_name: cloudflared-tunnel 22 | image: cloudflare/cloudflared 23 | restart: unless-stopped 24 | command: tunnel run 25 | environment: 26 | - TUNNEL_TOKEN= #此处填入设定Cloudflare Tunnel时产生的指令的 --token 后面那一串密钥 27 | 28 | sign: 29 | image: ghcr.io/clansty/qsign 30 | restart: unless-stopped 31 | 32 | q2tg: 33 | image: ghcr.io/clansty/q2tg:sleepyfox 34 | restart: unless-stopped 35 | depends_on: 36 | - postgres 37 | - sign 38 | ports: 39 | # 如果要使用 RICH_HEADER 需要将端口发布到公网 40 | - 8080:8080 41 | volumes: 42 | - q2tg:/app/data 43 | # 下面这行是固定的,和你用不用 NapCat 没关系,不要动 44 | - cache:/app/.config/QQ/NapCat/temp 45 | - /var/run/docker.sock:/var/run/docker.sock 46 | environment: 47 | - TG_API_ID= 48 | - TG_API_HASH= 49 | - TG_BOT_TOKEN= 50 | - DATABASE_URL=postgres://user:password@postgres/db_name 51 | - SIGN_API=http://sign:4848/sign?key=114514 52 | - SIGN_VER=9.0.56 # 与上方 sign 容器的配置同步 53 | - TG_CONNECTION=tcp # 连接 Telegram 的方式,也可以改成 websocket 54 | # 如果你需要使用 /flags set RICH_HEADER 来显示头像,或者正确显示合并转发的消息记录,则需将 q2tg 8080 端口发布到公网,可以使用 cloudflare tunnel 55 | # 请尽量配置这个服务 56 | - WEB_ENDPOINT= # https://yourichheader.com 填写你发布到公网的域名 57 | #- CRV_VIEWER_APP= 58 | # DEPRECATED: 请使用 WEB_ENDPOINT 59 | #- CRV_API= 60 | #- CRV_KEY= 61 | # 要关闭文件上传提示,请取消注释以下变量 https://github.com/clansty/Q2TG/issues/153 62 | #- DISABLE_FILE_UPLOAD_TIP=1 63 | # 如果需要通过代理联网,那么设置下面两个变量 64 | #- PROXY_IP= 65 | #- PROXY_PORT= 66 | # 代理联网认证,有需要请修改下面两个变量 67 | #- PROXY_USERNAME= 68 | #- PROXY_PASSWORD= 69 | -------------------------------------------------------------------------------- /main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "q2tg-main", 3 | "scripts": { 4 | "dev": "tsx src/index.ts", 5 | "build": "tsc && tsx build.ts", 6 | "start": "prisma db push --accept-data-loss --skip-generate && node --enable-source-maps 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 | "@elysiajs/html": "^1.1.1", 17 | "@kitajs/ts-html-plugin": "^4.1.1", 18 | "@types/cli-progress": "^3.11.6", 19 | "@types/dockerode": "^3.3.32", 20 | "@types/ejs": "^3.1.5", 21 | "@types/fluent-ffmpeg": "^2.1.27", 22 | "@types/lodash": "^4.17.13", 23 | "@types/markdown-escape": "^1.1.3", 24 | "@types/mime-types": "^2.1.4", 25 | "@types/node": "^22.10.1", 26 | "@types/probe-image-size": "^7.2.5", 27 | "@types/prompts": "^2.4.9", 28 | "@types/tmp": "^0.2.6", 29 | "axios": "^1.7.9", 30 | "big-integer": "^1.6.52", 31 | "cli-progress": "^3.12.0", 32 | "date-fns": "^4.1.0", 33 | "dotenv": "^16.4.7", 34 | "elysia": "^1.1.26", 35 | "esbuild": "^0.24.0", 36 | "eviltransform": "^0.2.2", 37 | "file-type": "19.3.0", 38 | "fluent-ffmpeg": "^2.1.3", 39 | "image-size": "^1.1.1", 40 | "lodash": "^4.17.21", 41 | "log4js": "^6.9.1", 42 | "mime-types": "^2.1.35", 43 | "node-napcat-ts": "^0.4.1", 44 | "nodejs-base64": "^2.0.0", 45 | "posthog-node": "^4.3.1", 46 | "prompts": "^2.4.2", 47 | "reconnecting-websocket": "^4.4.0", 48 | "telegram": "^2.26.8", 49 | "tmp-promise": "^3.0.3", 50 | "tsx": "^4.19.2", 51 | "undici": "^7.1.0", 52 | "zod": "^3.24.1" 53 | }, 54 | "dependencies": { 55 | "@bogeychan/elysia-polyfills": "^0.6.4", 56 | "@icqqjs/icqq": "1.4.0", 57 | "@prisma/client": "6.1.0", 58 | "canvas": "^3.0.1", 59 | "dockerode": "^4.0.2", 60 | "prisma": "6.1.0", 61 | "quote-api": "https://github.com/Clansty/quote-api/archive/014b21138afbbe0e12c91b00561414b1e851fc0f.tar.gz", 62 | "sharp": "^0.33.5", 63 | "silk-sdk": "^0.2.2" 64 | }, 65 | "engines": { 66 | "node": ">=22.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/utils/processHistory.ts: -------------------------------------------------------------------------------- 1 | import type DateGroup from '../types/DateGroup'; 2 | import type SenderGroup from '../types/SenderGroup'; 3 | import { ForwardMessage } from '@icqqjs/icqq'; 4 | import { format } from 'date-fns'; 5 | import getUserAvatarUrl from './getUserAvatarUrl'; 6 | 7 | const USER_ID_PRIVATE = 1094950020; 8 | 9 | export default function processHistory(history: ForwardMessage[]) { 10 | const data: DateGroup[] = []; 11 | let currentDateGroup: DateGroup | undefined; 12 | let currentSenderGroup: SenderGroup | undefined; 13 | for (let i = 0; i < history.length; i++) { 14 | const current = history[i]; 15 | const time = current.time; 16 | const msgDate = new Date(time * 1000); 17 | if (!currentDateGroup || format(msgDate, 'yyyy/M/d') !== currentDateGroup.date) { 18 | // 推入所有数据 19 | if (currentSenderGroup) 20 | // 必有 currentDateGroup 21 | currentDateGroup!.messages.push(currentSenderGroup); 22 | if (currentDateGroup) 23 | data.push(currentDateGroup); 24 | currentSenderGroup = undefined; 25 | // 开始新的一天 26 | currentDateGroup = { 27 | date: format(msgDate, 'yyyy/M/d'), 28 | messages: [], 29 | }; 30 | } 31 | let senderId = 0 as number | string, username = '', avatar = ''; 32 | senderId = current.user_id === USER_ID_PRIVATE ? current.avatar || current.nickname : current.user_id; 33 | username = current.nickname; 34 | avatar = current.avatar || (Number(senderId) ? getUserAvatarUrl(Number(senderId)) : ''); 35 | 36 | if (!currentSenderGroup || senderId !== currentSenderGroup.senderId) { 37 | if (currentSenderGroup) { 38 | // 不是一开始的情况 39 | currentDateGroup!.messages.push(currentSenderGroup); 40 | } 41 | // 开始一个新的发送者分组 42 | currentSenderGroup = { 43 | id: i, 44 | senderId, 45 | username, 46 | messages: [], 47 | avatar, 48 | }; 49 | } 50 | currentSenderGroup.messages.push(current); 51 | } 52 | // 收工啦 53 | if (currentSenderGroup) 54 | currentDateGroup!.messages.push(currentSenderGroup); 55 | if (currentDateGroup) 56 | data.push(currentDateGroup); 57 | return data; 58 | } 59 | -------------------------------------------------------------------------------- /main/src/controllers/AliveCheckController.ts: -------------------------------------------------------------------------------- 1 | import Instance from '../models/Instance'; 2 | import Telegram from '../client/Telegram'; 3 | import { Api } from 'telegram'; 4 | import { QQClient } from '../client/QQClient'; 5 | import OicqClient from '../client/OicqClient'; 6 | 7 | export default class AliveCheckController { 8 | constructor(private readonly instance: Instance, 9 | private readonly tgBot: Telegram, 10 | private readonly tgUser: Telegram, 11 | private readonly oicq: QQClient) { 12 | tgBot.addNewMessageEventHandler(this.handleMessage); 13 | } 14 | 15 | private handleMessage = async (message: Api.Message) => { 16 | if (!message.sender?.id?.eq(this.instance.owner) || !message.isPrivate) { 17 | return false; 18 | } 19 | if (!['似了吗', '/alive'].includes(message.message)) { 20 | return false; 21 | } 22 | 23 | await message.reply({ 24 | message: await this.genMessage(this.instance.id === 0 ? Instance.instances : [this.instance]), 25 | }); 26 | return true; 27 | }; 28 | 29 | private async genMessage(instances: Instance[]): Promise { 30 | const boolToStr = (value: boolean) => { 31 | return value ? '好' : '坏'; 32 | }; 33 | const messageParts: string[] = []; 34 | 35 | for (const instance of instances) { 36 | const oicq = instance.oicq; 37 | const tgBot = instance.tgBot; 38 | const tgUser = instance.tgUser; 39 | 40 | const sign = oicq instanceof OicqClient ? await oicq.oicq.getSign('MessageSvc.PbSendMsg', 233, Buffer.alloc(10)) : null; 41 | 42 | const tgUserName = (tgUser.me.username || tgUser.me.usernames.length) ? 43 | '@' + (tgUser.me.username || tgUser.me.usernames[0].username) : tgUser.me.firstName; 44 | messageParts.push([ 45 | `Instance #${instance.id} (${instance.workMode}) ${instance.isInit ? '' : '初始化未完成'}`, 46 | 47 | `QQ ${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; 15 | 16 | getVideoUrl(fid: string, md5: string | Buffer): Promise; 17 | 18 | recallMsg(seqOrMessageId: number, rand?: number, timeOrPktNum?: number): Promise; 19 | 20 | sendMsg(content: Sendable, source?: Quotable, isSpoiler?: boolean): Promise; 21 | 22 | getFileUrl(fid: string): Promise; 23 | } 24 | 25 | export interface QQUser extends QQEntity { 26 | readonly uin: number; 27 | } 28 | 29 | export interface Friend extends QQUser { 30 | readonly nickname: string; 31 | readonly remark: string; 32 | 33 | sendFile(file: string, filename: string): Promise; 34 | } 35 | 36 | export interface Group extends QQEntity { 37 | readonly gid: number; 38 | readonly name: string; 39 | readonly is_owner: boolean; 40 | readonly is_admin: boolean; 41 | readonly fs: GroupFs; 42 | 43 | pickMember(uin: number, strict?: boolean): GroupMember; 44 | 45 | muteMember(uin: number, duration?: number): Promise; 46 | 47 | setCard(uin: number, card?: string): Promise; 48 | 49 | announce(content: string): Promise; 50 | } 51 | 52 | export interface GroupFs { 53 | upload(file: string | Buffer | Uint8Array, pid?: string, name?: string, callback?: (percentage: string) => void): Promise; 54 | } 55 | 56 | export interface GroupMember extends QQUser { 57 | renew(): Promise; 58 | } 59 | 60 | export interface GroupMemberInfo { 61 | readonly user_id: number; 62 | readonly card: string; 63 | readonly nickname: string; 64 | readonly sex: Gender; 65 | readonly age: number; 66 | readonly join_time: number; 67 | readonly last_sent_time: number; 68 | readonly role: GroupRole; 69 | readonly title: string; 70 | } 71 | 72 | export interface ForwardMessage { 73 | user_id: number; 74 | nickname: string; 75 | group_id?: number; 76 | time: number; 77 | seq: number; 78 | message: MessageElem[]; 79 | raw_message: string; 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Q2TG 2 | QQ 群与 Telegram 群相互转发的 bot 3 | 4 | 交流 https://t.me/+XkF-96lLnFU3ZTM1 5 | 6 | ## 安装方法 7 | 8 | 请看 [手册](https://kb.0w.al/文档/Q2TG/安装部署),[从 V3 更新到 V4](https://kb.0w.al/文档/Q2TG/从%20V3%20更新到%20V4) 9 | 10 | v2.x 及以上版本同时需要机器人账号以及登录 Telegram 个人账号,需要自己注册 Telegram API ID,并且还需要配置一些辅助服务。 11 | 12 | 如果你主要使用群组模式并且不想使用个人账号登录 UserBot,可以使用去除 UserBot 的 [Nofated095/Q2TG](https://github.com/Nofated095/Q2TG) 版本。一些功能例如撤回检测将无法使用 13 | 14 | ## 支持的消息类型 15 | 16 | - [x] 文字(双向) 17 | - [x] 图片(双向) 18 | - [x] GIF 19 | - [x] 闪照 20 | 闪照每个 TG 用户也只能查看 5 秒 21 | - [x] 图文混排消息(双向) 22 | - [x] 大表情(双向) 23 | - [x] TG 中的动态 Sticker
24 | 目前是[转换成 GIF](https://github.com/ed-asriyan/tgs-to-gif) 发送的,并且可能有些[问题](https://github.com/ed-asriyan/tgs-to-gif/issues/13#issuecomment-633244547) 25 | - [x] 视频(双向) 26 | - [x] 语音(双向) 27 | - [x] 小表情(可显示为文字) 28 | - [x] 链接(双向) 29 | - [x] JSON/XML 卡片
30 | (包括部分转化为小程序的链接) 31 | - [x] 位置(TG -> QQ) 32 | - [x] 群公告 33 | - [x] 回复(双平台原生回复) 34 | - [x] 文件
35 | QQ -> TG 按需获取下载地址
36 | TG -> QQ 将自动转发 20M 以下的小文件 37 | - [x] 转发多条消息记录 38 | - [x] TG 编辑消息(撤回再重发) 39 | - [x] 双向撤回消息 40 | - [x] 戳一戳 41 | 42 | ## 关于模式 43 | 44 | ### 群组模式 45 | 46 | 群组模式就是 1.x 版本唯一的模式,是给群主使用的。如果群组想要使自己的 QQ 群和 Telegram 群联通起来,就使用这个模式。群组模式只可以给群聊配置转发,并且转发消息时会带上用户在当前平台的发送者名称。 47 | 48 | ### 个人模式 49 | 50 | 个人模式适合 QQ 轻度使用者,TG 重度使用者。可以把 QQ 的好友和群聊搬到 Telegram 中。个人模式一定要登录机器人主人自己的 Telegram 账号作为 UserBot。可以自动为 QQ 中的好友和群组创建对应的 Telegram 群组,并同步头像简介等信息。当有没有创建关联的好友发起私聊的时候会自动创建 Telegram 中的对应群组。个人模式在初始化的时候会自动在 Telegram 个人账号中创建一个文件夹来存储所有来自 QQ 的对应群组。消息在从 TG 转发到 QQ 时不会带上发送者昵称,因为默认发送者只有一个人。 51 | 52 | ## 如何撤回消息 53 | 54 | 在 QQ 中,直接撤回相应的消息,撤回操作会同步到 TG 55 | 56 | 在 TG 中,可以选择以下操作之一: 57 | 58 | - 将消息内容编辑为 `/rm` 59 | - 回复要撤回的消息,内容为 `/rm`。如果操作者在 TG 群组中没有「删除消息」权限,则只能撤回自己的消息 60 | - 如果正确配置了个人账号的 User Bot,可以直接删除消息 61 | 62 | 为了使撤回功能正常工作,TG 机器人需要具有「删除消息」权限,QQ 机器人需要为管理员或群主 63 | 64 | 即使 QQ 机器人为管理员,也无法撤回其他管理员在 QQ 中发送的消息 65 | 66 | ## 免责声明 67 | 68 | 一切开发旨在学习,请勿用于非法用途。本项目完全免费开源,不会收取任何费用,无任何担保。请勿将本项目用于商业用途。由于使用本程序造成的任何问题,由使用者自行承担,项目开发者不承担任何责任。 69 | 70 | 本项目基于 AGPL 发行。修改、再发行和运行服务需要遵守 AGPL 许可证,源码需要和服务一起提供。 71 | 72 | ## 许可证 73 | 74 | ``` 75 | This program is free software: you can redistribute it and/or modify 76 | it under the terms of the GNU Affero General Public License as 77 | published by the Free Software Foundation, either version 3 of the 78 | License, or (at your option) any later version. 79 | 80 | This program is distributed in the hope that it will be useful, 81 | but WITHOUT ANY WARRANTY; without even the implied warranty of 82 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 83 | GNU Affero General Public License for more details. 84 | 85 | You should have received a copy of the GNU Affero General Public License 86 | along with this program. If not, see . 87 | ``` 88 | -------------------------------------------------------------------------------- /docker-compose-examples/NapCat/with-nginx-certbot/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 | postgres: 12 | image: postgres:14-alpine 13 | restart: unless-stopped 14 | environment: 15 | POSTGRES_DB: db_name 16 | POSTGRES_USER: user 17 | POSTGRES_PASSWORD: password 18 | volumes: 19 | - postgresql:/var/lib/postgresql/data 20 | 21 | napcat: 22 | image: mlikiowa/napcat-docker:latest 23 | environment: 24 | - ACCOUNT=要登录的 QQ 号 25 | - WS_ENABLE=true 26 | - NAPCAT_GID=1000 27 | - NAPCAT_UID=1000 28 | ports: 29 | - 6099:6099 30 | mac_address: 02:42:12:34:56:78 # 请修改为一个固定的 MAC 地址,但是不要和其他容器或你的主机重复 31 | restart: unless-stopped 32 | volumes: 33 | - napcat-data:/app/.config/QQ 34 | - napcat-config:/app/napcat/config 35 | - cache:/app/.config/QQ/NapCat/temp 36 | 37 | q2tg: 38 | image: ghcr.io/clansty/q2tg:sleepyfox 39 | restart: unless-stopped 40 | depends_on: 41 | - postgres 42 | - napcat 43 | ports: 44 | # 如果要使用 RICH_HEADER 需要将端口发布到公网 45 | - 8080:8080 46 | volumes: 47 | - q2tg:/app/data 48 | - cache:/app/.config/QQ/NapCat/temp 49 | - /var/run/docker.sock:/var/run/docker.sock 50 | environment: 51 | - TG_API_ID= 52 | - TG_API_HASH= 53 | - TG_BOT_TOKEN= 54 | - DATABASE_URL=postgres://user:password@postgres/db_name 55 | - NAPCAT_WS_URL=ws://napcat:3001 56 | - TG_CONNECTION=tcp # 连接 Telegram 的方式,也可以改成 websocket 57 | # 如果你需要使用 /flags set RICH_HEADER 来显示头像,或者正确显示合并转发的消息记录,则需将 q2tg 8080 端口发布到公网,可以使用 cloudflare tunnel 58 | # 请尽量配置这个服务 59 | - WEB_ENDPOINT= # https://yourichheader.com 填写你发布到公网的域名 60 | #- CRV_VIEWER_APP= 61 | # DEPRECATED: 请使用 WEB_ENDPOINT 62 | #- CRV_API= 63 | #- CRV_KEY= 64 | # 要关闭文件上传提示,请取消注释以下变量 https://github.com/clansty/Q2TG/issues/153 65 | #- DISABLE_FILE_UPLOAD_TIP=1 66 | # 如果需要通过代理联网,那么设置下面两个变量 67 | #- PROXY_IP= 68 | #- PROXY_PORT= 69 | # 代理联网认证,有需要请修改下面两个变量 70 | #- PROXY_USERNAME= 71 | #- PROXY_PASSWORD= 72 | 73 | nginx: 74 | image: nginx:alpine 75 | restart: unless-stopped 76 | ports: 77 | - 80:80 78 | - 443:443 79 | volumes: 80 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 81 | - ./certbot/www:/var/www/certbot:ro 82 | - ./certbot/cert:/etc/letsencrypt:ro 83 | depends_on: 84 | - q2tg 85 | 86 | certbot: 87 | image: certbot/certbot:latest 88 | volumes: 89 | - ./certbot/www:/var/www/certbot 90 | - ./certbot/cert:/etc/letsencrypt 91 | depends_on: 92 | - nginx 93 | command: certonly --webroot -w /var/www/certbot --force-renewal --email 你的邮箱 -d 你的域名 --agree-tos 94 | -------------------------------------------------------------------------------- /ui/src/views/ChatRecord/Viewer/components/MessageElement.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, type PropType } from 'vue'; 2 | import type { MessageElemExt } from '../types/MessageElemExt'; 3 | import styles from './MessageElement.module.sass'; 4 | import getImageUrlByMd5 from '../utils/getImageUrlByMd5'; 5 | import { NImage } from 'naive-ui'; 6 | import JsonElement from './JsonElement'; 7 | import XmlElement from './XmlElement'; 8 | import linkifyStr from 'linkify-string'; 9 | 10 | export default defineComponent({ 11 | props: { 12 | elem: { required: true, type: Object as PropType }, 13 | }, 14 | setup(props) { 15 | return () => { 16 | switch (props.elem.type) { 17 | case 'text': 18 | case 'face': 19 | case 'sface': 20 | case 'at': 21 | // will not xss 22 | return
; 26 | case 'image': 27 | case 'flash': { 28 | let url = props.elem.url; 29 | let md5; 30 | if (!url && typeof props.elem.file === 'string') { 31 | md5 = props.elem.file.substring(0, 32); 32 | if (!/([a-f\d]{32}|[A-F\d]{32})/.test(md5)) 33 | md5 = undefined; 34 | if (md5) { 35 | url = getImageUrlByMd5(md5); 36 | } 37 | } 38 | return ; 44 | } 45 | case 'video-loop': 46 | return