🐍 第13章 打造 LINE 聊天机器人
📄 一句话概括

LINE Bot 的模型是倒过来的:不是你轮询去问,是 LINE 有消息就 POST 到你给的网址。 所以你得有一个公网敲得到的地址。本机开发靠内网穿透顶一下,这就是 10.1 篇那套东西的用处。 验签是整章第一道门。 签名只由「原始请求体字节 + Channel Secret」决定,少一个字、 多一个空格都对不上。

📝 本章和前面几章的关系

前十二章的程序都是「你启动、它自己跑」。这一章反过来:程序起在那里等别人来敲门。 教材第 13 章开头三节是【LINE bot 聊天机器人程序开发】【制作一个 LINE Echo Bot】 和【在本机电脑上测试第一个 LINE 程序】。 本沙箱没有 LINE 官方账号,也连不上 api.line.me。 但协议本身完全能在本机跑通: 请求体是真的 JSON、签名是真的 HMAC-SHA256、reply 是真的 POST。 所以这一篇起两个本地服务:一个装成 LINE 的服务器,一个装成你的机器。 两头真收发,只有目的地换了——这个换法在正文里逐处标出来。

一、消息是别人送过来的

你在 LINE 里打一句「你好」,到机器人回你一句,中间走五步:你发出去、LINE 打包成 JSON、 POST 到你的网址、你调 reply 接口、LINE 显示给你。

角色 是谁 本课里对应什么
用户 手机上的 LINE App 我们扮演
平台 LINE 的服务器 本地起一个假服务器
你的服务 跑在公网上的程序 本地起一个 Flask

关键在于你没有去取,是消息被送过来了。 你的程序在 LINE 那边只是一个网址, 有消息它就往这个网址 POST。 这个接收地址有个专门的名字叫 webhook。 它跟 9.3 篇那个表单提交是同一个动作——都是别人把数据 POST 给你,只不过这次发数据的是机器。 这解释了一件事:你的程序不用一直醒着轮询。 没消息的时候它什么都不做。

代价是你必须有个公网能访问的地址。 本机跑的时候用内网穿透(10.1 篇):它给你一个临时网址,把外面的请求转到 127.0.0.1:5000。 这也是为什么本书把内网穿透放在 LINE 之前讲。没有公网地址,这一章一步都走不动。

二、先看清 POST 过来的是什么

一个 JSON,两个顶层字段:destination 是你的 Bot 的 ID,events 是事件数组。

字段 是什么
destination 你的 Bot 的 ID
events 事件数组,一次可能好几条
type message、follow、postback 等
source 谁发的,userId 在里面
message 消息本体,type 和 text
timestamp 毫秒时间戳
replyToken 回话的凭据,只能用一次

events 是数组这一点很重要。 用户连发三条、或者群里几个人同时说话,LINE 可能一次全塞给你。 所以代码里必须是个循环,不能只取 events[0]。 演示第二段打出来的就是那 18 行,注意 message 里面还套了一层:外层 type 说是 message 事件, 内层 message.type 才说是文字还是贴图。 两层 type 是新手最容易看晕的地方。外层决定「这是什么事件」,内层决定「这条消息是什么格式」。

三、验签:证明这条真来自 LINE

请求头里带一个 X-Line-Signature,演示里算出来是 RaVKB/qh2xY5o8fMB+LUdsTm7n07j+rUSXTLaiLxEzI=。 算法三步:拿请求体的原始字节、用 Channel Secret 做 HMAC-SHA256、把结果 base64。 演示第三段把 secret 换成别的再算一次,得到 7OgyABWznY9OBq9kc42Rw6KWAxqWrKQHxA+Dxu96ePw=, 跟真的完全不一样。

别人不知道 secret 就算不出这个值,哪怕他知道你的网址也伪造不了消息。 这就是 webhook 的鉴权—— 没有这一层,谁都能往你的接口灌数据。 有个细节九成人第一次写错:算签名必须用原始的请求体字节。 先 json.loads 再 json.dumps 回去,内容看着一样,但空格、缩进、字段顺序都可能变, 签名立刻对不上。演示里 raw_body 是直接 POST 出去的那串字节,一步没转手。

