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

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. 把演示第五段那个「1 到 5」改成「1 到 10」,看给 9 的那次是不是就通过了。
  2. 在演示第五段的 talk 里加一条:只要收到「取消」,就把状态清零并回一句「好的,不记了」。 然后试试在写备注那一步发「取消」。
  3. 把演示第四段三条事件的顺序打乱,看回复是不是还各自对得上自己的 token。

常见误区

  1. 「只处理 message 事件就够了。」漏掉 follow,新用户加完你一条消息都收不到。 那是唯一一次你能主动开口的机会。
  2. 「replyToken 存起来,过一会儿再回。」token 会过期。 拿到就回,别拖。 要延迟回复那是 push 的场景,要花钱。
  3. 「状态放全局变量就行,反正就一个用户。」两个人同时用就串味,重启就全丢。 以 userId 为键,而且要落库。
自测三问(点开对答案) 1. 为什么说 `follow` 事件是「唯一一次主动打招呼的机会」? —— 因为用户刚加你时,你手上有一个可用的 `replyToken`,可以直接回一条欢迎语。 之后他没有再发消息,你就只能靠 push 主动发——那是要钱的,而且容易被当成骚扰。 所以欢迎语、使用说明都要放在 `follow` 里。 2. `replyToken` 为什么不能复用?想一次回好几条怎么办? —— 它跟某一条事件一对一绑定,而且很快过期,拿它去回别人会失败。 一次回好几条的正确做法是把多条消息放进同一个 `messages` 数组,一次请求发完, 而不是调多次 reply。 3. 多轮对话的状态应该存在哪里?为什么不能只放内存? —— 以 `userId` 为键存起来。放内存的字典里,程序一重启就全丢,用户得从头再来一遍; 而且多个 worker 进程之间也不共享。 做产品要存数据库,第一次对话就把 `userId` 和当前步骤写进去。

下一篇把这些零件拼成真客服:关键词分流、报修流程,再挂一张图文选单。

相关笔记