上一篇文章 中我们讲了如何在 Vercel 上部署一个简单的 Telegram Bot,这里我们讲讲如何给 Bot 的消息添加可点击的按钮,以及通过回复 Bot 的消息来回复评论

Cusdis Webhook

首先我们先创建一个 api/hook/[chatId].ts 来处理 Cusdis 发送的 Webhook 请求

在 Vercel 中 [chatId].ts 可以对应路径 /api/hook/123456

这里隐去了一些类型定义,完整代码可以查看 api/hook/[chatId].ts

import { VercelRequest, VercelResponse } from '@vercel/node'
import { InlineKeyboard } from 'grammy'
import { bot } from '../../index'

// buildNewCommentMsg 构建发送给用户的通知消息
function buildNewCommentMsg(data: NewCommentHookData) {
    return `New comment on website <strong>${
        data.project_title
    }</strong> in page <strong>${data.page_title}</strong>:
<pre>
${data.content.replace(/<[^>]*>?/gm, "")}
</pre>
by: <strong>${data.by_nickname}</strong>`
}

export default async (req: VercelRequest, res: VercelResponse) => {
    if (req.method === 'POST') {
        const chatId = req.query['chatId'] as string
        const { type, data } = req.body as HookBody<NewCommentHookData>

        switch (type) {
            case 'new_comment': {
                const msg = buildNewCommentMsg(data)
                
                // 创建 InlineKeyboard,即附带在消息中的按钮,并且添加 callback_data
                const approveKeyboard = new InlineKeyboard().text('Approve', 'approve')

                let new_msg = await bot.api.sendMessage(chatId, msg, {
                    parse_mode: 'HTML',
                    reply_markup: approveKeyboard
                })
                break
            }
        }

        res.json({
            msg: "works"
        })
    }
}

然后我们需要在 Bot 中添加 /gethook 命令来获取给 Cusdis 使用的 Webhook 地址

// api/bot.ts
const { VERCEL_URL } = process.env

bot.command('gethook', async (ctx) => {
    let chanId = ctx.message.chat.id
    let hookUrl = `https://${VERCEL_URL}/api/hook/${chanId}`
    await ctx.reply(`Your Webhook URL:\n ${hookUrl}`)
})

使用 Redis

因为 InlineKeyboardButtoncallback_data 最高支持 64 字节的数据,而 Cusdis 的 approve_link 包含了 SHA256 的 token,所以我们没有办法直接将 approve_link 包在 Button 里面一起发给用户,所以这里用到 Upstash(一个 Serverless 的 Redis 服务)来保存

在 Vercel 中可以通过 Intergrations 来一键集成到项目中:Upstash in Vercel

添加库

pnpm i redis@next

添加 redis.ts

import { createClient } from 'redis'

const { REDIS_URL } = process.env

export const client = createClient({
    url: REDIS_URL
})

然后我们再将 approve_link 存入 Redis 中,方便后续 Bot 使用

// api/hook/[chatId].ts
import { client } from '../../redis'

...

let new_msg = await bot.api.sendMessage(chatId, msg, {
    parse_mode: 'HTML',
    reply_markup: approveKeyboard
})

// 设置 key 为聊天 id + 消息 id 这样能保证对于单独的用户来说 key 是唯一的
let key = chatId + new_msg.message_id.toString()
await client.connect()
// 设置过期时间为 3 天,和 approve_link 的 token 的过期时间一致
await client.set(key, data.approve_link, {
    EX: 259200
})
break

...

通过评论

在一个包含 callback_data 的按钮被点击后,Bot 会发送消息到 Webhok 上,我们可以使用 bot.callbackQuery() 来监听并处理这个消息

// api/bot.ts

// 这里的 approve 即为 button 的 callback_data
bot.callbackQuery('approve', async (ctx) => {
    let chatId = ctx.message.chat.id
    let key = chatId.toString() + ctx.message.message_id.toString()
    await client.connect()
    let link = await client.get(key)
    if (link) {
        // Cusdis 的 API 地址为 domain.com/api/open/approve?token=xxx
        // 而 approve_link 为 domain.com/open/approve,所以这里需要进行替换
        let api = link.replace('open', 'api/open')
        let res = await axios.post(api)
        if (res.status == 200) {
            // 成功则返回 'Successed' 消息
            await ctx.answerCallbackQuery({
                text: 'Successed'
            })
        } else {
            // 失败则返回 'Failed' 消息
            await ctx.answerCallbackQuery({
                text: 'Failed'
            })
        }
    } else {
        await ctx.reply('Token has expired')
    }
})

通过回复添加评论

监听消息,当用户发送的消息是回复消息时,通过回复消息去查找 approve_link,并发送带数据的 POST 请求即可添加评论到指定回复上

// api/bot.ts

bot.on('msg', async (ctx) => {
    // 判断该消息是否回复了某一条消息
    if (ctx.message.reply_to_message) {
        let reply_msg = ctx.message.reply_to_message
        let key = ctx.chat.id.toString() + reply_msg.message_id.toString()
        let replyContent = ctx.message.text

        await client.connect()
        // 查找 approve_link
        let link = await client.get(key)
        if (link) {
            let api = link.replace("open", "api/open")
            // 这里发送的数据为
            // {
            //      replyContent: 'your reply'
            // }
            let res = await axios.post(api, {
                replyContent
            })
            if (res.status == 200) {
                ctx.reply("Success to append comment")
            } else {
                ctx.reply("Fail to append comment")
            }
        } else {
            ctx.reply("Token has expired")
        }
    }
})

效果

使用 vercel --prod 部署到生产环境后,让我们来看看实际效果展示

cusdis-bot
cusdis-bot

你可以给自己的网站添加 Cusdis 评论服务,并启用 @CusdisxBot 来尝试这个机器人的功能