比较也别用 ==,用 hmac.compare_digest。 按文档,它的耗时跟内容无关,不会因为「前几位对上了」而变慢——那种快慢差异本身就能被拿来猜签名。

四、最小 webhook 就三件事

验签、解析、处理。演示第四段就是这么个函数:签名对给 200,签名错给 403。 这两行返回码是整章的地基。 403 不是给人看的,是给 LINE 看的——它会重试。 官方文档要求 1 秒内返回 200,超时会重发。 所以重活不能塞在 webhook 里做:查数据库、调接口、发通知,全放到后台去。

重发还有个后遗症:同一条消息会被处理两遍。 用户留一次言,你库里记两条。 防的办法是用 message.id 或 webhookEventId 去重——处理过的 ID 存一份,再来就跳过。 这个动作很小,但省掉的麻烦很大。 所以 webhook 该干的是:验签、把事件原样存下来、立刻返回 200。真正的处理交给另一个循环。 这是这一章最像工程的一句话。

五、回话:两个凭证别混

reply 是 POST 到 https://api.line.me/v2/bot/message/reply,带一个 Bearer 头, body 里放 replyToken 和 messages 数组。

凭证 管什么 放在哪儿
Channel Secret 验签:证明请求来自 LINE 算签名,不进请求头
Channel Access Token 鉴权:证明你是 Bot 的主人 Authorization: Bearer …

一个进一个出,方向别搞反。 Secret 是你用来验别人,Token 是你拿去证明自己。 两个都泄露的后果不一样:Secret 泄露,别人能伪造消息骗你的程序; Token 泄露,别人能拿你的 Bot 给用户发消息,额度还算你的。 所以两个都不许写进代码仓库。做法跟 12.3 篇那个 secret_key 一样——从环境变量读。

沙箱连不上 api.line.me,所以演示第六段把目的地换成本地一个假服务器。 地址、证书、返回的那个空 JSON 是假的,请求头、请求体、状态码全是真的。

六、Echo Bot 的完整形状

把上面几段拼起来就是一个 Echo Bot:收到什么,回什么。 演示里那个假 LINE 服务器收到的 Authorization 是 Bearer DUMMY-TOKEN-0000000000, replyToken 原样带回来了,messages 是一个只有一条文本的数组。就这些。 真机上唯一的差别是地址、证书,还有成功时 LINE 返回的空 JSON 对象 {}。 所以这段代码改两个常量就能上线。

但 Echo Bot 不是终点。 它没有状态、没有分流:用户说「报修」,它把「报修」两个字回给你。 下一篇补状态,再下一篇补分流和按钮。这三篇连起来才是一个能用的客服机器人。

七、动手 Demo:LINE Bot:别人替你把消息 POST 过来

起两个本地服务:一个装成 LINE 的服务器,一个装成你的机器。 重点看三处:签名到底怎么算出来、用错 secret 会差多少、reply 请求长什么样。

import base64
import hashlib
import hmac
import json
import threading
import time

import requests
from flask import Flask, request
from werkzeug.serving import make_server

SECRET = '2f9c8e1d4b7a6f3e5c0d9b8a7f6e5d4c'


def sign(body, secret=SECRET):
    mac = hmac.new(secret.encode('utf-8'), body, hashlib.sha256).digest()
    return base64.b64encode(mac).decode('ascii')


def serve(app):
    srv = make_server('127.0.0.1', 0, app)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    time.sleep(0.12)
    return srv, 'http://127.0.0.1:%d' % srv.server_port


print('  ① 消息是怎么走过来的')
print('  ' + '-' * 68)
steps = ['用户在 LINE 里打「你好」',
         'LINE 服务器把这条消息打包成 JSON,POST 到你的网址',
         '你的程序看是谁说的、说了什么,再决定回什么',
         '你调 LINE 的 reply 接口,把回复交回去',
         'LINE 把回复显示给用户']
for i, s in enumerate(steps, 1):
    print('    %d %s' % (i, s))
print()
print('| 角色 | 谁 | 本课里对应什么 |')
print('|---|---|---|')
for r in (('用户', '手机上的 LINE App', '我们扮演'),
          ('平台', 'LINE 的服务器', '本地起一个假服务器'),
          ('你的服务', '跑在公网上的程序', '本地起一个 Flask')):
    print('| %s | %s | %s |' % r)
