import { App, Bot, segment, Session, sleep } from 'koishi'; import 'koishi-adapter-onebot'; import { Message as CQMessage, SenderInfo } from 'koishi-adapter-onebot'; import { parseCmd, view } from './command'; import { getLogger } from './loggers'; import { chainPromises } from './utils'; const logger = getLogger('qqbot'); type CQSession = Session & CQMessage & {sender: SenderInfo & {groupId?: number}}; interface IQQProps { access_token: string; host: string; port: number; bot_id: number; list(chat: IChat, args: string[], replyfn: (msg: string) => any): void; sub(chat: IChat, args: string[], replyfn: (msg: string) => any): void; unsub(chat: IChat, args: string[], replyfn: (msg: string) => any): void; } const cqUrlFix = (factory: segment.Factory) => (...args: Parameters) => factory(...args).replace(/(?<=\[CQ:.*)url=(?=(base64|file|https?):\/\/)/, 'file='); export const Message = { Image: cqUrlFix(segment.image), Video: cqUrlFix(segment.video), Voice: cqUrlFix(segment.audio), ellipseBase64: (msg: string) => msg.replace(/(?<=\[CQ:.*base64:\/\/).*?(,|\])/g, '...$1'), separateAttachment: (msg: string) => { const attachments: string[] = []; const message = msg.replace(/\[CQ:(video|record),.*?\]/g, code => { attachments.push(code); return ''; }); return {message, attachments}; }, }; export default class { private botInfo: IQQProps; private app: App; public bot: Bot; private messageQueues: {[key: string]: (() => Promise)[]} = {}; private tempSenders: {[key: number]: number} = {}; private next = (type: 'private' | 'group', id: string) => { const queue = this.messageQueues[`${type}:${id}`]; if (queue && queue.length) { queue[0]().then(() => { queue.shift(); if (!queue.length) delete this.messageQueues[`${type}:${id}`]; else this.next(type, id); }); } }; private enqueue = (type: 'private' | 'group', id: string, resolver: () => Promise) => { let wasEmpty = false; const queue = this.messageQueues[`${type}:${id}`] ||= (() => { wasEmpty = true; return []; })(); queue.push(() => sleep(200).then(resolver)); logger.debug(`no. of message currently queued for ${type}:${id}: ${queue.length}`); if (wasEmpty) this.next(type, id); }; private getChat = async (session: CQSession): Promise => { switch (session.subtype) { case 'private': if (session.sender.groupId) { // temp message const friendList = await session.bot.getFriendList(); if (!friendList.some(friendItem => friendItem.userId === session.userId)) { this.tempSenders[session.userId] = session.sender.groupId; return { chatID: { qq: Number(session.userId), group: Number(session.sender.groupId), toString: () => session.userId, }, chatType: ChatType.Temp, }; } } return { // already befriended chatID: Number(session.userId), chatType: ChatType.Private, }; case 'group': return { chatID: Number(session.groupId), chatType: ChatType.Group, }; } }; private sendToGroup = (groupID: string, message: string) => new Promise((resolve, reject) => { this.enqueue('group', groupID, () => this.bot.sendMessage(groupID, message).then(resolve).catch(reject)); }); private sendToUser = (userID: string, message: string) => new Promise((resolve, reject) => { this.enqueue('private', userID, () => this.bot.sendPrivateMessage(userID, message).then(resolve).catch(reject)); }); public sendTo = (subscriber: IChat, messageChain: string, noErrors = false) => Promise.all( (splitted => [splitted.message, ...splitted.attachments])( Message.separateAttachment(messageChain) ).map(msg => { switch (subscriber.chatType) { case 'group': return this.sendToGroup(subscriber.chatID.toString(), msg); case 'private': return this.sendToUser(subscriber.chatID.toString(), msg); case 'temp': // currently unable to open session, awaiting OneBot v12 return this.sendToUser(subscriber.chatID.qq.toString(), msg); } })) .then(response => { if (response === undefined) return; logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response: ${response}`); }) .catch(reason => { logger.error(Message.ellipseBase64(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`)); if (!noErrors) throw reason instanceof Error ? reason : Error(reason); }); private initBot = () => { this.app = new App({ type: 'onebot', server: `ws://${this.botInfo.host}:${this.botInfo.port}`, selfId: this.botInfo.bot_id.toString(), token: this.botInfo.access_token, axiosConfig: { maxContentLength: Infinity, }, processMessage: msg => msg.trim(), }); this.app.on('friend-request', async session => { const userString = `${session.username}(${session.userId})`; let groupId: string; let groupString: string; if (session.username in this.tempSenders) groupId = this.tempSenders[session.userId as unknown as number].toString(); logger.debug(`detected new friend request event: ${userString}`); return session.bot.getGroupList().then(groupList => { if (groupList.some(groupItem => { const test = groupItem.groupId === groupId; if (test) groupString = `${groupItem.groupName}(${groupId})`; return test; })) { return session.bot.handleFriendRequest(session.messageId, true) .then(() => { logger.info(`accepted friend request from ${userString} (from group ${groupString})`); }) .catch(error => { logger.error(`error accepting friend request from ${userString}, error: ${error}`); }); } chainPromises(groupList.map(groupItem => (done: boolean) => Promise.resolve(done || this.bot.getGroupMember(groupItem.groupId, session.userId).then(() => { groupString = `${groupItem.groupName}(${groupItem.groupId})`; return session.bot.handleFriendRequest(session.messageId, true) .then(() => { logger.info(`accepted friend request from ${userString} (found in group ${groupString})`); }) .catch(error => { logger.error(`error accepting friend request from ${userString}, error: ${error}`); }) .then(() => true); }).catch(() => false) ) )).then(done => { if (done) return; logger.warn(`received friend request from ${userString} (stranger)`); logger.warn('please manually accept this friend request'); }); }); }); this.app.on('group-request', async session => { const userString = `${session.username}(${session.userId})`; const groupString = `${session.groupName}(${session.groupId})`; logger.debug(`detected group invitation event: ${groupString}}`); return session.bot.getFriendList().then(friendList => { if (friendList.some(friendItem => friendItem.userId = session.userId)) { return session.bot.handleGroupRequest(session.messageId, true) .then(() => { logger.info(`accepted group invitation from ${userString} (friend)`); }) .catch(error => { logger.error(`error accepting group invitation from ${userString}, error: ${error}`); }); } logger.warn(`received group invitation from ${userString} (stranger)`); logger.warn('please manually accept this group invitation'); }); }); this.app.middleware(async (session: CQSession) => { const chat = await this.getChat(session); const cmdObj = parseCmd(session.content); const reply = async msg => { const userString = `${session.username}(${session.userId})`; return (chat.chatType === ChatType.Group ? this.sendToGroup : this.sendToUser)(chat.chatID.toString(), msg) .catch(error => { logger.error(`error replying to message from ${userString}, error: ${error}`); }); }; switch (cmdObj.cmd) { case 'igstory_view': case 'igstory_get': view(chat, cmdObj.args, reply); break; case 'igstory_sub': case 'igstory_subscribe': this.botInfo.sub(chat, cmdObj.args, reply); break; case 'igstory_unsub': case 'igstory_unsubscribe': this.botInfo.unsub(chat, cmdObj.args, reply); break; case 'ping': case 'igstory': this.botInfo.list(chat, cmdObj.args, reply); break; case 'help': if (cmdObj.args.length === 0) { reply(`Instagram 限时动态搬运机器人: /igstory - 查询当前聊天中的 Instagram Stories 动态订阅 /igstory_sub[scribe]〈链接|用户名〉- 订阅 Instagram Stories 搬运 /igstory_unsub[scribe]〈链接|用户名〉- 退订 Instagram Stories 搬运 /igstory_view〈链接|用户名〉[skip=〈跳过条数〉] [count=〈最大条数(默认:10)〉] - 查看该用户全部或指定范围的 Stories\ ${chat.chatType === ChatType.Temp ? '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : '' }`); } } }, true); }; private listen = async (logMsg = 'connecting to bot provider...'): Promise => { logger.warn(logMsg); try { await this.app.start(); } catch (err) { logger.error(`error connecting to bot provider at ${this.app.options.server}, will retry in 2.5s...`); await sleep(2500); await this.listen('retry connecting...'); } }; public connect = async () => { this.initBot(); await this.listen(); this.bot = this.app.getBot('onebot'); }; constructor(opt: IQQProps) { logger.warn(`Initialized koishi on ${opt.host}:${opt.port} with access_token ${opt.access_token}`); this.botInfo = opt; } }