写一个简单的 Telegram 机器人

DN42 Telegram 群群友的要求,我打算给我的 Bird Looking Glass 加上 Telegram Bot 的支持,方便群友现场查询 Whois、测试网络通断、检查漏油路由泄漏源头等。这个 Bot 要能识别以斜线 / 开头的命令,然后对命令消息进行回复。

我的 Looking Glass 使用 Go 语言写成,因此我一开始先查找了 Go 语言的 Telegram Bot API。但流行的 API 库无一例外都遵循了同样的请求结构:

  • Telegram 服务器发送一个回调到自己的服务器;
  • 自己的程序处理请求,期间可能根据本地配置的 Token 向 Telegram 服务器多次主动请求;
  • 自己的程序最终主动请求 Telegram 服务器,发送回复信息。

这套方案功能强大,但有点复杂,而多余的功能我根本用不上。我更希望使用 Telegram 官方提供的另一种方式,直接回复回调 HTTP 请求的方式:

  • Telegram 服务器发送一个回调到自己的服务器;
  • 自己的程序处理请求后,直接以 HTTP Response 方式回复回调请求,执行操作。

虽然这种方法限制我对一个请求只能做出一个回复,但因为我的 Bot 也只需要回复一次,对我来说已经够用。同时这种方法也具有以下的优点:

  1. 写起程序来极其简单,根本不需要第三方库。
  2. 服务端无需知道 Token,减少了配置量。同时也可以创建多个机器人,仅以回调地址区分。
  3. 消除了额外 HTTP 请求的 CPU 周期、网络流量及延迟的消耗。

解析回调请求

Telegram 的回调请求以 JSON 发送,附在 HTTP POST 请求的 Body 上。Telegram 官方文档中给出了一个例子:

{
  "update_id": 10000,
  "message": {
    "date": 1441645532,
    "chat": {
      "last_name": "Test Lastname",
      "id": 1111111,
      "first_name": "Test",
      "username": "Test"
    },
    "message_id": 1365,
    "from": {
      "last_name": "Test Lastname",
      "id": 1111111,
      "first_name": "Test",
      "username": "Test"
    },
    "text": "/start"
  }
}

作为一个只关心命令本身的机器人,在这些请求中,我们只需要提取这些内容:

  • message/message_id:消息的编号,回复时需要设置这个编号以「回复/引用」原始消息。
  • message/chat/id:聊天窗口的编号。
  • message/text:用户发送的命令。

Go 语言解析 JSON 没有 Python 那么方便。不像 Python 直接解析然后当作一个 dict 访问,Go 语言中我们需要自己建好基本的数据结构来接收需要的信息。因此建立如下数据结构:

type tgChat struct {
  ID int64 `json:"id"`
}

type tgMessage struct {
  MessageID int64  `json:"message_id"`
  Chat      tgChat `json:"chat"`
  Text      string `json:"text"`
}

type tgWebhookRequest struct {
  Message tgMessage `json:"message"`
}

然后用这样一个函数来处理 net/http 服务器收到的请求:

func webHandlerTelegramBot(w http.ResponseWriter, r *http.Request) {
  // Parse only needed fields of incoming JSON body
  var err error
  var request tgWebhookRequest
  err = json.NewDecoder(r.Body).Decode(&request)
  if err != nil {
    println(err.Error())
    return
  }

  ...
}

提取指令目标

当 Telegram 用户调用 Bot 时,根据用户输入的不同,可能包含额外的参数,或者机器人的 Telegram ID。以调用 /traceroute 命令为例,可能是如下任意一种:

用户输入的命令(message/text命令本身命令的参数
/traceroutetraceroute
/traceroute lantian.pubtraceroutelantian.pub
/traceroute@lantian_lg_bottraceroute
/traceroute@lantian_lg_bot lantian.pubtraceroutelantian.pub

因此需要考虑各种情况进行解析:

// 使用这个函数判断某条消息是不是要执行某个命令
func telegramIsCommand(message string, command string) bool {
  b := false
  b = b || strings.HasPrefix(message, "/"+command+"@")
  b = b || strings.HasPrefix(message, "/"+command+" ")
  b = b || message == "/"+command
  return b
}

// 使用这段代码提取参数
target := ""
if strings.Contains(request.Message.Text, " ") {
  target = strings.Join(strings.Split(request.Message.Text, " ")[1:], " ")
}

构建回复消息

返回给 Telegram 回调的响应信息同样是一个 JSON,含有如下内容:

  • method:响应的类型,在我的用例中固定为 sendMessage,即发送消息。
  • chat_id:聊天窗口编号,与回调请求相同。
  • text:回复的具体内容,根据需要由程序设置。
  • reply_to_message_id:回复哪条信息,设置为回调请求中的 message_id
  • parse_mode:设置为 Markdown 可以让 Telegram 以 Markdown 格式解析文本,也可以去掉。

在 Go 中的结构体如下:

type tgWebhookResponse struct {
  Method           string `json:"method"`
  ChatID           int64  `json:"chat_id"`
  Text             string `json:"text"`
  ReplyToMessageID int64  `json:"reply_to_message_id"`
  ParseMode        string `json:"parse_mode"`
}

然后将 JSON 序列化并作为 HTTP 响应输出即可。注意要设置 Content-Type: application/json,否则 Telegram 服务器将不会解析这段 JSON,也就不会执行任何操作。

commandResult = "Hello World"
if len(commandResult) > 0 {
  // Create a JSON response
  w.Header().Add("Content-Type", "application/json")
  response := &tgWebhookResponse{
    Method:           "sendMessage",
    ChatID:           request.Message.Chat.ID,
    Text:             commandResult,
    ReplyToMessageID: request.Message.MessageID,
    ParseMode:        "Markdown",
  }
  data, err := json.Marshal(response)
  if err != nil {
    println(err.Error())
    return
  }
  // println(string(data))
  w.Write(data)
}

完整示例

以上代码均节选自我的 Go 语言 Bird Looking Glass,完整的代码可以在以下地址看到: