events 是数组,一次可能来好几条;replyToken 和事件是一对一的,拿错了就回错人。
六种事件类型里,只有 follow 是你能主动打招呼的唯一机会,漏了它等于放弃新用户。
多轮对话要有状态机。机器人自己不记事,状态得存在以 userId 为键的地方。
上一篇的 Echo Bot 只会「原样回」。这一篇开始看「谁在说、说什么、说到哪一步」。 教材第 13 章中间两节是【接收与解析 LINE 的讯息】和【记录心情留言传讯息】。 这一篇全部是真跑的:事件分流、字段判断、三条事件各回各的 token、多轮状态机, 都是本机实测输出。 只有一处是复刻:事件的来源。真机上是 LINE 推给你,这里是脚本自己造。 但事件的结构、字段名、取值全部照官方文档写。
一、events 是个数组,一次可能好几条
| 事件类型 | 什么情况 | 通常怎么处理 |
|---|---|---|
message |
有人发了消息 | 看 message.type 再分流 |
follow |
有人加你为好友 | 发欢迎语 |
unfollow |
有人删了你 | 清掉他的状态 |
join |
机器人被拉进群 | 发使用说明 |
postback |
用户点了按钮或选单 | 看 postback.data |
beacon |
用户走到某个信标附近 | 线下场景才用 |
只处理 message 是不会崩的,但你会错过 follow。 那是用户刚加你的那一秒,
也是唯一一次你能主动开口的机会。
漏了它,用户加完你,界面上一条消息都没有。他还以为这个号是死的。
unfollow 也别忽略。这个人走了,你留着他的状态只是垃圾;
更麻烦的是下次他回来,你会拿旧状态去接他。
演示第一段就是这张表。 这张表不用背,但要知道它存在——以后遇到「为什么用户加了我没反应」,先想到这里。
二、一条消息事件里有什么
| 字段 | 示例值 | 干什么用 |
|---|---|---|
type |
message |
先按它分流 |
replyToken |
tok_001 |
回话必须带上,只能用一次 |
source.userId |
Uabc123 |
认人、记状态 |
message.type |
text |
文字、贴图还是图片 |
message.text |
库存还有多少 |
只有文字消息才有 |
timestamp |
1793600000000 |
毫秒时间戳 |
source.userId 是这一篇的另一半重点。 它就是「你是谁」——机器人没有别的办法认人,
全靠这个字符串。
timestamp 是毫秒。想转成时间得除以 1000,datetime.fromtimestamp(ts / 1000)。
忘了除,你会得到公元五万多年。
source 里还有 type,取值可能是 user、group、room。
群里说话时,userId 是那个人,群 ID 在 groupId 里——回群消息要认准这一点。
三、message.type:只有文字能直接读
message.type |
里面有什么 | 能直接读文字 |
|---|---|---|
text |
text |
能 |
sticker |
packageId、stickerId |
不能 |
image |
id(要另外下载) |
不能 |
file |
fileName、fileSize |
不能 |
location |
title、经纬度 |
不能 |
audio |
id、duration |
不能 |
所以代码里一律先取 message.type,确认是 text 再读 text 字段。
不判断就取,用户发个表情过来你就崩了。 而用户发第一个表情的概率,
比你想的高得多——很多人打招呼就是一个笑脸。
演示第四段第三条事件专门造了一个 sticker,回的是「收到了非文字消息,先回一句收到」。
这么处理不算聪明,但它不会 500。
图片和文件更麻烦一层:id 只是编号,内容得再调一个接口去下载。
本书没展开,但要知道「收到图片」不等于「拿到了图片」。
四、replyToken 是一次性的
演示第四段三条事件、三个 token,各回各的:t1 回第一条,t2 回加好友,t3 回贴图。
拿 t1 去回第二个人,第二个人收不到,你还会收到一个错误。 replyToken 和事件是一对一绑定的,
而且很快就过期。
想固定回好几条消息(比如一句文字加一张图),不是调两次 reply,
是把它们放进同一个 messages 数组,一次发完。
那要不要「先存起来,过一会儿再回」?不行。 token 会过期,过期就只能用 push——而 push 是要钱的(下一节讲)。 所以 reply 的窗口很窄:拿到 token 就回,别拖。 真要延迟回复,那是 push 的场景,不是 reply 的。
五、多轮对话要有状态
演示第五段跑了五句:开始记录、给 4 分、写一句备注、重新开始、给 9 分被拒。
这段的关键不是文本,是那个 state 字典。
机器人本身不记事,它靠「上次跟这个人说到哪一步」来决定现在回什么。
状态机就三个数:现在是第几步、这一步要收什么、收完跳到第几步。
演示里第一步收分数(1 到 5),第二步收备注,收完回到第零步。
给 9 的时候回「只收 1 到 5」,而且状态没有被推进——这一点很容易写漏。
输入不合法就退回去重问,不能往下走。
字典放在内存里,程序一重启就全没了。 做产品得存数据库(12.1 篇那套),
第一次对话就把 userId 和状态写进去。
还有更麻烦的一层:用户会在中途说别的话。他随时可能发「取消」跳出来,或者发「记录心情」重新开始。
每个状态都得留一条退路,不然他就卡死在里面了。
六、reply 和 push 的账
| reply(回复) | push(推送) | |
|---|---|---|
| 要不要 token | 要 replyToken |
不要,用 userId |
| 有时间限制 | 有,token 很快过期 | 没有,随时能发 |
| 能主动发吗 | 不能,只能应答 | 能 |
| 免费额度 | 基本不限 | 有上限,超了要钱 |
「用户发消息,你回」用 reply;「到点提醒他」「库存告警推给他」用 push。 官方把 push 算钱、reply 不算——这个差别会直接影响你的设计。能塞进 reply 解决的,就别用 push。 举个具体的:用户问「工单好了吗」,你用 reply 回,免费。 你说「好了我通知你」,那就变成 push,按条算。
所以「主动服务」是有成本的。 做产品时这个成本要算进去,不是技术上能不能的问题。
七、动手 Demo:解析事件:一次可能来好几条
造三条事件(文字、加好友、贴图),看分流函数各回各的。 再跑一遍多轮对话:记录心情、给分、写备注、给个不合法的数。 最后把 reply 和 push 摆在一起看区别。
print(' ① events 数组里可能有几种')
print(' ' + '-' * 68)
print()
print('| 事件类型 | 含义 | 通常怎么处理 |')
print('|---|---|---|')
for r in (('message', '有人发了消息', '看 message.type 是 text 还是 sticker'),
('follow', '有人加你为好友', '该发欢迎语'),
('unfollow', '有人删了你', '清掉他的状态'),
('join', '机器人被拉进群', '该发使用说明'),
('postback', '用户点了按钮/选单', '看 postback.data'),
('beacon', '用户走到某个信标附近', '线下场景才会用到')):
print('| %s | %s | %s |' % r)
print()
print(' 只处理 message 是不会崩的,')
print(' 但你会错过「用户刚加好友」这个最重要的时刻——那是唯一一次你能主动打招呼的机会。')
print()
print(' ② 一条消息事件的字段')
print(' ' + '-' * 68)
print()
print('| 字段 | 值 | 干什么用 |')
print('|---|---|---|')
for r in (('type', 'message', '先按它分流'),
('replyToken', 'tok_001', '回话必须带上,而且只能用一次'),
('source.userId', 'Uabc123', '认人、记状态'),
('message.type', 'text', '文字还是贴图还是图片'),
('message.text', '库存还有多少', '只有文字消息才有'),
('timestamp', '1793600000000', '毫秒时间戳')):
print('| %s | %s | %s |' % r)
print()
print(' replyToken 是一次性的:同一个 token 发两次,第二次会失败。')
print(' 想多发几条,把它们放进同一个 messages 数组,一次发完。')
print()
print(' ③ 消息类型不止文字')
print(' ' + '-' * 68)
print()
print('| message.type | 里面有什么 | 能不能直接读文字 |')
print('|---|---|---|')
for r in (('text', 'text', '能'),
('sticker', 'packageId、stickerId', '不能'),
('image', 'id(要另外下载)', '不能'),
('file', 'fileName、fileSize', '不能'),
('location', 'title、latitude、longitude', '不能'),
('audio', 'id、duration', '不能')):
print('| %s | %s | %s |' % r)
print()
print(' 所以代码里一律先取 message.type,确认是 text 再读 text 字段。')
print(' 不判断就取,用户发个表情过来你就崩了。')
print()
print(' ④ 一次来好几条,要按顺序处理')
print(' ' + '-' * 68)
events = [
{'type': 'message', 'replyToken': 't1',
'message': {'type': 'text', 'text': '你好'}},
{'type': 'follow', 'replyToken': 't2',
'source': {'type': 'user', 'userId': 'Unew'}},
{'type': 'message', 'replyToken': 't3',
'message': {'type': 'sticker', 'id': '52002734'}},
]
def handle(ev):
if ev['type'] == 'follow':
return '欢迎!发「报修」可以提交问题'
if ev['type'] != 'message':
return None
m = ev.get('message', {})
if m.get('type') != 'text':
return '(收到了非文字消息,先回一句「收到」)'
return '你说的是:%s' % m.get('text')
for i, e in enumerate(events, 1):
print(' %d. %-8s token=%s → %s' % (i, e['type'], e['replyToken'], handle(e)))
print(' 三条事件、三个 token,各回各的。不能拿 t1 去回第二个人——replyToken 和事件是一对一的。')
print()
print(' ⑤ 多轮对话要有状态')
print(' ' + '-' * 68)
state = {}
def talk(uid, text):
st = state.get(uid, {'step': 0})
if text == '记录心情':
st['step'] = 1
state[uid] = st
return '今天心情打几分?1 到 5'
if st['step'] == 1:
try:
score = int(text)
except ValueError:
return '要给个数字哦(1 到 5)'
if not 1 <= score <= 5:
return '只收 1 到 5'
st['step'] = 2
st['score'] = score
state[uid] = st
return '记下了 %d 分,想说的话可以一起写' % score
if st['step'] == 2:
st['step'] = 0
st['note'] = text
state[uid] = st
return '已存档:%d 分,备注「%s」' % (st['score'], text)
state[uid] = st
return '发「记录心情」可以记一条'
for t in ('记录心情', '4', '今天现场很顺利', '记录心情', '9'):
print(' 用户:%-12s 机器人:%s' % (t, talk('Uabc123', t)))
print(' 机器人本身不记事,状态得存在一个以 userId 为键的字典里。')
print(' 字典放内存里,程序一重启就没了——真要做产品得存数据库。')
print(' 还有更麻烦的一层:用户会在中途说别的话。')
print(' 所以他随时能发一句「取消」跳出来,或者发「记录心情」重新开始,这些都要处理。')
print()
print(' ⑥ reply 和 push 的区别')
print(' ' + '-' * 68)
print()
print('| | reply(回复) | push(推送) |')
print('|---|---|---|')
for r in (('要不要 token', '要 replyToken', '不要,用 userId'),
('有时间限制吗', '有,token 很快过期', '没有,随时能发'),
('能主动发吗', '不能,只能应答', '能'),
('免费额度', '基本不限', '有上限,超了要钱')):
print('| %s | %s | %s |' % r)
print()
print(' 「用户发消息 → 你回」用 reply,「到点提醒他」「库存告警推给他」用 push。')
print(' 官方把 push 算钱,reply 不算——所以能从 reply 解决的别用 push。')
实跑输出:
--------------------------------------------------------------------
解析事件:一次可能来好几条
--------------------------------------------------------------------
① events 数组里可能有几种
--------------------------------------------------------------------
| 事件类型 | 含义 | 通常怎么处理 |
|---|---|---|
| message | 有人发了消息 | 看 message.type 是 text 还是 sticker |
| follow | 有人加你为好友 | 该发欢迎语 |
| unfollow | 有人删了你 | 清掉他的状态 |
| join | 机器人被拉进群 | 该发使用说明 |
| postback | 用户点了按钮/选单 | 看 postback.data |
| beacon | 用户走到某个信标附近 | 线下场景才会用到 |
只处理 message 是不会崩的,
但你会错过「用户刚加好友」这个最重要的时刻——那是唯一一次你能主动打招呼的机会。
② 一条消息事件的字段
--------------------------------------------------------------------
| 字段 | 值 | 干什么用 |
|---|---|---|
| type | message | 先按它分流 |
| replyToken | tok_001 | 回话必须带上,而且只能用一次 |
| source.userId | Uabc123 | 认人、记状态 |
| message.type | text | 文字还是贴图还是图片 |
| message.text | 库存还有多少 | 只有文字消息才有 |
| timestamp | 1793600000000 | 毫秒时间戳 |
replyToken 是一次性的:同一个 token 发两次,第二次会失败。
想多发几条,把它们放进同一个 messages 数组,一次发完。
③ 消息类型不止文字
--------------------------------------------------------------------
| message.type | 里面有什么 | 能不能直接读文字 |
|---|---|---|
| text | text | 能 |
| sticker | packageId、stickerId | 不能 |
| image | id(要另外下载) | 不能 |
| file | fileName、fileSize | 不能 |
| location | title、latitude、longitude | 不能 |
| audio | id、duration | 不能 |
所以代码里一律先取 message.type,确认是 text 再读 text 字段。
不判断就取,用户发个表情过来你就崩了。
④ 一次来好几条,要按顺序处理
--------------------------------------------------------------------
1. message token=t1 → 你说的是:你好
2. follow token=t2 → 欢迎!发「报修」可以提交问题
3. message token=t3 → (收到了非文字消息,先回一句「收到」)
三条事件、三个 token,各回各的。不能拿 t1 去回第二个人——replyToken 和事件是一对一的。
⑤ 多轮对话要有状态
--------------------------------------------------------------------
用户:记录心情 机器人:今天心情打几分?1 到 5
用户:4 机器人:记下了 4 分,想说的话可以一起写
用户:今天现场很顺利 机器人:已存档:4 分,备注「今天现场很顺利」
用户:记录心情 机器人:今天心情打几分?1 到 5
用户:9 机器人:只收 1 到 5
机器人本身不记事,状态得存在一个以 userId 为键的字典里。
字典放内存里,程序一重启就没了——真要做产品得存数据库。
还有更麻烦的一层:用户会在中途说别的话。
所以他随时能发一句「取消」跳出来,或者发「记录心情」重新开始,这些都要处理。
⑥ reply 和 push 的区别
--------------------------------------------------------------------
| | reply(回复) | push(推送) |
|---|---|---|
| 要不要 token | 要 replyToken | 不要,用 userId |
| 有时间限制吗 | 有,token 很快过期 | 没有,随时能发 |
| 能主动发吗 | 不能,只能应答 | 能 |
| 免费额度 | 基本不限 | 有上限,超了要钱 |
「用户发消息 → 你回」用 reply,「到点提醒他」「库存告警推给他」用 push。
官方把 push 算钱,reply 不算——所以能从 reply 解决的别用 push。
第一段那张表里,follow 和 unfollow 最容易漏。
漏了 follow,新用户加完你一片空白,他还以为号是死的。
第四段三行的对齐是故意的:中间那个 %-8s 把事件类型补齐,一眼就能看出三个 token 各回各的。
第四段第三条回的是「收到了非文字消息」,这就是防御性写法——不判断 message.type 就取 text,
用户发个表情你就 500。
第五段那五行是本篇的核心。
注意第五行给 9 的时候回了「只收 1 到 5」,而且状态没有推进——下次他还是被问分数。
第五段那个 state 字典在内存里,进程一退就没了。真做产品得存数据库。
30 秒实验
- 把演示第五段那个「1 到 5」改成「1 到 10」,看给 9 的那次是不是就通过了。
- 在演示第五段的
talk里加一条:只要收到「取消」,就把状态清零并回一句「好的,不记了」。 然后试试在写备注那一步发「取消」。 - 把演示第四段三条事件的顺序打乱,看回复是不是还各自对得上自己的 token。
常见误区
- 「只处理 message 事件就够了。」漏掉
follow,新用户加完你一条消息都收不到。 那是唯一一次你能主动开口的机会。 - 「replyToken 存起来,过一会儿再回。」token 会过期。 拿到就回,别拖。 要延迟回复那是 push 的场景,要花钱。
- 「状态放全局变量就行,反正就一个用户。」两个人同时用就串味,重启就全丢。
以
userId为键,而且要落库。
自测三问(点开对答案)
1. 为什么说 `follow` 事件是「唯一一次主动打招呼的机会」? —— 因为用户刚加你时,你手上有一个可用的 `replyToken`,可以直接回一条欢迎语。 之后他没有再发消息,你就只能靠 push 主动发——那是要钱的,而且容易被当成骚扰。 所以欢迎语、使用说明都要放在 `follow` 里。 2. `replyToken` 为什么不能复用?想一次回好几条怎么办? —— 它跟某一条事件一对一绑定,而且很快过期,拿它去回别人会失败。 一次回好几条的正确做法是把多条消息放进同一个 `messages` 数组,一次请求发完, 而不是调多次 reply。 3. 多轮对话的状态应该存在哪里?为什么不能只放内存? —— 以 `userId` 为键存起来。放内存的字典里,程序一重启就全丢,用户得从头再来一遍; 而且多个 worker 进程之间也不共享。 做产品要存数据库,第一次对话就把 `userId` 和当前步骤写进去。下一篇把这些零件拼成真客服:关键词分流、报修流程,再挂一张图文选单。
- ☐ (本篇完,下一篇 13.3 在线报修与图文选单)