2024-05-09

Telegram Bot 图片审核

文档

https://core.telegram.org/api

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]
  • 发图
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,490评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,581评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,830评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,957评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,974评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,754评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,464评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,357评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,847评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,995评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,137评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,819评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,482评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,023评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,149评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,409评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,086评论 2 355

推荐阅读更多精彩内容

  • 《英语书面表达错误分析 及其干预措施的实证研究》结题报告 一、课题提出 (一)提出的依据 外语学习的过程就是克服和...
    海门中专王松阅读 156评论 0 0
  • 党纪学习心得体会 《中国共产党纪律处分条例》是党规党纪体系中的基础性法规,对党员、干部以及党的组织在违反党纪行为时...
    海豚_630a阅读 36评论 0 0
  • 感恩!六点签到 可以肯定“在这个时—空,一切都是注定”,这种信仰本身才是最大的恩典。信仰到了,一个人也就成熟了,准...
    感恩学习相信小陶阅读 66评论 0 0
  • 党纪学习心得体会 《中国共产党纪律处分条例》是党规党纪体系中的基础性法规,对党员、干部以及党的组织在违反党纪行为时...
    海豚_630a阅读 41评论 0 0
  • 格式要求: 题目: 【第038篇】意识形态学习 正文: 【红星四亮】党员学习分享 中国共产党章程(第二章 党的组织...
    春天的春阅读 75评论 0 0