print()
print('  关键认识:你的程序不是「主动去 LINE 拿消息」,是 LINE 主动来敲你的门。')
print('  所以你的机器必须有一个公网能访问的地址——这就是第 10 章讲内网穿透的原因。')
print()
print('  ② 先看 LINE 到底 POST 什么过来')
print('  ' + '-' * 68)
body = {
    'destination': 'U1234567890abcdef',
    'events': [{
        'type': 'message',
        'message': {'type': 'text', 'id': '4687894561', 'text': '你好'},
        'timestamp': 1793600000000,
        'source': {'type': 'user', 'userId': 'Uabcdef1234567890'},
        'replyToken': 'nHuyWiB7yP5Zw52FIkcQobQuGDXCTA',
        'mode': 'active',
    }],
}
raw_body = json.dumps(body, ensure_ascii=False).encode('utf-8')
for ln in json.dumps(body, ensure_ascii=False, indent=2).split('\n')[:18]:
    print('  ' + ln)
print('  events 是个数组,一次可能给你好几条。')
print('  每条里有:谁发的(source)、什么时候(timestamp)、是什么(type 和 message)、')
print('  以及回话要用的 replyToken。')
print()
print('  ③ 验签:确认「这条真的来自 LINE」')
print('  ' + '-' * 68)
good = sign(raw_body)
bad = sign(raw_body, 'wrong-secret-of-another-bot')
print('    请求头 X-Line-Signature 里应该是:')
print('      %s' % good)
hex40 = hmac.new(SECRET.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()[:40]
print('    底层那个 HMAC-SHA256(十六进制)前 40 位:%s…' % hex40)
print('    用错的 secret 算一遍:%s' % bad)
print('    两者一样吗?%r' % (good == bad))
print('  签名只跟「请求体 + 你的 Channel Secret」有关。')
print('  别人不知道 secret,就造不出正确的签名,哪怕他知道你的网址也伪造不了消息。')
print('  这里有个细节很容易写错:算签名必须用原始的请求体字节,')
print('  不能先解析成 JSON 再 dump 回去——空格和字段顺序一变,签名就对不上了。')
print()
print('  ④ 一个最小的 webhook')
print('  ' + '-' * 68)
app = Flask('demo32')
seen = []


@app.route('/callback', methods=['POST'])
def callback():
    raw = request.get_data()
    sig = request.headers.get('X-Line-Signature', '')
    if not hmac.compare_digest(sig, sign(raw)):
        return 'bad signature', 403
    payload = json.loads(raw.decode('utf-8'))
    for ev in payload.get('events', []):
        seen.append(ev)
    return 'OK', 200


srv, base = serve(app)
r = requests.post(base + '/callback', data=raw_body, timeout=5,
                  headers={'X-Line-Signature': good,
                           'Content-Type': 'application/json'})
print('    签名正确 → %d %s' % (r.status_code, r.text))
r = requests.post(base + '/callback', data=raw_body, timeout=5,
                  headers={'X-Line-Signature': bad})
print('    签名错误 → %d %s' % (r.status_code, r.text))
print('    收到的事件数 %d,类型 %s' % (len(seen), seen[0]['type'] if seen else '—'))
print('  webhook 就三件事:验签、解析、把事件存下来或立刻处理。')
print('  LINE 要求你 1 秒内返回 200,不然会重发——所以重活要丢到后台去做。')
print('  hmac.compare_digest 是专门用来比签名的。')
print('  它耗时固定,不会因为「前几位对上了」而变慢,从而泄露信息。')
srv.shutdown()
print()
print('  ⑤ 回话:调那个 reply 接口')
print('  ' + '-' * 68)
print('    真机上是这一个请求:')
print('      POST https://api.line.me/v2/bot/message/reply')
print('      Authorization: Bearer {Channel Access Token}')
print('      Content-Type: application/json')
print('      {')
print('        "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",')
print('        "messages": [{"type": "text", "text": "你好,我是机器人"}]')
print('      }')
print('  两个凭证别混:Channel Secret 用来验签(证明请求来自 LINE),')
print('  Channel Access Token 用来发请求(证明你是这个 Bot 的主人)。')
print('  本沙箱连不上 api.line.me,所以下一节把「LINE 的服务器」换成本地的一个假服务器,请求头、请求体、')
print('  返回码都是真的,只有目的地换了。')
print()
print('  ⑥ 把 reply 请求发到一个假 LINE 服务器上')
print('  ' + '-' * 68)
api = Flask('fake-line')
got = []


@api.route('/v2/bot/message/reply', methods=['POST'])
def reply():
    got.append({'auth': request.headers.get('Authorization'),
                'body': request.get_json()})
    return '{}', 200


srv2, base2 = serve(api)
resp = requests.post(base2 + '/v2/bot/message/reply', timeout=5,
                     headers={'Authorization': 'Bearer DUMMY-TOKEN-0000000000',
                              'Content-Type': 'application/json'},
                     json={'replyToken': body['events'][0]['replyToken'],
                           'messages': [{'type': 'text',
                                         'text': '你好,我是机器人'}]})
print('    LINE 服务器收到 → %d' % resp.status_code)
print('    Authorization 头  %s' % got[0]['auth'])
print('    replyToken       %s' % got[0]['body']['replyToken'])
print('    要发的内容       %r' % got[0]['body']['messages'])
print('  请求的形状就这些,没有更神秘的东西。真机上唯一的差别是地址、证书、还有成功时返回的空 JSON 对象。')
srv2.shutdown()

实跑输出:

--------------------------------------------------------------------
LINE Bot:别人替你把消息 POST 过来
--------------------------------------------------------------------

  ① 消息是怎么走过来的
  --------------------------------------------------------------------
    1 用户在 LINE 里打「你好」
    2 LINE 服务器把这条消息打包成 JSON,POST 到你的网址
    3 你的程序看是谁说的、说了什么,再决定回什么
    4 你调 LINE 的 reply 接口,把回复交回去
    5 LINE 把回复显示给用户

| 角色 | 谁 | 本课里对应什么 |
|---|---|---|
| 用户 | 手机上的 LINE App | 我们扮演 |
| 平台 | LINE 的服务器 | 本地起一个假服务器 |
| 你的服务 | 跑在公网上的程序 | 本地起一个 Flask |

  关键认识:你的程序不是「主动去 LINE 拿消息」,是 LINE 主动来敲你的门。
  所以你的机器必须有一个公网能访问的地址——这就是第 10 章讲内网穿透的原因。

  ② 先看 LINE 到底 POST 什么过来
  --------------------------------------------------------------------
  {
    "destination": "U1234567890abcdef",
    "events": [
      {
        "type": "message",
        "message": {
          "type": "text",
          "id": "4687894561",
          "text": "你好"
        },
        "timestamp": 1793600000000,
        "source": {
          "type": "user",
          "userId": "Uabcdef1234567890"
        },
        "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
        "mode": "active"
      }
  events 是个数组,一次可能给你好几条。
  每条里有:谁发的(source)、什么时候(timestamp)、是什么(type 和 message)、
  以及回话要用的 replyToken。

  ③ 验签:确认「这条真的来自 LINE」
  --------------------------------------------------------------------
    请求头 X-Line-Signature 里应该是:
      RaVKB/qh2xY5o8fMB+LUdsTm7n07j+rUSXTLaiLxEzI=
    底层那个 HMAC-SHA256(十六进制)前 40 位:45a54a07faa1db1639a3c7cc07e2d476c4e6ee7d…
    用错的 secret 算一遍:7OgyABWznY9OBq9kc42Rw6KWAxqWrKQHxA+Dxu96ePw=
    两者一样吗?False
  签名只跟「请求体 + 你的 Channel Secret」有关。
  别人不知道 secret,就造不出正确的签名,哪怕他知道你的网址也伪造不了消息。
  这里有个细节很容易写错:算签名必须用原始的请求体字节,
  不能先解析成 JSON 再 dump 回去——空格和字段顺序一变,签名就对不上了。

  ④ 一个最小的 webhook
  --------------------------------------------------------------------
    签名正确 → 200 OK
    签名错误 → 403 bad signature
    收到的事件数 1,类型 message
  webhook 就三件事:验签、解析、把事件存下来或立刻处理。
  LINE 要求你 1 秒内返回 200,不然会重发——所以重活要丢到后台去做。
  hmac.compare_digest 是专门用来比签名的。
  它耗时固定,不会因为「前几位对上了」而变慢,从而泄露信息。

  ⑤ 回话:调那个 reply 接口
  --------------------------------------------------------------------
    真机上是这一个请求:
      POST https://api.line.me/v2/bot/message/reply
      Authorization: Bearer {Channel Access Token}
      Content-Type: application/json
      {
        "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
        "messages": [{"type": "text", "text": "你好,我是机器人"}]
      }
  两个凭证别混:Channel Secret 用来验签(证明请求来自 LINE),
  Channel Access Token 用来发请求(证明你是这个 Bot 的主人)。
  本沙箱连不上 api.line.me,所以下一节把「LINE 的服务器」换成本地的一个假服务器,请求头、请求体、
  返回码都是真的,只有目的地换了。

  ⑥ 把 reply 请求发到一个假 LINE 服务器上
  --------------------------------------------------------------------
    LINE 服务器收到 → 200
    Authorization 头  Bearer DUMMY-TOKEN-0000000000
    replyToken       nHuyWiB7yP5Zw52FIkcQobQuGDXCTA
    要发的内容       [{'type': 'text', 'text': '你好,我是机器人'}]
  请求的形状就这些,没有更神秘的东西。真机上唯一的差别是地址、证书、还有成功时返回的空 JSON 对象。

第三段是本篇最该盯的地方。 同一个请求体,secret 换掉就得到完全不同的签名, 而且两个值没有任何肉眼可见的关系——这就是哈希的意义。 还有那个十六进制的前 40 位,它是同一份签名的另一种表示(base64 之前的样子), 对照着看能明白 base64 只是换了个壳。 第四段那两个返回码是重点:200 和 403。403 不是给人看的,是给 LINE 看的。 第六段那个假服务器收到的三样东西——Authorization 头、replyToken、messages 数组, 就是真机上 LINE 会收到的全部。 注意第六段最后那句「真机上唯一的差别是地址、证书」。 这一句是整个复刻的边界,别把它当成「本机通了就等于上线通了」。

30 秒实验

  1. 把演示第三段的 secret 改一个字符,看签名变成什么——你会发现它完全不像原来那个。
  2. 把演示第四段的 hmac.compare_digest 换成 ==,跑一遍,结果一样; 然后想清楚为什么生产代码不能用 ==。
  3. 把演示第六段 reply 请求里的 messages 改成两条(一条文字一条贴图), 看假服务器收到的内容变成什么形状。

常见误区

  1. 「本机测通了,上线就是改个网址。」本机复刻的是协议形状,不是 LINE 的账号体系。 Token、Secret、公网地址、审核,这四样一个都不能少。
  2. 「验签最后再加,先把功能跑通。」没有验签,谁都能往你的接口灌任意事件。 先写验签,再写业务,顺序反了以后很难补回来。
  3. 「先 json.loads 再 dumps 回去算签名,内容一样就行。」空格、缩进、字段顺序都会变。 必须用原始字节。
自测三问(点开对答案) 1. 为什么 LINE Bot 需要一个公网能访问的地址? —— 因为消息是 LINE 主动 POST 过来的,你的程序在它那边只是一个网址。 你不需要轮询,但你必须被敲得到。 本机开发时靠内网穿透把外网请求转到 `127.0.0.1`,那个临时地址就是临时的公网入口。 2. 验签为什么必须用原始的请求体字节,不能先解析成 JSON? —— 因为签名是对字节算的。 `json.loads` 再 `json.dumps` 回去,空格、缩进、字段顺序都可能变,字节一变签名就对不上。 正确做法是把收到的原始 body 直接喂给 HMAC,解析另做一次。 3. Channel Secret 和 Channel Access Token 分别干什么? —— Secret 用来验签——证明这个请求真的来自 LINE;Token 用来发请求——证明你是这个 Bot 的主人。 一个进一个出,两个都不能泄露,都不能写进代码仓库。

下一篇把事件拆开:一次可能来好几条,还要记住用户说到哪一步了。

相关笔记