diff --git a/.eslintrc.js b/.eslintrc.js index a627c9c..5b3e09c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,5 +4,8 @@ module.exports = { ], parserOptions: { project: './tsconfig.json' + }, + rules: { + '@typescript-eslint/no-non-null-assertion': 'off' } } diff --git a/src/assets/index.ts b/src/assets/index.ts new file mode 100644 index 0000000..f4fea77 --- /dev/null +++ b/src/assets/index.ts @@ -0,0 +1,22 @@ +import { readFile } from 'fs/promises' +import { getLogger } from 'log4js' +import { registerFont } from 'ultimate-text-to-image' + +export let defaultAvatar: Buffer +export let mask: Buffer + +export const loadAssets = async (): Promise => { + // 获取 logger + const logger = getLogger() + // 读取默认的头像文件 + defaultAvatar = await readFile('./src/assets/default_profile.png') + logger.debug('读取到了默认的头像文件') + // 读取遮罩文件 + mask = await readFile('./src/assets/gradient-mask.png') + logger.debug('读取到了遮罩文件') + // 读取字体文件 + registerFont('./src/assets/Alibaba-PuHuiTi-Regular.ttf', { + family: 'AliBabaPuHui' + }) + logger.debug('已注册字体文件') +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..a254434 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,80 @@ +import { InputFile } from 'grammy' +import { getLogger } from 'log4js' +import { defaultAvatar, mask } from './assets' +import { token } from './config' +import { Commnad, MyHandler } from './types' +import { getArgsFromMessageText, jimpToInputFile, makeItAQuote } from './utils' + +const logger = getLogger() + +// 处理 quote 命令 +const handleQuoteCommand: MyHandler = async (ctx) => { + const msg = ctx.message! + const { message_id: messageId } = msg + const { id: chatId } = ctx.chat + // 进行一些错误的判断 + const replyMsg = ctx.message?.reply_to_message + if (typeof replyMsg === 'undefined') { + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 没有获取到被回复的消息`) + await ctx.reply('你并没有回复任何人哦', { + reply_to_message_id: ctx.message?.message_id + }) + return + } + const sender = replyMsg.from + if (typeof sender === 'undefined') { + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 没有获取到被回复者`) + await ctx.reply('你回复的这条消息可能来自一个频道,获取不到作者呢', { + reply_to_message_id: ctx.message?.message_id + }) + return + } + if (typeof replyMsg.text === 'undefined') { + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 没有获取到被回复消息的内容`) + await ctx.reply('你回复的这条消息没有内容呢', { + reply_to_message_id: ctx.message?.message_id + }) + return + } + const { message_id: replyId } = await ctx.reply('正在进行处理,请稍等...') + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 已成功发送“处理中”提示信息`) + // 进行参数处理 + const args = getArgsFromMessageText(msg.text) + // 被回复者 id + const username = sender.username ?? 'no_name' + // 被回复的消息内容 + const text = replyMsg.text + // 被回复者的头像,这里取第一个 + const avatar = (await ctx.api.getUserProfilePhotos(sender.id)).photos + let quoted: InputFile | undefined + if (avatar.length === 0) { + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 被回复的消息作者是没有头像的`) + // 如果没有头像,使用默认头像进行图片的合成 + const res = await makeItAQuote(defaultAvatar, mask, username, text, args) + quoted = await jimpToInputFile(res) + } else { + // 有头像,使用头像组中的第一个进行图片的合成 + const photo = avatar[0][0] + // 尝试获取此文件 + const file = await ctx.api.getFile(photo.file_id) + if (typeof file.file_path === 'undefined') { + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 被回复的消息作者头像文件获取到了,但是没有路径`) + return + } + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 被回复的消息作者头像在 https://api.telegram.org/file/bot${token}/${file.file_path}`) + const res = await makeItAQuote(`https://api.telegram.org/file/bot${token}/${file.file_path}`, mask, username, text, args) + quoted = await jimpToInputFile(res) + } + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 图片处理完成`) + await ctx.replyWithPhoto(quoted, { + reply_to_message_id: messageId + }) + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 图片发送完成`) + await ctx.api.deleteMessage(chatId, replyId) + logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 提示信息删除完成`) +} + +// 导出命令集 +export const commands: Commnad[] = [ + new Commnad('quote', handleQuoteCommand) +] diff --git a/src/config.ts b/src/config.ts index fcfd87b..435fc6d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,9 @@ import axios from 'axios' import { getLogger } from 'log4js' +export let token: string +export let notifyChatId: string + /** * 从环境变量或参数中获取参数 * @param envVarName 环境变量名 @@ -55,3 +58,9 @@ export const getBotToken = async (): Promise => { logger.debug('token 是有效的') return token } + +// 初始化配置 +export const initConfig = async (): Promise => { + token = await getBotToken() + notifyChatId = await getEnvVarOrArg('NOTIFY_CHAT_ID', '--notify-chat-id=', '通知聊天 ID') +} diff --git a/src/main.ts b/src/main.ts index bca26db..916c731 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ -import { getBotToken, getEnvVarOrArg } from './config' +import { initConfig, notifyChatId, token } from './config' import log4js from 'log4js' -import { Bot, InputFile } from 'grammy' -import { readFile } from 'fs/promises' -import { getArgsFromMessageText, jimpToInputFile, makeItAQuote } from './utils' -import { registerFont } from 'ultimate-text-to-image' +import { Bot } from 'grammy' +import { loadAssets } from './assets' +import { useCommands } from './utils' +import { commands } from './commands' const main = async (): Promise => { // 配置 logger @@ -11,95 +11,16 @@ const main = async (): Promise => { appenders: { default: { type: 'console' } }, categories: { default: { appenders: ['default'], level: 'debug' } } }) - // 获取 token - const token = await getBotToken() - const notifyChatId = await getEnvVarOrArg('NOTIFY_CHAT_ID', '--notify=', '启动时通知到的对话 id') + // 初始化配置 + await initConfig() // 获取 logger const logger = log4js.getLogger() - // 读取默认的头像文件 - const defaultAvatar = await readFile('./src/assets/default_profile.png') - logger.debug('读取到了默认的头像文件') - // 读取遮罩文件 - const mask = await readFile('./src/assets/gradient-mask.png') - logger.debug('读取到了遮罩文件') - // 读取字体文件 - registerFont('./src/assets/Alibaba-PuHuiTi-Regular.ttf', { - family: 'AliBabaPuHui' - }) - logger.debug('已注册字体') + // 加载资源 + await loadAssets() // 构建一个 bot const bot = new Bot(token) - // 处理 quote 命令 - bot.command('quote', async (ctx) => { - const msg = ctx.message - if (typeof msg === 'undefined') { - logger.error('收到了指令,但是获取不到对话 id') - return - } - const { message_id: messageId } = msg - const { id: chatId } = ctx.chat - logger.debug(`收到了来自 ${chatId} 的 quote 指令,消息 id: ${messageId}`) - // 进行一些错误的判断 - const replyMsg = ctx.message?.reply_to_message - if (typeof replyMsg === 'undefined') { - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 没有获取到被回复的消息`) - await ctx.reply('你并没有回复任何人哦', { - reply_to_message_id: ctx.message?.message_id - }) - return - } - const sender = replyMsg.from - if (typeof sender === 'undefined') { - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 没有获取到被回复者`) - await ctx.reply('你回复的这条消息可能来自一个频道,获取不到作者呢', { - reply_to_message_id: ctx.message?.message_id - }) - return - } - if (typeof replyMsg.text === 'undefined') { - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 没有获取到被回复消息的内容`) - await ctx.reply('你回复的这条消息没有内容呢', { - reply_to_message_id: ctx.message?.message_id - }) - return - } - const { message_id: replyId } = await ctx.reply('正在进行处理,请稍等...') - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 已成功发送“处理中”提示信息`) - // 进行参数处理 - const args = getArgsFromMessageText(msg.text) - // 被回复者 id - const username = sender.username ?? 'no_name' - // 被回复的消息内容 - const text = replyMsg.text - // 被回复者的头像,这里取第一个 - const avatar = (await ctx.api.getUserProfilePhotos(sender.id)).photos - let quoted: InputFile | undefined - if (avatar.length === 0) { - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 被回复的消息作者是没有头像的`) - // 如果没有头像,使用默认头像进行图片的合成 - const res = await makeItAQuote(defaultAvatar, mask, username, text, args) - quoted = await jimpToInputFile(res) - } else { - // 有头像,使用头像组中的第一个进行图片的合成 - const photo = avatar[0][0] - // 尝试获取此文件 - const file = await ctx.api.getFile(photo.file_id) - if (typeof file.file_path === 'undefined') { - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 被回复的消息作者头像文件获取到了,但是没有路径`) - return - } - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 被回复的消息作者头像在 https://api.telegram.org/file/bot${token}/${file.file_path}`) - const res = await makeItAQuote(`https://api.telegram.org/file/bot${token}/${file.file_path}`, mask, username, text, args) - quoted = await jimpToInputFile(res) - } - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 图片处理完成`) - await ctx.replyWithPhoto(quoted, { - reply_to_message_id: messageId - }) - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 图片发送完成`) - await ctx.api.deleteMessage(chatId, replyId) - logger.debug(`[chat: ${chatId}, command: quote, msg: ${messageId}] 提示信息删除完成`) - }) + // 使用命令处理器集 + useCommands(bot, commands) // 错误处理 bot.catch(e => { const err = e as Error diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3c7c5a4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,50 @@ +import { Bot, Middleware } from 'grammy' +import { getLogger } from 'log4js' + +/** + * 命令参数 + * 从消息中获取 + */ +export interface ArgsFromMsg { + quoteMarkLeft: string // 自定义引号字符,左侧 + quoteMarkRight: string // 自定义引号字符,右侧 + gray: boolean // 是否把头像处理成灰色 +} + +// 获取 Bot 的 command 方法类型 +type GetCommandFn = Bot['command'] +export type BotCommandHandler = GetCommandFn extends (f: any, r: infer Rest) => any ? Rest : never +export type CommandCtx = BotCommandHandler extends Middleware ? Ctx : never +export type MyHandler = (ctx: CommandCtx) => Promise + +/** + * 命令 + */ +export class Commnad { + name: string // 命令名称 + fn: MyHandler // 命令处理函数 + + constructor (name: string, fn: MyHandler) { + this.name = name + this.fn = fn + } + + // 让 Bot 使用此命令 + use = (bot: Bot): void => { + bot.command(this.name, async (ctx) => { + const logger = getLogger() + try { + const msg = ctx.message + if (typeof msg === 'undefined') { + logger.info(`收到 ${this.name} 命令,但获取不到消息内容,对话 ID: ${ctx.chat.id}`) + return + } + logger.info(`收到 ${this.name} 命令,对话 ID: ${ctx.chat.id},消息 ID: ${msg.message_id}`) + // 尝试执行命令处理器 + await this.fn(ctx) + } catch (e) { + logger.error(`[${this.name} 命令,对话 ID: ${ctx.chat.id}] 处理出错:`, e) + } + }) + } +} diff --git a/src/utils.ts b/src/utils.ts index 0a37c6b..ab4b660 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,25 +1,16 @@ -import { InputFile } from 'grammy' +import { Bot, InputFile } from 'grammy' import Jimp from 'jimp' import { getLogger } from 'log4js' // import path from 'path' import { UltimateTextToImage, VerticalImage } from 'ultimate-text-to-image' - -/** - * 命令参数 - */ -export interface Args { - quoteMarkLeft: string // 自定义引号字符,左侧 - quoteMarkRight: string // 自定义引号字符,右侧 - gray: boolean // 是否把头像处理成灰色 -} - +import { ArgsFromMsg, Commnad } from './types' /** * 把任意头像、id、文字转成一张图片 * @param avatar 头像,buffer 类型 * @param id 会在前面加上一个 @ * @param text 图片正文 */ -export const makeItAQuote = async (avatarIn: Buffer | string, maskIn: Buffer, idIn: string, textIn: string, commandArgs: Args): Promise => { +export const makeItAQuote = async (avatarIn: Buffer | string, maskIn: Buffer, idIn: string, textIn: string, commandArgs: ArgsFromMsg): Promise => { const logger = getLogger() let avatar: Jimp // 这个类型判断是为了通过类型检查 @@ -50,7 +41,7 @@ export const makeItAQuote = async (avatarIn: Buffer | string, maskIn: Buffer, id * @param id 会在前面加上一个 @ * @param text 图片正文 */ -export const genTextWithIdPic = async (id: string, text: string, commandArgs: Args): Promise => { +export const genTextWithIdPic = async (id: string, text: string, commandArgs: ArgsFromMsg): Promise => { const logger = getLogger() logger.debug(`使用了引号 ${commandArgs.quoteMarkLeft}${commandArgs.quoteMarkRight}`) const image = new VerticalImage([ @@ -85,7 +76,7 @@ export const jimpToInputFile = async (src: Jimp): Promise => { } // 从消息文本中获取参数 -export const getArgsFromMessageText = ((): ((text: string) => Args) => { +export const getArgsFromMessageText = ((): ((text: string) => ArgsFromMsg) => { // 获取参数的方法,不需要外部可以访问 const getArgValue = (rawArgs: string[], prefix: string): string => { for (const arg0 of rawArgs) { @@ -104,9 +95,9 @@ export const getArgsFromMessageText = ((): ((text: string) => Args) => { } return false } - return (text: string): Args => { + return (text: string): ArgsFromMsg => { // 默认参数 - const defaultArgs: Args = { + const defaultArgs: ArgsFromMsg = { quoteMarkLeft: '"', quoteMarkRight: '"', gray: false @@ -122,3 +113,14 @@ export const getArgsFromMessageText = ((): ((text: string) => Args) => { return defaultArgs } })() + +/** + * 使用命令集 + * @param bot 机器人实例 + * @param commands + */ +export const useCommands = (bot: B, commands: Commnad[]): void => { + for (const command of commands) { + command.use(bot) + } +}