feat: 自动创建转发群组

This commit is contained in:
Clansty 2022-02-23 17:11:04 +08:00
parent 7b6d615984
commit 32422506df
No known key found for this signature in database
GPG Key ID: 05F8479BA63A8E92
15 changed files with 312 additions and 110 deletions

View File

@ -9,13 +9,13 @@
},
"devDependencies": {
"@types/node": "^17.0.18",
"prisma": "^3.9.2",
"prisma": "latest",
"ts-node": "^10.5.0",
"tsc": "^2.0.4",
"typescript": "^4.5.5"
},
"dependencies": {
"@prisma/client": "^3.9.2",
"@prisma/client": "latest",
"axios": "^0.26.0",
"log4js": "^6.4.1",
"oicq": "^2.2.0",

View File

@ -11,45 +11,53 @@ datasource db {
}
model Message {
id Int @id @default(autoincrement())
qqRoomId Int
qqSenderId Int
time DateTime
brief String
seq Int
rand Int
pktnum Int
tgChatId Int
tgMsgId Int
id Int @id @default(autoincrement())
qqRoomId Int
qqSenderId Int
time DateTime
brief String
seq Int
rand Int
pktnum Int
tgChatId Int
tgMsgId Int
@@unique([qqRoomId, qqSenderId, seq, rand, pktnum])
@@unique([tgChatId, tgMsgId])
}
model ForwardPair {
id Int @id @default(autoincrement())
qqRoomId Int @unique
tgChatId Int @unique
id Int @id @default(autoincrement())
qqRoomId Int @unique
tgChatId Int @unique
AvatarCache AvatarCache[]
}
model File {
id Int @id @default(autoincrement())
groupId Int
fileId String
info String
id Int @id @default(autoincrement())
groupId Int
fileId String
info String
@@unique([groupId, fileId])
}
model FlashPhoto {
id Int @id @default(autoincrement())
photoMd5 String
id Int @id @default(autoincrement())
photoMd5 String
}
model FlashPhotoView {
id Int @id @default(autoincrement())
flashPhotoId Int
viewerId Int
id Int @id @default(autoincrement())
flashPhotoId Int
viewerId Int
@@unique([flashPhotoId, viewerId])
}
model AvatarCache {
id Int @id @default(autoincrement())
forwardPair ForwardPair @relation(fields: [forwardPairId], references: [id])
forwardPairId Int @unique
hash String
}

View File

@ -88,6 +88,7 @@ export default class OicqClient extends Client {
const client = new this(params.uin, {
platform: params.platform,
data_dir: path.resolve('./data'),
log_level: 'warn',
})
.on('system.login.device', loginDeviceHandler)
.on('system.login.slider', loginSliderHandler)

View File

@ -4,17 +4,16 @@ import { BotAuthParams, UserAuthParams } from 'telegram/client/auth';
import { NewMessage, NewMessageEvent } from 'telegram/events';
import { EditedMessage, EditedMessageEvent } from 'telegram/events/EditedMessage';
import { DeletedMessage, DeletedMessageEvent } from 'telegram/events/DeletedMessage';
import { ButtonLike, Entity, EntityLike } from 'telegram/define';
import { SendMessageParams } from 'telegram/client/messages';
import { CustomFile } from 'telegram/client/uploads';
import { EntityLike } from 'telegram/define';
import WaitForMessageHelper from '../helpers/WaitForMessageHelper';
import createPaginatedInlineSelector from '../utils/paginatedInlineSelector';
import CallbackQueryHelper from '../helpers/CallbackQueryHelper';
import { CallbackQuery } from 'telegram/events/CallbackQuery';
import os from 'os';
import TelegramChat from './TelegramChat';
type MessageHandler = (message: Api.Message) => Promise<boolean>;
export class Telegram {
export default class Telegram {
private readonly client: TelegramClient;
private waitForMessageHelper: WaitForMessageHelper;
private callbackQueryHelper: CallbackQueryHelper = new CallbackQueryHelper();
@ -29,6 +28,7 @@ export class Telegram {
{
connectionRetries: 5,
langCode: 'zh',
deviceModel: `Q2TG On ${os.hostname()}`,
appVersion: 'raincandy',
proxy: process.env.PROXY_IP ? {
socksType: 5,
@ -95,7 +95,7 @@ export class Telegram {
public getStringSession() {
// 上游定义不好好写
return this.client.session.save() as any as string;
return (this.client.session as StringSession).save();
}
public async setCommands(commands: Api.BotCommand[], scope: Api.TypeBotCommandScope) {
@ -119,43 +119,10 @@ export class Telegram {
public async updateDialogFilter(params: Partial<Partial<{ id: number; filter?: Api.DialogFilter; }>>) {
return await this.client.invoke(new Api.messages.UpdateDialogFilter(params));
}
}
export class TelegramChat {
constructor(public readonly parent: Telegram,
private readonly client: TelegramClient,
public readonly entity: Entity,
private readonly waitForInputHelper: WaitForMessageHelper) {
}
public async sendMessage(params: SendMessageParams | string) {
if (typeof params === 'string') {
params = { message: params };
}
return await this.client.sendMessage(this.entity, params);
}
public async sendSelfDestructingPhoto(params: SendMessageParams, photo: CustomFile, ttlSeconds: number) {
// @ts-ignore 定义不好好写的?你家 `FileLike` 明明可以是 `TypeInputMedia`
params.file = new Api.InputMediaUploadedPhoto({
file: await this.client.uploadFile({
file: photo,
workers: 1,
}),
ttlSeconds,
});
return await this.client.sendMessage(this.entity, params);
}
public async waitForInput() {
return this.waitForInputHelper.waitForMessage(this.entity.id);
}
public cancelWait() {
this.waitForInputHelper.cancel(this.entity.id);
}
public createPaginatedInlineSelector(message: string, choices: ButtonLike[][]) {
return createPaginatedInlineSelector(this, message, choices);
public async createChat(params: Partial<Partial<{ users: EntityLike[]; title: string; }>>) {
const updates = await this.client.invoke(new Api.messages.CreateChat(params)) as Api.Updates;
const newChat = updates.chats[0];
return new TelegramChat(this, this.client, newChat, this.waitForMessageHelper);
}
}

106
src/client/TelegramChat.ts Normal file
View File

@ -0,0 +1,106 @@
import { BigInteger } from 'big-integer';
import { Api, TelegramClient, utils } from 'telegram';
import { ButtonLike, Entity, EntityLike } from 'telegram/define';
import WaitForMessageHelper from '../helpers/WaitForMessageHelper';
import { SendMessageParams } from 'telegram/client/messages';
import { CustomFile } from 'telegram/client/uploads';
import Telegram from './Telegram';
import createPaginatedInlineSelector from '../utils/paginatedInlineSelector';
export default class TelegramChat {
public readonly inputPeer: Api.TypeInputPeer;
public readonly id: BigInteger;
constructor(public readonly parent: Telegram,
private readonly client: TelegramClient,
public readonly entity: Entity,
private readonly waitForInputHelper: WaitForMessageHelper) {
this.inputPeer = utils.getInputPeer(entity);
this.id = entity.id;
}
public async sendMessage(params: SendMessageParams | string) {
if (typeof params === 'string') {
params = { message: params };
}
return await this.client.sendMessage(this.entity, params);
}
public async sendSelfDestructingPhoto(params: SendMessageParams, photo: CustomFile, ttlSeconds: number) {
// @ts-ignore 定义不好好写的?你家 `FileLike` 明明可以是 `TypeInputMedia`
params.file = new Api.InputMediaUploadedPhoto({
file: await this.client.uploadFile({
file: photo,
workers: 1,
}),
ttlSeconds,
});
return await this.client.sendMessage(this.entity, params);
}
public async waitForInput() {
return this.waitForInputHelper.waitForMessage(this.entity.id);
}
public cancelWait() {
this.waitForInputHelper.cancel(this.entity.id);
}
public createPaginatedInlineSelector(message: string, choices: ButtonLike[][]) {
return createPaginatedInlineSelector(this, message, choices);
}
public async setProfilePhoto(photo: Buffer) {
if (!(this.entity instanceof Api.Chat))
throw new Error('不是群组,无法设置头像');
return await this.client.invoke(
new Api.messages.EditChatPhoto({
chatId: this.id,
photo: new Api.InputChatUploadedPhoto({
file: await this.client.uploadFile({
file: new CustomFile('photo.jpg', photo.length, '', photo),
workers: 1,
}),
}),
}),
);
}
public async editAdmin(user: EntityLike, isAdmin: boolean) {
if (!(this.entity instanceof Api.Chat))
throw new Error('不是群组,无法设置管理员');
return await this.client.invoke(
new Api.messages.EditChatAdmin({
chatId: this.id,
userId: user,
isAdmin,
}),
);
}
public async editAbout(about: string) {
if (!(this.entity instanceof Api.Chat))
throw new Error('不是群组,无法设置描述');
return await this.client.invoke(
new Api.messages.EditChatAbout({
peer: this.entity,
about,
}),
);
}
public async getInviteLink() {
if (!(this.entity instanceof Api.Chat))
throw new Error('不是群组,无法邀请');
const links = await this.client.invoke(
new Api.messages.GetExportedChatInvites({
peer: this.entity,
adminId: this.parent.me,
limit: 1,
revoked: false,
}),
);
console.log(links);
return links.invites[0];
}
}

View File

@ -1,6 +1,6 @@
import { Api } from 'telegram';
import { Telegram } from '../client/Telegram';
import { Client as OicqClient } from 'oicq';
import Telegram from '../client/Telegram';
import OicqClient from '../client/OicqClient';
import ConfigService from '../services/ConfigService';
import { config } from '../providers/userConfig';
import regExps from '../constants/regExps';

View File

@ -1,4 +1,4 @@
import { Telegram } from '../client/Telegram';
import Telegram from '../client/Telegram';
import SetupService from '../services/SetupService';
import { Api } from 'telegram';
import { getLogger } from 'log4js';

View File

@ -1,4 +1,4 @@
import { Telegram } from '../client/Telegram';
import Telegram from '../client/Telegram';
import { BigInteger } from 'big-integer';
import { Api } from 'telegram';
@ -8,6 +8,7 @@ export default class WaitForMessageHelper {
constructor(private tg: Telegram) {
tg.addNewMessageEventHandler(async e => {
if (!e.chat || e.chat.id) return false;
const handler = this.map.get(Number(e.chat.id));
if (handler) {
this.map.delete(Number(e.chat.id));

View File

@ -1,4 +1,4 @@
import { Telegram } from './client/Telegram';
import Telegram from './client/Telegram';
import { config } from './providers/userConfig';
import { getLogger, configure } from 'log4js';
import SetupController from './controllers/SetupController';

View File

@ -1,21 +1,23 @@
import { Telegram, TelegramChat } from '../client/Telegram';
import { Client as OicqClient, FriendInfo } from 'oicq';
import Telegram from '../client/Telegram';
import { Friend, FriendInfo, Group } from 'oicq';
import { config } from '../providers/userConfig';
import { Button } from 'telegram/tl/custom/button';
import { getLogger } from 'log4js';
import axios from 'axios';
import { getAvatarUrl } from '../utils/urls';
import { getAvatar } from '../utils/urls';
import { CustomFile } from 'telegram/client/uploads';
import db from '../providers/db';
import { Api, utils } from 'telegram';
import commands from '../constants/commands';
import OicqClient from '../client/OicqClient';
import { md5B64 } from '../utils/hashing';
import TelegramChat from '../client/TelegramChat';
const DEFAULT_FILTER_ID = 114; // 514
export default class ConfigService {
private owner: TelegramChat;
private log = getLogger('ConfigService');
private filter;
private filter: Api.DialogFilter;
constructor(private readonly tgBot: Telegram,
private readonly tgUser: Telegram,
@ -33,7 +35,7 @@ export default class ConfigService {
await this.tgBot.setCommands(
config.workMode === 'personal' ? commands.personalPrivateCommands : commands.groupPrivateCommands,
new Api.BotCommandScopePeer({
peer: utils.getInputPeer((await this.tgBot.getChat(config.owner)).entity),
peer: (await this.tgBot.getChat(config.owner)).inputPeer,
}),
);
}
@ -47,7 +49,7 @@ export default class ConfigService {
config.workMode === 'personal' ?
[Button.inline(
`${e.group_name} (${e.group_id})`,
this.tgBot.registerCallback(() => this.createGroupAndLink(-e.group_id)),
this.tgBot.registerCallback(() => this.createGroupAndLink(-e.group_id, e.group_name)),
)] :
[Button.url(
`${e.group_name} (${e.group_id})`,
@ -82,7 +84,7 @@ export default class ConfigService {
private async openFriendSelection(clazz: FriendInfo[], name: string) {
await this.owner.createPaginatedInlineSelector(`选择 QQ 好友\n分组${name}`, clazz.map(e => [
Button.inline(`${e.remark || e.nickname} (${e.user_id})`, this.tgBot.registerCallback(
() => this.createGroupAndLink(e.user_id),
() => this.createGroupAndLink(e.user_id, e.remark || e.nickname),
)),
]));
}
@ -91,10 +93,7 @@ export default class ConfigService {
const group = this.oicq.gl.get(gin);
let avatar: Buffer;
try {
const res = await axios.get(getAvatarUrl(-group.group_id), {
responseType: 'arraybuffer',
});
avatar = res.data;
avatar = await getAvatar(-group.group_id);
}
catch (e) {
avatar = null;
@ -110,8 +109,74 @@ export default class ConfigService {
// endregion
private async createGroupAndLink(roomId: number) {
private async createGroupAndLink(roomId: number, title?: string) {
this.log.info(`创建群组并关联:${roomId}`);
const qEntity = this.oicq.getEntity(roomId);
if (!title) {
// TS 这边不太智能
if (qEntity instanceof Friend) {
title = qEntity.remark || qEntity.nickname;
}
else {
title = qEntity.name;
}
}
let isFinish = false;
try {
// 状态信息
const status = await this.owner.sendMessage('正在创建 Telegram 群…');
// 创建群聊,拿到的是 user 的 chat
const chat = await this.tgUser.createChat({
title,
users: [this.tgBot.me.id],
});
const chatForBot = await this.tgBot.getChat(chat.id);
// 设置管理员
await status.edit({ text: '正在设置管理员…' });
await chat.editAdmin(this.tgBot.me.username, true);
// 关联写入数据库
await status.edit({ text: '正在写数据库…' });
const dbPair = await db.forwardPair.create({
data: { qqRoomId: roomId, tgChatId: Number(chat.id) },
});
isFinish = true;
// 更新头像
await status.edit({ text: '正在更新头像…' });
const avatar = await getAvatar(roomId);
const avatarHash = md5B64(avatar);
await chatForBot.setProfilePhoto(avatar);
await db.avatarCache.create({
data: { forwardPairId: dbPair.id, hash: avatarHash },
});
// 添加到 Filter
await status.edit({ text: '正在将群添加到文件夹…' });
this.filter.includePeers.push(utils.getInputPeer(chat));
await this.tgUser.updateDialogFilter({
id: this.filter.id,
filter: this.filter,
});
// 更新关于文本
await status.edit({ text: '正在更新关于文本…' });
await chatForBot.editAbout(await this.getAboutText(qEntity));
// 完成
await status.edit({ text: '正在获取链接…' });
const { link } = await chat.getInviteLink();
await status.edit({
text: '创建完成!',
buttons: Button.url('打开', link),
});
}
catch (e) {
this.log.error('创建群组并关联失败', e);
await this.owner.sendMessage(`创建群组并关联${isFinish ? '成功了但没完全成功' : '失败'}\n<code>${e}</code>`);
}
}
public async createLinkGroup(qqRoomId: number, tgChatId: number) {
@ -144,7 +209,7 @@ export default class ConfigService {
id: DEFAULT_FILTER_ID,
title: 'QQ',
pinnedPeers: [
utils.getInputPeer((await this.tgUser.getChat(this.tgBot.me.username)).entity),
(await this.tgUser.getChat(this.tgBot.me.username)).inputPeer,
],
includePeers: [],
excludePeers: [],
@ -169,4 +234,27 @@ export default class ConfigService {
}
}
}
private async getAboutText(entity: Friend | Group) {
let text = '';
if (entity instanceof Friend) {
text = `备注:${entity.remark}\n` +
`昵称:${entity.nickname}\n` +
`账号:${entity.user_id}`;
}
else {
const owner = entity.pickMember(entity.info.owner_id);
await owner.renew();
const self = entity.pickMember(this.oicq.uin);
await self.renew();
text = `群名称:${entity.name}\n` +
`${entity.info.member_count} 名成员\n` +
`群号:${entity.group_id}\n` +
(self ? `我的群名片:${self.title ? `${self.title}` : ''}${self.card}\n` : '') +
(owner ? `群主:${owner.title ? `${owner.title}` : ''}${owner.card || owner.info.nickname} (${owner.user_id})` : '') +
((entity.is_admin || entity.is_owner) ? '\n可管理' : '');
}
return text + `\n\n由 @${this.tgBot.me.username} 管理`;
}
}

View File

@ -1,4 +1,4 @@
import { Telegram, TelegramChat } from '../client/Telegram';
import Telegram from '../client/Telegram';
import { config, saveConfig } from '../providers/userConfig';
import { getLogger } from 'log4js';
import { BigInteger } from 'big-integer';
@ -8,6 +8,7 @@ import OicqClient from '../client/OicqClient';
import { Button } from 'telegram/tl/custom/button';
import { CustomFile } from 'telegram/client/uploads';
import { WorkMode } from '../types/definitions';
import TelegramChat from '../client/TelegramChat';
export default class SetupService {
private owner: TelegramChat;

21
src/utils/hashing.ts Normal file
View File

@ -0,0 +1,21 @@
import crypto from 'crypto';
export function md5Hex(input: crypto.BinaryLike) {
const hash = crypto.createHash('md5');
return hash.update(input).digest('hex');
}
export function md5B64(input: crypto.BinaryLike) {
const hash = crypto.createHash('md5');
return hash.update(input).digest('base64');
}
export function sha256Hex(input: crypto.BinaryLike) {
const hash = crypto.createHash('sha256');
return hash.update(input).digest('hex');
}
export function sha256B64(input: crypto.BinaryLike) {
const hash = crypto.createHash('sha256');
return hash.update(input).digest('base64');
}

View File

@ -1,8 +1,8 @@
import { ButtonLike } from 'telegram/define';
import arrays from './arrays';
import { Button } from 'telegram/tl/custom/button';
import { TelegramChat } from '../client/Telegram';
import { Api } from 'telegram';
import TelegramChat from '../client/TelegramChat';
export default async function createPaginatedInlineSelector(chat: TelegramChat, message: string, choices: ButtonLike[][]) {
const PAGE_SIZE = 12;

View File

@ -1,8 +1,10 @@
export function getAvatarUrl(roomId: number, large = false): string {
import axios from 'axios';
export function getAvatarUrl(roomId: number): string {
if (!roomId) return '';
return roomId < 0 ?
`https://p.qlogo.cn/gh/${-roomId}/${-roomId}/0` :
`https://q1.qlogo.cn/g?b=qq&nk=${roomId}&s=${large ? 0 : 140}`;
`https://q1.qlogo.cn/g?b=qq&nk=${roomId}&s=0`;
}
export function getImageUrlByMd5(md5: string) {
@ -15,3 +17,10 @@ export function getBigFaceUrl(file: string) {
2,
)}/${file.substring(0, 32)}/300x300.png`;
}
export async function getAvatar(roomId: number): Promise<Buffer> {
const res = await axios.get(getAvatarUrl(roomId), {
responseType: 'arraybuffer',
});
return res.data;
}

View File

@ -55,31 +55,31 @@ __metadata:
languageName: node
linkType: hard
"@prisma/client@npm:^3.9.2":
version: 3.9.2
resolution: "@prisma/client@npm:3.9.2"
"@prisma/client@npm:latest":
version: 3.10.0
resolution: "@prisma/client@npm:3.10.0"
dependencies:
"@prisma/engines-version": 3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009
"@prisma/engines-version": 3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86
peerDependencies:
prisma: "*"
peerDependenciesMeta:
prisma:
optional: true
checksum: 5b4b8f252624250f609d57ed3939f3dbecb174c297691476050f48d6c24dd41ca2f70f9e5a52729a4e875527e56a2d564a116c0ada8f5fc935c558f09d2759e3
checksum: c9fe863abfbcecff6b26c2fc4c93de77fd11c8209e91b8e2cba13ba3f0866d228eb5e92fac43e1f75497b652db54cb5e309320c3d51e603a4ecb4125dbf3a872
languageName: node
linkType: hard
"@prisma/engines-version@npm:3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version: 3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009
resolution: "@prisma/engines-version@npm:3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
checksum: 413d478fc1980604de1f46a0d53ae6c752965283a7d8c51d8fb3881c371b4a71ab4aa10002dba411d961286caf1141871c3c85d209a316421cdf1e1fdac71b4e
"@prisma/engines-version@npm:3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version: 3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86
resolution: "@prisma/engines-version@npm:3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
checksum: ddcb3bfdab61ac74e2df9e57562ccc7f0eb863955c8cf711f52f45572168f65e030e5070dfc9fc2e37d916b97b236576006b5cd56c813257a96b825c598e79fa
languageName: node
linkType: hard
"@prisma/engines@npm:3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version: 3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009
resolution: "@prisma/engines@npm:3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
checksum: f934a02f0d12b67a8b878da5b854a2df0b1d5adbedb9d6df552dddcb4f58af9401268c2066180a337daa0fd22d9005eae2197bf8a4cc7b9e1a20764fe93f1244
"@prisma/engines@npm:3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version: 3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86
resolution: "@prisma/engines@npm:3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
checksum: 7faa8659dd72e70f9b24be291e3660d086d5b2ee1b6557bf783100a2efaf4f7087882196191c845f96c3953a55ea8bcb03110af98a18e803b90084c0c2211444
languageName: node
linkType: hard
@ -1460,15 +1460,15 @@ __metadata:
languageName: node
linkType: hard
"prisma@npm:^3.9.2":
version: 3.9.2
resolution: "prisma@npm:3.9.2"
"prisma@npm:latest":
version: 3.10.0
resolution: "prisma@npm:3.10.0"
dependencies:
"@prisma/engines": 3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009
"@prisma/engines": 3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86
bin:
prisma: build/index.js
prisma2: build/index.js
checksum: 4070b61b09f45229680e1beeb14a95716353493032abaac9f6664bc60eba8279e165d446e8a12457a6d6a0e80d100c09228d00e7e1986e504fc8ceb9a16316f2
checksum: da693ebd69ec5f5a8132a86f1c6e300033a5ea869016e19364f514fdda6143f0d6236982a26b431313f06fecfaa2d1bd82eac3ebbf5b58082271f1a65c8a683d
languageName: node
linkType: hard
@ -1511,12 +1511,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "q2tg@workspace:."
dependencies:
"@prisma/client": ^3.9.2
"@prisma/client": latest
"@types/node": ^17.0.18
axios: ^0.26.0
log4js: ^6.4.1
oicq: ^2.2.0
prisma: ^3.9.2
prisma: latest
telegram: ^2.5.0
ts-node: ^10.5.0
tsc: ^2.0.4