Telegram Bot 图片审核
文档
https://core.telegram.org/bots/api
例子(图片审核)
- 再送审组发图片
- 图片会转发到审批组
- 不合格,发回送审组,表示不合格
- 合格,同时发送到送审组,合格组
graph LR
a[送审组]-->b[审批组]
b-->|未通过|a
b-->|合格|c[合格组]
b-->|合格|a
创建机器人
- telegram 添加 BotFather
- 输入命令
/newbot
- 输入机器人名称
- 输入机器人用户名,以 bot 结尾
- 拿到 token
- 设置 /setprivacy,开放机器人接收消息,不然机器人默认只接收 / 开头的消息
设置 webhook
// 设置 webhook,将 bot 请求发送到指定的域名,如 https://example.com
https://api.telegram.org/bot[token]/setWebhook?url=https://example.com
开发技巧
注册 ngrok https://dashboard.ngrok.com/
下载 ngrok
# 设置 token,登录 ngrok 就能看到 token ngrok config add-authtoken xxx
# 内网穿透 ngrok http http://localhost:4040
会拿到一个临时的 https 域名
# 设置 webhook https://api.telegram.org/bot[token]/setWebhook?url=[临时域名]/[api]
这样就可以在本地测试 telegram bot 的请求,会方便很多,容易写错参数:)
创建项目
配置
// package.json
{
// ...
"main": "code/index.js",
"source": "src/index.ts",
"scripts": {
"dev": "npx nodemon ./dist/index.js",
"watch": "parcel watch",
"build": "parcel build"
},
"devDependencies": {
"@antfu/eslint-config": "^2.13.2",
"@parcel/config-default": "^2.12.0",
"@parcel/transformer-typescript-tsc": "^2.12.0",
"@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.12.7",
"@types/uuid": "^9.0.8",
"eslint": "^9.0.0",
"nodemon": "^3.1.0",
"parcel": "^2.12.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.4"
},
"dependencies": {
"@swc/helpers": "^0.5.8",
"axios": "^1.6.8",
"express": "^4.19.2",
"fs-extra": "^11.2.0",
"pg": "^8.11.5",
"reflect-metadata": "^0.2.2",
"tesseract.js": "^5.0.5",
"typeorm": "^0.3.20",
"uuid": "^9.0.1"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["esnext", "es5", "es6", "dom"],
"skipLibCheck": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
// .eslintrc
{
"extends": "@antfu",
"rules": {
"@typescript-eslint/no-use-before-define": [
"error",
{"functions": false, "classes": true, "variables": false}
]
}
}
用 parcel 打包,用到了 typeorm,需要配置一下 https://parceljs.org/languages/typescript/#tsc
// .parcelrc
{
"extends": "@parcel/config-default",
"transformers": {
"*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"]
}
}
// config/index.ts
export const MY_TOKEN = "xxx:xxx"
export const API_DOMAIN = "https://api.telegram.org"
export const BASE_URL = API_DOMAIN + "/bot" + MY_TOKEN;
export const PHOTO_BASE_PATH = `${__dirname}/photo`;
入口文件
// index.ts
import "reflect-metadata"
import express from 'express'
import { appDataSource } from './db';
import { handleApi, handlerBot } from './controller';
import path from "path";
const app = express();
// 初始化数据库
appDataSource.initialize();
const port = 4040;
// 中间件解析请求体
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// GET请求路由
app.get('/', async (req, res) => {
res.send(await handlerBot(req));
});
// POST请求路由
app.post('/', async (req, res) => {
// const requestBody = req.body;
// console.log(requestBody)
res.json(await handlerBot(req));
});
// 404错误处理
app.use((req, res) => {
res.status(404).send('404 - 找不到页面');
});
// 启动服务器
app.listen(port, () => {
console.log(`服务器正在监听端口 ${port}`);
});
工具类
axios 封装
// common/axios.ts
import axios from 'axios'
import { API_DOMAIN, BASE_URL, MY_TOKEN, PHOTO_BASE_PATH } from '../config'
import fse from 'fs-extra'
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
// 获取文件拓展
const getImageExtension = (url: string) => {
const extname = path.extname(url);
return extname.toLowerCase();
}
// 设置基础 url
const getAxiosInstance = () => {
return axios.create({
headers: {
'Content-Type': 'application/json', // 设置 Content-Type 为 application/json
},
baseURL: BASE_URL,
});
}
// 品种文件 url
export const getFileUrl = (filePath: string) => {
return `${API_DOMAIN}/file/bot${MY_TOKEN}/${filePath}`
}
// 下载图片
const downloadImage = async (fileUrl: string) => {
const response = await axios({
method: 'GET',
url: fileUrl,
responseType: 'arraybuffer',
});
const imageName = `${uuidv4().toString()}_${new Date().getTime()}`
const extension = getImageExtension(fileUrl);
const imagePath = `${PHOTO_BASE_PATH}/${imageName}${extension}`;
console.log(imagePath)
fse.ensureDirSync(path.dirname(imagePath));
const imageBuffer = Buffer.from(response.data);
await fse.writeFile(imagePath, imageBuffer);
return `/${imageName}${extension}`
}
export default {
getAxiosInstance,
getFileUrl,
downloadImage,
}
telegram api
// common/telegram.ts
import { MessageObjType } from "../types";
import axios from "./axios";
const axiosInstance = axios.getAxiosInstance()
/**
* 发送消息
* @param chatId 群组 chatId
* @param messageText 发送内容
* @returns
*/
export const sendMessageByChatId = async (chatId: string, messageText: string) => {
const params: Record<string, any> = {
chat_id: chatId,
text: messageText
}
return await axiosInstance.get("sendMessage", {
params
})
}
/**
* 发送消息
* @param messageObj 消息对象
* @param messageText 发送内容
* @returns
*/
export const sendMessage = async (messageObj: MessageObjType, messageText: string) => {
const params: Record<string, any> = {
chat_id: messageObj.chat.id,
text: messageText
}
return await axiosInstance.get("sendMessage", {
params
})
}
/**
* 发送图片
* @param chatId 群组 chatId
* @param photo 图片 url
* @param extend 拓展
* @returns
*/
export const sendPhoto = async (chatId: string, photo: string, extend?: {
caption?: string // 附加文本内容
replyMarkup?: any // 按钮类
}) => {
const params: Record<string, any> = {
chat_id: chatId,
photo,
}
if (extend?.caption) {
params.caption = extend.caption
}
if (extend?.replyMarkup) {
params.reply_markup = JSON.stringify(extend.replyMarkup)
}
return await axiosInstance.get("sendPhoto", {
params
})
}
/**
* 获取文件 url
* @param fileId 文件 id
* @returns
*/
export const getFilePath = async (fileId: string) => {
return await axiosInstance.get("getFile", {
params: {
file_id: fileId,
}
})
}
/**
* 获取消息
* @param chatId 群组 chatId
* @param messageId 消息 id
* @returns
*/
export const getMessage = async (chatId: string, messageId: number) => {
return await axiosInstance.get("getMessage", {
params: {
chat_id: chatId,
message_id: messageId,
}
})
}
/**
* 转发消息
* @param chatId 目标 chatId
* @param fromChatId 来自 chartId
* @param messageId 转发消息内容
* @returns
*/
export const forwardMessage = async (chatId: string, fromChatId: string, messageId: number) => {
return await axiosInstance.get("forwardMessage", {
params: {
chat_id: chatId,
from_chat_id: fromChatId,
message_id: messageId,
}
})
}
db
实体
// 基类
import { Entity, CreateDateColumn, DeleteDateColumn } from 'typeorm';
@Entity()
class BaseEntity {
@CreateDateColumn()
createTime: Date;
@DeleteDateColumn({ nullable: true })
deleteDate: Date;
}
export default BaseEntity;
// 群组
import BaseEntity from './base';
import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToOne,
JoinColumn,
} from 'typeorm';
@Entity()
class Group extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
chatId: string;
@Column()
code: string;
@Column()
name: string;
@OneToOne(() => Group)
@JoinColumn({ name: 'auditGroupId' })
auditGroup: Group;
@OneToOne(() => Group)
@JoinColumn({ name: 'nextGroupId' })
nextGroup: Group;
}
export default Group;
// 审批记录
import BaseEntity from './base';
import {
Entity,
Column,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
class Record extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
code: string;
@Column()
fileId: string;
@Column()
chatId: string;
@Column()
nextChatId: string;
@Column()
type: number;
}
export default Record;
// 文件
import BaseEntity from './base';
import {
Entity,
Column,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
class Files extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
code: string;
@Column()
path: string;
@Column()
url: string;
}
export default Files;
配置类
// db/index.ts
import { DataSource } from "typeorm";
import Group from "./entity/group";
import Record from "./entity/record";
import Files from "./entity/files";
export const appDataSource = new DataSource({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'xxx',
database: 'xxx',
synchronize: true,
entities: [Group, Record, Files],
});
Controller
// controller/index.ts
import type { Request, Response } from 'express'
import { handleCallback, handleMessage } from '../service';
export const handlerBot = async (req: Request) => {
const { body } = req
if (body) {
if (body.message) {
// 处理消息回调
await handleMessage(body.message)
}
if (body.callback_query) {
// 处理 callback
await handleCallback(body.callback_query)
}
}
return;
}
Service
主类
import { getFilePath, getFileUrl, sendMessage, sendMessageByChatId, sendPhoto } from "../common";
import { CallbackQueryType, MessageObjType } from "../types";
import { createMyFile, getFileByCode } from "./files";
import { getGroupByChatId, handleSetApproval, handleSetNext, handleSetUp } from "./group";
import fse from "fs-extra";
import { v4 as uuidv4 } from 'uuid';
import myAxios from "../common/axios";
import { PHOTO_BASE_PATH } from "../config";
import { approvalRecord, createRecord, getRecordByCode } from "./record";
export const handleCallback = async (callbackObj: CallbackQueryType) => {
const messageText = callbackObj.data || ""
if (messageText.charAt(0) === "/") {
const arr = messageText.split(" ")
const command = arr[0].slice(1).toLowerCase()
const chatId = arr[1]
const value = arr[2]
switch (command) {
case "approval":
try {
const arr = value.split("$")
const code = arr[0]
const type = parseInt(arr[1], 10)
const record = await getRecordByCode(code)
if (record) {
if (record.type > 0) {
await sendMessageByChatId(chatId, "审批记录已处理")
} else {
await approvalRecord(record, type)
if (type === 1) {
await sendPhoto(record.chatId, record.fileId, {
caption: '审批未通过'
})
}
if (type === 2) {
await sendPhoto(record.chatId, record.fileId, {
caption: '审批成功'
})
await sendPhoto(record.nextChatId, record.fileId, {
caption: '审批成功'
})
}
await sendMessageByChatId(chatId, "审批成功")
}
} else {
await sendMessageByChatId(chatId, "审批记录不存在")
}
} catch (ex) {
const e = ex as unknown as { message: string }
await sendMessageByChatId(chatId, e.message)
}
break;
}
}
}
// 处理消息
export const handleMessage = async (messageObj: MessageObjType) => {
const messageText = messageObj.text || ""
let chatId = `${messageObj.chat.id}`
if (messageText.charAt(0) === "/") {
const arr = messageText.split(" ")
const command = arr[0].slice(1).toLowerCase()
const value = arr[1]
// 命令
switch (command) {
// 设置群组
case "setup":
try {
const code = await handleSetUp(messageObj)
await sendMessage(messageObj, `欢迎使用本机器人, 你的 code 是: ${code}`)
} catch (err) {
const e = err as unknown as { message: string }
await sendMessage(messageObj, e.message)
}
break
// 获取群组 code
case "channelcode":
await sendMessage(messageObj, `频道 code: ${messageObj.chat.id}`)
break
// 设置审批群
case "setapproval":
try {
await handleSetApproval(chatId, value)
await sendMessage(messageObj, "设置审批成功")
} catch (ex) {
const e = ex as unknown as { message: string }
await sendMessage(messageObj, e.message)
}
break
// 设置合格群
case "setnext":
try {
await handleSetNext(chatId, value)
await sendMessage(messageObj, "设置合格群成功")
} catch (ex) {
const e = ex as unknown as { message: string }
await sendMessage(messageObj, e.message)
}
break
default:
await sendMessage(messageObj, "未知命令")
}
} else {
// 处理图片
const photoArr = messageObj.photo
if (photoArr && photoArr.length > 0) {
// 获取图片 url
const fileId = photoArr[photoArr.length - 1].file_id
const response = await getFilePath(fileId)
const filePath = response.data.result.file_path;
const fileUrl = getFileUrl(filePath);
// 下载图片
const imagePath = await myAxios.downloadImage(fileUrl)
try {
// 送审
const group = await getGroupByChatId(chatId)
if (group !== null && group.auditGroup && group.nextGroup) {
// 创建文件记录
await createMyFile(result.code, fileUrl, imagePath)
const code = uuidv4()
// 创建审批记录
await createRecord(code, fileId, chatId, `${group.nextGroup.chatId}`)
// 设置按钮
const replyMarkup = {
inline_keyboard: [
[
{ text: '审批未通过', callback_data: `/approval ${group.auditGroup.chatId} ${code}$1` },
{ text: '审批通过', callback_data: `/approval ${group.auditGroup.chatId} ${code}$2` },
]
],
};
// 发送图片
await sendPhoto(group.auditGroup.chatId, fileId, {
caption: `来自:${group.name},合格群:${group.nextGroup.name}`,
replyMarkup
})
} else {
await sendMessage(messageObj, "群组未设置审批群或合格群")
}
} catch (ex) {
const e = ex as unknown as { message: string }
await sendMessage(messageObj, e.message)
}
}
}
}
辅助类
// file
import { appDataSource } from "../db";
import Files from "../db/entity/files";
const filesRepository = appDataSource.getRepository(Files);
export const getFileByCode = async (code: string) => {
const file = await filesRepository.findOne({ where: { code } });
return file
}
export const createMyFile = async (code: string, url: string, path: string) => {
try {
const newFile = new Files();
newFile.code = code;
newFile.url = url;
newFile.path = path;
await filesRepository.save(newFile);
} catch {
throw new Error("保存图片失败")
}
}
// group
import { MessageObjType } from '../types';
import Group from '../db/entity/group';
import { appDataSource } from '../db';
import { v4 as uuidv4 } from 'uuid';
const groupRepository = appDataSource.getRepository(Group);
export const getGroupByChatId = async (chatId: string) => {
const group = await groupRepository.findOne({ where: { chatId }, relations: ['auditGroup', 'nextGroup'] });
return group;
}
export const handleSetUp = async (messageObj: MessageObjType) => {
const chatId = `${messageObj.chat.id}`;
if (await getGroupByChatId(chatId)) {
throw new Error("群组已存在")
}
const code = uuidv4();
const group = new Group();
group.chatId = chatId;
group.code = code;
group.name = messageObj.chat.title;
await groupRepository.save(group);
return code;
}
export const handleSetApproval = async (chatId: string, code: string) => {
const group = await groupRepository.findOne({ where: { chatId } });
if (group) {
const auditGroup = await groupRepository.findOne({ where: { code } });
if (auditGroup) {
group.auditGroup = auditGroup;
await groupRepository.save(group);
} else {
throw new Error("审批组不存在")
}
}
}
export const handleSetNext = async (chatId: string, code: string) => {
const group = await groupRepository.findOne({ where: { chatId } });
if (group) {
const nextGroup = await groupRepository.findOne({ where: { code } });
if (nextGroup) {
group.nextGroup = nextGroup;
await groupRepository.save(group);
} else {
throw new Error("合格组不存在")
}
}
}
// record
import { appDataSource } from "../db";
import Record from "../db/entity/record";
const recordRepository = appDataSource.getRepository(Record);
export const createRecord = async (
code: string,
fileId: string,
chatId: string,
nextChatId: string,
) => {
try {
const record = new Record()
record.code = code
record.fileId = fileId
record.chatId = chatId
record.nextChatId = nextChatId
record.type = 0
recordRepository.save(record)
} catch {
throw new Error("保存审批记录失败,请联系管理员")
}
}
export const getRecordByCode = async (code: string) => {
const record = await recordRepository.findOne({ where: { code } })
return record
}
export const approvalRecord = async (record: Record, type: number) => {
try {
record.type = type
await recordRepository.save(record)
} catch {
throw new Error("审批失败")
}
}
类型
export type PhotoItemType = {
file_id: string,
}
export type FromItemType = {
id: number,
is_bot: boolean,
first_name: string,
language_code: string
}
export type ChatItemType = {
id: number,
title: string,
type: string,
all_members_are_administrators: boolean
}
export type MessageObjType = {
message_id: number,
from: FromItemType,
chat: ChatItemType,
date: number,
photo?: PhotoItemType[]
text?: string
}
export type MessageButtonType = {
text: string,
callback_data: string
}
export type SendMessageExtend = {
photo: string,
reply_markup: {
inline_keyboard: MessageButtonType[][]
}
}
export type UpdateDataType = {
update_id: number,
message: MessageObjType
}
export type CallbackQueryType = {
id: string,
from: FromItemType,
message: MessageObjType,
chat_instance: string,
data: string
}
测试
- 创建三个组,分别添加机器人,执行
/setup
- 在送审组,添加审批组 /setApproval [审批组 code],添加合格组 /setNext [审批合格组 code]
- 发图