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 秒实验
- 把演示第三段的 secret 改一个字符,看签名变成什么——你会发现它完全不像原来那个。
- 把演示第四段的
hmac.compare_digest换成==,跑一遍,结果一样; 然后想清楚为什么生产代码不能用==。 - 把演示第六段 reply 请求里的
messages改成两条(一条文字一条贴图), 看假服务器收到的内容变成什么形状。
常见误区
- 「本机测通了,上线就是改个网址。」本机复刻的是协议形状,不是 LINE 的账号体系。 Token、Secret、公网地址、审核,这四样一个都不能少。
- 「验签最后再加,先把功能跑通。」没有验签,谁都能往你的接口灌任意事件。 先写验签,再写业务,顺序反了以后很难补回来。
- 「先
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 的主人。 一个进一个出,两个都不能泄露,都不能写进代码仓库。下一篇把事件拆开:一次可能来好几条,还要记住用户说到哪一步了。
- ☐ (本篇完,下一篇 13.2 接收与解析 LINE 讯息)