feat: 可以转发消息了,session 需要存储

This commit is contained in:
Clansty 2022-02-24 18:27:06 +08:00
parent 042f90d119
commit ceddf86453
No known key found for this signature in database
GPG Key ID: 05F8479BA63A8E92
11 changed files with 459 additions and 29 deletions

View File

@ -17,8 +17,13 @@
"dependencies": {
"@prisma/client": "latest",
"axios": "^0.26.0",
"file-type": "^17.1.1",
"log4js": "^6.4.1",
"nodejs-base64": "^2.0.0",
"oicq": "^2.2.0",
"telegram": "^2.5.0"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
}
}

View File

@ -14,7 +14,7 @@ model Message {
id Int @id @default(autoincrement())
qqRoomId Int
qqSenderId Int
time DateTime
time Int
brief String
seq Int
rand Int
@ -22,7 +22,7 @@ model Message {
tgChatId Int
tgMsgId Int
@@unique([qqRoomId, qqSenderId, seq, rand, pktnum])
@@unique([qqRoomId, qqSenderId, seq, rand, pktnum, time])
@@unique([tgChatId, tgMsgId])
}
@ -35,11 +35,11 @@ model ForwardPair {
model File {
id Int @id @default(autoincrement())
groupId Int
roomId Int
fileId String
info String
@@unique([groupId, fileId])
@@unique([roomId, fileId])
}
model FlashPhoto {

3
src/constants/exts.ts Normal file
View File

@ -0,0 +1,3 @@
export default {
images: ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']
}

View File

@ -0,0 +1,49 @@
import Telegram from '../client/Telegram';
import OicqClient from '../client/OicqClient';
import ForwardService from '../services/ForwardService';
import forwardPairs from '../providers/forwardPairs';
import { DiscussMessageEvent, Friend, Group, GroupMessageEvent, PrivateMessageEvent } from 'oicq';
import db from '../providers/db';
import helper from '../helpers/forwardHelper';
export default class ForwardController {
private readonly forwardService: ForwardService;
constructor(private readonly tgBot: Telegram,
private readonly tgUser: Telegram,
private readonly oicq: OicqClient) {
this.forwardService = new ForwardService(tgBot, oicq);
forwardPairs.init(oicq, tgBot)
.then(() => oicq.on('message', this.onQqMsg));
}
private onQqMsg = async (event: PrivateMessageEvent | GroupMessageEvent | DiscussMessageEvent) => {
let target: Friend | Group;
if (event.message_type === 'private') {
target = event.friend;
}
else if (event.message_type === 'group') {
target = event.group;
}
else return;
const pair = forwardPairs.find(target);
if (!pair) return;
const tgMsg = await this.forwardService.forwardFromQq(event, pair);
if (tgMsg) {
// 更新数据库
await db.message.create({
data: {
qqRoomId: helper.getRoomId(pair.qq),
qqSenderId: event.sender.user_id,
time: event.time,
brief: event.raw_message,
seq: event.seq,
rand: event.rand,
pktnum: event.pktnum,
tgChatId: Number(pair.tg.id),
tgMsgId: tgMsg.id,
},
});
}
};
}

View File

@ -0,0 +1,122 @@
import { fetchFile } from '../utils/urls';
import { CustomFile } from 'telegram/client/uploads';
import { Friend, Group } from 'oicq';
import { base64decode } from 'nodejs-base64';
import { getLogger } from 'log4js';
const log = getLogger('ForwardHelper');
export default {
async downloadToCustomFile(url: string) {
const { fileTypeFromBuffer } = await import('file-type');
const file = await fetchFile(url);
const type = await fileTypeFromBuffer(file);
return new CustomFile(`image.${type.ext}`, file.length, '', file);
},
hSize(size: number) {
const BYTE = 1024;
if (size < BYTE)
return size + 'B';
if (size < Math.pow(BYTE, 2))
return (size / BYTE).toFixed(1) + 'KB';
if (size < Math.pow(BYTE, 3))
return (size / Math.pow(BYTE, 2)).toFixed(1) + 'MB';
if (size < Math.pow(BYTE, 4))
return (size / Math.pow(BYTE, 3)).toFixed(1) + 'GB';
return (size / Math.pow(BYTE, 4)).toFixed(1) + 'TB';
},
htmlEscape: (text: string) =>
text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;'),
getRoomId(room: Friend | Group) {
if (room instanceof Friend) {
return room.user_id;
}
else {
return room.group_id;
}
},
processJson(json: string) {
const jsonObj = JSON.parse(json);
if (jsonObj.app === 'com.tencent.mannounce') {
try {
const title = base64decode(jsonObj.meta.mannounce.title);
const content = base64decode(jsonObj.meta.mannounce.text);
return title + '\n\n' + content;
}
catch (err) {
log.error('解析群公告时出错', err);
return '[群公告]';
}
}
let appurl: string;
const biliRegex = /(https?:\\?\/\\?\/b23\.tv\\?\/\w*)\??/;
const zhihuRegex = /(https?:\\?\/\\?\/\w*\.?zhihu\.com\\?\/[^?"=]*)\??/;
const biliRegex2 = /(https?:\\?\/\\?\/\w*\.?bilibili\.com\\?\/[^?"=]*)\??/;
const jsonLinkRegex = /{.*"app":"com.tencent.structmsg".*"jumpUrl":"(https?:\\?\/\\?\/[^",]*)".*}/;
const jsonAppLinkRegex = /"contentJumpUrl": ?"(https?:\\?\/\\?\/[^",]*)"/;
if (biliRegex.test(json))
appurl = json.match(biliRegex)[1].replace(/\\\//g, '/');
else if (biliRegex2.test(json))
appurl = json.match(biliRegex2)[1].replace(/\\\//g, '/');
else if (zhihuRegex.test(json))
appurl = json.match(zhihuRegex)[1].replace(/\\\//g, '/');
else if (jsonLinkRegex.test(json))
appurl = json.match(jsonLinkRegex)[1].replace(/\\\//g, '/');
else if (jsonAppLinkRegex.test(json))
appurl = json.match(jsonAppLinkRegex)[1].replace(/\\\//g, '/');
if (appurl) {
return appurl;
}
else {
// TODO 记录无法解析的 JSON
return '[JSON]';
}
},
processXml(xml: string):
{ type: 'forward', resId: string } | { type: 'text', text: string } | { type: 'image', md5: string } {
const urlRegex = /url="([^"]+)"/;
const md5ImageRegex = /image md5="([A-F\d]{32})"/;
let text: string;
if (urlRegex.test(xml))
text = xml.match(urlRegex)[1].replace(/\\\//g, '/');
if (xml.includes('action="viewMultiMsg"')) {
text = '[Forward multiple messages]';
const resIdRegex = /m_resid="([\w+=/]+)"/;
if (resIdRegex.test(xml)) {
const resId = xml.match(resIdRegex)![1];
return {
type: 'forward',
resId,
};
}
}
else if (text) {
text = text.replace(/&amp;/g, '&');
return {
type: 'text',
text,
};
}
else if (md5ImageRegex.test(xml)) {
const imgMd5 = xml.match(md5ImageRegex)![1];
return {
type: 'image',
md5: imgMd5,
};
}
else {
return {
type: 'text',
text: '[XML]',
};
}
},
};

View File

@ -1,9 +1,10 @@
import Telegram from './client/Telegram';
import { config } from './providers/userConfig';
import { getLogger, configure } from 'log4js';
import { configure, getLogger } from 'log4js';
import SetupController from './controllers/SetupController';
import OicqClient from './client/OicqClient';
import ConfigController from './controllers/ConfigController';
import ForwardController from './controllers/ForwardController';
(async () => {
configure({
@ -49,4 +50,5 @@ import ConfigController from './controllers/ConfigController';
log.debug('OICQ 登录完成');
}
new ConfigController(tgBot, tgUser, oicq);
new ForwardController(tgBot, tgUser, oicq);
})();

View File

@ -5,7 +5,7 @@ import OicqClient from '../client/OicqClient';
import Telegram from '../client/Telegram';
import db from './db';
type Pair = {
export type Pair = {
qq: Friend | Group;
tg: TelegramChat;
}
@ -34,13 +34,17 @@ class ForwardPairsInternal {
});
}
public find(target: Friend | Group | TelegramChat | Api.Chat) {
public find(target: Friend | Group | TelegramChat | Api.Chat | number) {
if (target instanceof Friend) {
return this.pairs.find(e => e.qq instanceof Friend && e.qq.user_id === target.user_id);
}
else if (target instanceof Group) {
return this.pairs.find(e => e.qq instanceof Group && e.qq.group_id === target.group_id);
}
else if (typeof target === 'number') {
return this.pairs.find(e => e.qq instanceof Friend && e.qq.user_id === target ||
e.qq instanceof Group && e.qq.group_id === -target);
}
else {
return this.pairs.find(e => e.tg.id.eq(target.id));
}

View File

@ -16,14 +16,14 @@ import forwardPairs from '../providers/forwardPairs';
const DEFAULT_FILTER_ID = 114; // 514
export default class ConfigService {
private owner: TelegramChat;
private owner: Promise<TelegramChat>;
private log = getLogger('ConfigService');
private filter: Api.DialogFilter;
constructor(private readonly tgBot: Telegram,
private readonly tgUser: Telegram,
private readonly oicq: OicqClient) {
tgBot.getChat(config.owner).then(e => this.owner = e);
this.owner = tgBot.getChat(config.owner);
}
private getAssociateLink(roomId: number) {
@ -31,12 +31,11 @@ export default class ConfigService {
}
public async configCommands() {
// 这个在一初始化好就要调用,所以不能直接用 this.owner
await this.tgBot.setCommands([], new Api.BotCommandScopeUsers());
await this.tgBot.setCommands(
config.workMode === 'personal' ? commands.personalPrivateCommands : commands.groupPrivateCommands,
new Api.BotCommandScopePeer({
peer: (await this.tgBot.getChat(config.owner)).inputPeer,
peer: (await this.owner).inputPeer,
}),
);
}
@ -56,7 +55,7 @@ export default class ConfigService {
`${e.group_name} (${e.group_id})`,
this.getAssociateLink(-e.group_id),
)]);
await this.owner.createPaginatedInlineSelector(
await (await this.owner).createPaginatedInlineSelector(
'选择 QQ 群组' + (config.workMode === 'group' ? '\n然后选择在 TG 中的群组' : ''), buttons);
}
@ -75,7 +74,7 @@ export default class ConfigService {
return 1;
}
});
await this.owner.createPaginatedInlineSelector('选择分组', classes.map(e => [
await (await this.owner).createPaginatedInlineSelector('选择分组', classes.map(e => [
Button.inline(e[1], this.tgBot.registerCallback(
() => this.openFriendSelection(friends.filter(f => f.class_id === e[0]), e[1]),
)),
@ -83,7 +82,7 @@ export default class ConfigService {
}
private async openFriendSelection(clazz: FriendInfo[], name: string) {
await this.owner.createPaginatedInlineSelector(`选择 QQ 好友\n分组${name}`, clazz.map(e => [
await (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, e.remark || e.nickname),
)),
@ -101,7 +100,7 @@ export default class ConfigService {
this.log.error(`加载 ${group.group_name} (${gin}) 的头像失败`, e);
}
const message = `${group.group_name}\n${group.group_id}\n${group.member_count} 名成员`;
await this.owner.sendMessage({
await (await this.owner).sendMessage({
message,
file: avatar ? new CustomFile('avatar.png', avatar.length, '', avatar) : undefined,
buttons: Button.url('关联 Telegram 群组', this.getAssociateLink(-group.group_id)),
@ -125,18 +124,18 @@ export default class ConfigService {
let isFinish = false;
try {
// 状态信息
const status = await this.owner.sendMessage('正在创建 Telegram 群…');
const status = await (await this.owner).sendMessage('正在创建 Telegram 群…');
// 创建群聊,拿到的是 user 的 chat
const chat = await this.tgUser.createChat({
title,
users: [this.tgBot.me.id],
users: [this.tgBot.me.username],
});
const chatForBot = await this.tgBot.getChat(chat.id);
// 设置管理员
await status.edit({ text: '正在设置管理员…' });
await chat.editAdmin(this.tgBot.me.username, true);
const chatForBot = await this.tgBot.getChat(chat.id);
// 关联写入数据库
await status.edit({ text: '正在写数据库…' });
@ -174,7 +173,7 @@ export default class ConfigService {
}
catch (e) {
this.log.error('创建群组并关联失败', e);
await this.owner.sendMessage(`创建群组并关联${isFinish ? '成功了但没完全成功' : '失败'}\n<code>${e}</code>`);
await (await this.owner).sendMessage(`创建群组并关联${isFinish ? '成功了但没完全成功' : '失败'}\n<code>${e}</code>`);
}
}
@ -190,14 +189,13 @@ export default class ConfigService {
catch (e) {
message = `错误:<code>${e}</code>`;
}
await this.owner.sendMessage({ message });
await (await this.owner).sendMessage({ message });
}
// 创建 QQ 群组的文件夹
public async setupFilter() {
const result = await this.tgUser.getDialogFilters();
this.filter = result.find(e => e.id === DEFAULT_FILTER_ID);
this.log.debug(this.filter);
if (!this.filter) {
this.log.info('创建 TG 文件夹');
// 要自己计算新的 id随意 id 也是可以的
@ -222,13 +220,13 @@ export default class ConfigService {
if (!isSuccess) {
this.filter = null;
this.log.error(errorText);
await this.owner.sendMessage(errorText);
await (await this.owner).sendMessage(errorText);
}
}
catch (e) {
this.filter = null;
this.log.error(errorText, e);
await this.owner.sendMessage(errorText + `\n<code>${e}</code>`);
await (await this.owner).sendMessage(errorText + `\n<code>${e}</code>`);
}
}
}

View File

@ -0,0 +1,183 @@
import Telegram from '../client/Telegram';
import OicqClient from '../client/OicqClient';
import { GroupMessageEvent, PrivateMessageEvent } from 'oicq';
import { Pair } from '../providers/forwardPairs';
import { fetchFile, getBigFaceUrl, getImageUrlByMd5 } from '../utils/urls';
import { FileLike, MarkupLike } from 'telegram/define';
import { CustomFile } from 'telegram/client/uploads';
import { getLogger } from 'log4js';
import path from 'path';
import exts from '../constants/exts';
import helper from '../helpers/forwardHelper';
import db from '../providers/db';
import { Button } from 'telegram/tl/custom/button';
import { SendMessageParams } from 'telegram/client/messages';
// noinspection FallThroughInSwitchStatementJS
export default class ForwardService {
private log = getLogger('ForwardService');
constructor(private readonly tgBot: Telegram,
private readonly oicq: OicqClient) {
}
public async forwardFromQq(event: PrivateMessageEvent | GroupMessageEvent, pair: Pair) {
try {
let message = '', files: FileLike[] = [], button: MarkupLike, replyTo = 0;
let messageHeader = '';
if (event.message_type === 'group') {
// 产生头部,这和工作模式没有关系
const sender = event.sender.card || event.sender.nickname;
messageHeader = `<b>${helper.htmlEscape(sender)}</b>: `;
}
for (const elem of event.message) {
let url: string;
switch (elem.type) {
case 'text': {
message += elem.text;
break;
}
case 'at': {
if (event.source?.user_id === elem.qq)
break;
}
case 'face':
case 'sface': {
message += `[${elem.text}]`;
break;
}
case 'bface': {
const file = await fetchFile(getBigFaceUrl(elem.file));
files.push(new CustomFile('face.png', file.length, '', file));
break;
}
case 'video':
// 先获取 URL要传给下面
url = await pair.qq.getVideoUrl(elem.fid, elem.md5);
case 'image':
case 'flash':
// TODO 闪照单独处理
if ('url' in elem)
url = elem.url;
try {
files.push(await helper.downloadToCustomFile(url));
}
catch (e) {
this.log.error('下载媒体失败', e);
// 下载失败让 Telegram 服务器下载
files.push(url);
}
break;
case 'file': {
const extName = path.extname(elem.name);
if (exts.images.includes(extName.toLowerCase())) {
// 是图片
const url = await pair.qq.getFileUrl(elem.fid);
try {
files.push(await helper.downloadToCustomFile(url));
}
catch (e) {
this.log.error('下载媒体失败', e);
// 下载失败让 Telegram 服务器下载
files.push(url);
}
}
else {
message = `文件: ${elem.name}\n` +
`大小: ${helper.hSize(elem.size)}`;
const dbEntry = await db.file.create({
data: { fileId: elem.fid, roomId: helper.getRoomId(pair.qq), info: message },
});
button = Button.url('⏬ 获取下载地址',
`https://t.me/${this.tgBot.me.username}?start=file-${dbEntry.id}`);
}
}
case 'record': {
// TODO
message = '[语音]';
break;
}
case 'share': {
message = elem.url;
break;
}
case 'json': {
message = helper.processJson(elem.data);
break;
}
case 'xml': {
const result = helper.processXml(elem.data);
switch (result.type) {
case 'text':
message = result.text;
break;
case 'image':
try {
files.push(await helper.downloadToCustomFile(getImageUrlByMd5(result.md5)));
}
catch (e) {
this.log.error('下载媒体失败', e);
// 下载失败让 Telegram 服务器下载
files.push(getImageUrlByMd5(result.md5));
}
break;
case 'forward':
// TODO 详细展开
message = '[转发多条消息]';
break;
}
break;
}
case 'rps':
case 'dice':
message = `[${elem.type === 'rps' ? '猜拳' : '骰子'}] ${elem.id}`;
break;
case 'poke':
message = `[戳一戳] ${elem.text}`;
break;
case 'location':
message = `[位置] ${elem.name}\n${elem.address}`;
break;
}
}
message = helper.htmlEscape(message.trim());
message = messageHeader + (message && messageHeader ? '\n' : '') + message;
// 处理回复
if (event.source) {
try {
const quote = await db.message.findFirst({
where: {
qqRoomId: helper.getRoomId(pair.qq),
seq: event.source.seq,
rand: event.source.rand,
},
});
if (quote) {
replyTo = quote.tgMsgId;
}
}
catch (e) {
this.log.error('查找回复消息失败', e);
}
}
// 发送消息
const messageToSend: SendMessageParams = {};
message && (messageToSend.message = message);
if (files.length === 1) {
messageToSend.file = files[0];
}
else if (files.length) {
messageToSend.file = files;
}
button && (messageToSend.buttons = button);
replyTo && (messageToSend.replyTo = replyTo);
return await pair.tg.sendMessage(messageToSend);
}
catch (e) {
this.log.error('从 QQ 到 TG 到消息转发失败', e);
}
}
}

View File

@ -12,15 +12,16 @@ export function getImageUrlByMd5(md5: string) {
}
export function getBigFaceUrl(file: string) {
return `https://gxh.vip.qq.com/club/item/parcel/item/${file.substring(
0,
2,
)}/${file.substring(0, 32)}/300x300.png`;
return `https://gxh.vip.qq.com/club/item/parcel/item/${file.substring(0, 2)}/${file.substring(0, 32)}/300x300.png`;
}
export async function getAvatar(roomId: number): Promise<Buffer> {
const res = await axios.get(getAvatarUrl(roomId), {
export async function fetchFile(url: string): Promise<Buffer> {
const res = await axios.get(url, {
responseType: 'arraybuffer',
});
return res.data;
}
export function getAvatar(roomId: number) {
return fetchFile(getAvatarUrl(roomId));
}

View File

@ -83,6 +83,13 @@ __metadata:
languageName: node
linkType: hard
"@tokenizer/token@npm:^0.3.0":
version: 0.3.0
resolution: "@tokenizer/token@npm:0.3.0"
checksum: 1d575d02d2a9f0c5a4ca5180635ebd2ad59e0f18b42a65f3d04844148b49b3db35cf00b6012a1af2d59c2ab3caca59451c5689f747ba8667ee586ad717ee58e1
languageName: node
linkType: hard
"@tootallnate/once@npm:1":
version: 1.1.2
resolution: "@tootallnate/once@npm:1.1.2"
@ -613,6 +620,17 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^17.1.1":
version: 17.1.1
resolution: "file-type@npm:17.1.1"
dependencies:
readable-web-to-node-stream: ^3.0.2
strtok3: ^7.0.0-alpha.7
token-types: ^5.0.0-alpha.2
checksum: fb52169bbb8f4d179ee372f8a8792880c8702f96cf2f9e55094baa6c7e81bed0be347a9f3dd849632597fe0e4c9bd6cc8d60b68292730dc52919844bbb3f2dab
languageName: node
linkType: hard
"flatted@npm:^3.2.4":
version: 3.2.5
resolution: "flatted@npm:3.2.5"
@ -1343,6 +1361,13 @@ __metadata:
languageName: node
linkType: hard
"nodejs-base64@npm:^2.0.0":
version: 2.0.0
resolution: "nodejs-base64@npm:2.0.0"
checksum: 1e9fc06396bd77caba14f93eec40751f00c4570346ebbaf9ded746f8309d0adc8a18bf62c55a9e0288854df7eb61ee9942b316548fd1e185de59213ce4f0fbde
languageName: node
linkType: hard
"nopt@npm:^5.0.0":
version: 5.0.0
resolution: "nopt@npm:5.0.0"
@ -1453,6 +1478,13 @@ __metadata:
languageName: node
linkType: hard
"peek-readable@npm:^5.0.0-alpha.5":
version: 5.0.0-alpha.5
resolution: "peek-readable@npm:5.0.0-alpha.5"
checksum: cab949ed457dac95ae191dd412c6a0ba05e8db4842fd51704ccf2c8c16d6f3ceeefc997e8caea584a0395f229e468c0203a38a8d0ec68cfef8bacc157a006dcb
languageName: node
linkType: hard
"pngjs@npm:^6.0.0":
version: 6.0.0
resolution: "pngjs@npm:6.0.0"
@ -1514,7 +1546,9 @@ __metadata:
"@prisma/client": latest
"@types/node": ^17.0.18
axios: ^0.26.0
file-type: ^17.1.1
log4js: ^6.4.1
nodejs-base64: ^2.0.0
oicq: ^2.2.0
prisma: latest
telegram: ^2.5.0
@ -1544,6 +1578,15 @@ __metadata:
languageName: node
linkType: hard
"readable-web-to-node-stream@npm:^3.0.2":
version: 3.0.2
resolution: "readable-web-to-node-stream@npm:3.0.2"
dependencies:
readable-stream: ^3.6.0
checksum: 8c56cc62c68513425ddfa721954875b382768f83fa20e6b31e365ee00cbe7a3d6296f66f7f1107b16cd3416d33aa9f1680475376400d62a081a88f81f0ea7f9c
languageName: node
linkType: hard
"retry@npm:^0.12.0":
version: 0.12.0
resolution: "retry@npm:0.12.0"
@ -1746,6 +1789,16 @@ __metadata:
languageName: node
linkType: hard
"strtok3@npm:^7.0.0-alpha.7":
version: 7.0.0-alpha.8
resolution: "strtok3@npm:7.0.0-alpha.8"
dependencies:
"@tokenizer/token": ^0.3.0
peek-readable: ^5.0.0-alpha.5
checksum: 00e5c9ed0c5de537839cf443d5628f0ae88d2956ca1fdcbd45cd97372045d7179a40ec99f6d06b02c59ec2141e362142ad0a87c59506d401dbd3bd1ee242abaa
languageName: node
linkType: hard
"tar@npm:^6.0.2, tar@npm:^6.1.2":
version: 6.1.11
resolution: "tar@npm:6.1.11"
@ -1792,6 +1845,16 @@ __metadata:
languageName: node
linkType: hard
"token-types@npm:^5.0.0-alpha.2":
version: 5.0.0-alpha.2
resolution: "token-types@npm:5.0.0-alpha.2"
dependencies:
"@tokenizer/token": ^0.3.0
ieee754: ^1.2.1
checksum: ee23eeed6f383b1072d99781d62fc7840f1296a96d47e636e36fca757debd7eb4274d31fcd2d56997606eede00b12b1e61a64610fe0ed7807d6b1c4dcf5ccc6b
languageName: node
linkType: hard
"ts-custom-error@npm:^3.2.0":
version: 3.2.0
resolution: "ts-custom-error@npm:3.2.0"