import { Inject , Injectable } from '@nestjs/common' ; import { ConfigType } from '@nestjs/config' ; import { Bot , Context , GrammyError , NextFunction } from 'grammy' ; import botConfig from '../config/bot.config' ; import { OptionalTextMessageIndex } from '../search/meili-search.service' ; import httpConfig from '../config/http.config' ; import { PhotoSize , Update } from '@grammyjs/types' ; import Debug = require ( 'debug' ) ; import fetch from 'node-fetch' ; import createHttpsProxyAgent = require ( 'https-proxy-agent' ) ; import { IndexService } from 'src/search/index.service' ; import { ImageIndexService } from 'src/search/image-index.service' ; const debug = Debug ( 'app:bot:bot.service' ) ; @ Injectable ( ) export class BotService { private bot : Bot ; private useWebhook : boolean ; private baseUrl : string ; private updateToken : string ; private agent : any ; public constructor ( @ Inject ( botConfig . KEY ) botCfg , @ Inject ( httpConfig . KEY ) httpCfg , private index , private imageIndex ) { this . useWebhook = botCfg . webhook ; this . baseUrl = `${ httpCfg . baseUrl }${ httpCfg . globalPrefix }` ; this . updateToken = botCfg . updateToken || botCfg . token ; if ( this . useWebhook && ! this . baseUrl ) { throw new Error ( 'You MUST set HTTP_BASE_URL if you have enabled TELEGRAM_WEBHOOK' ) ; } this . agent = getProxyAgent ( ) ; this . bot = new Bot ( botCfg . token , { client : { baseFetchConfig : { agent : this . agent , compress : true , } , } , } ) ; this . bot . on ( 'msg' , this . botOnMessage ) ; if ( botCfg . followEdit ) { this . bot . on ( 'edit' , this . botOnMessage ) ; } this . bot . command ( 'search' , this . botOnSearchCommand ) ; } public async start ( ) { if ( this . useWebhook ) { await this . bot . init ( ) ; return this . setWebhookUrl ( ) ; } else { await this . startPolling ( ) ; } } public async checkIfUserIsMember ( userId , chatId ) { const id = this . chatId2ApiId ( chatId ) ; const { status } = await this . bot . api . getChatMember ( id , userId ) ; return ( status === 'member' || status === 'creator' || status === 'administrator' ) ; } public chatId2ApiId ( chatId ) { return Number ( chatId . replace ( /^supergroup/ , '-100' ) . replace ( /^group/ , '-' ) ) ; } public async getProfilePhoto ( userId ) { const { photos } = await this . tryGetPhotos ( userId ) ; if ( photos . length < 1 || photos [ 0 ] . length < 1 ) { return null ; } const { file_id : fileId } = getSmallestPhoto ( photos [ 0 ] ) ; return await this . fetchFile ( fileId ) ; } private async fetchFile ( fileId ) { const { file_path : filePath } = await this . bot . api . getFile ( fileId ) ; const fileUrl = `https://api.telegram.org/file/bot${ this . bot . token }/${ filePath }` ; const res = await fetch ( fileUrl , { agent : this . agent } ) ; return res ; } private botOnMessage = async ( ctx , next ) => { await next ( ) ; const { msg , chat , from } = ctx ; if ( ! chat || ! msg || ! from ) { return ; } const realId = `${ chat . id }` . replace ( /^-100/ , '' ) ; const chatId = `${ chat . type }${ realId }` ; const searchable = msg ?. text || msg ?. caption ; const baseMessage = { id : `${ chatId }__${ msg . message_id }` , messageId : msg . message_id , chatId , fromId : `user${ from . id }` , fromName : joinNames ( from . first_name , from . last_name ) , text : searchable , raw : ctx . msg , from : 'bot' , timestamp : msg . date * 1000 , } ; if ( searchable ) { await this . index . queueMessage ( { ... baseMessage , text : searchable , } ) ; } if ( msg ?. photo ?. length ) { await this . handlePhoto ( msg . photo , baseMessage ) ; } } ; private async handlePhoto ( photoSize , baseMessage ) { const { file_id : fileId } = getLargestPhoto ( photoSize ) ; const res = await this . fetchFile ( fileId ) ; const buf = await res . buffer ( ) ; await this . imageIndex . indexImage ( [ buf ] , baseMessage ) ; } private botOnSearchCommand = async ( ctx ) => { const { msg , chat , from } = ctx ; if ( ! chat || ! msg || ! from ) { return ; } if ( chat . type === 'private' ) { await ctx . reply ( '？' ) ; return ; } const realId = `${ chat . id }` . replace ( /^-100/ , '' ) ; const chatId = `${ chat . type }${ realId }` ; const authUrl = new URL ( this . baseUrl + '/user/auth/viaTelegram' ) ; authUrl . searchParams . append ( 'chatId' , chatId ) ; try { await ctx . reply ( '🔍群内消息搜索试运行中，有问题请点我头像' , { reply_markup : { inline_keyboard : [ [ { text : '搜索' , login_url : { url : authUrl . toString ( ) , request_write_access : true , forward_text : `搜索「${ chat . title }」` , } , } , ] , ] , } , } ) ; } catch ( e ) { if ( e instanceof GrammyError && ( e . description . includes ( 'login URL is invalid' ) || e . description . includes ( 'BOT_DOMAIN_INVALID' ) ) ) { await ctx . reply ( `当前无法使用登录，请联系 @BotFather 在 Settings / Domain 处将域名修改为 <code>${ authUrl . hostname }</code>` , { parse_mode : 'HTML' } ) ; } else { throw e ; } } } ; private async tryGetPhotos ( userId ) { try { return await this . bot . api . getUserProfilePhotos ( userId , { limit : 1 , } ) ; } catch ( e ) { if ( e . message . includes ( 'user not found' ) ) { return { photos : [ ] } ; } else { throw e ; } } } private async setWebhookUrl ( ) { const url = `${ this . baseUrl }/bot/webhook/${ this . updateToken }/update` ; await this . bot . api . setWebhook ( url ) ; } private async startPolling ( ) { void this . bot . start ( ) ; } public handleUpdate ( update ) { return this . bot . handleUpdate ( update ) ; } public checkUpdateToken ( tokenInput ) { return tokenInput === this . updateToken ; } } function joinNames ( firstName , lastName ) { return [ firstName , lastName ] . filter ( ( x ) => x ) . join ( ' ' ) ; } function getProxyAgent ( ) { const proxy = process . env . https_proxy || process . env . http_proxy ; if ( ! proxy ) { return ; } return createHttpsProxyAgent ( proxy ) ; } function getSmallestPhoto ( photos ) { const sorted = photos . sort ( ( a , b ) => a . width - b . width ) ; return sorted [ 0 ] ; } function getLargestPhoto ( photos ) { const sorted = photos . sort ( ( a , b ) => b . width - a . width ) ; return sorted [ 0 ] ; }