python+redis让微信公众号根据上下文回复用户消息

2017-02-11 更新:

在实际应用过程中发现replay时候重复写数据的问题还是挺难绕开的,所以加了一个is_replay的新参数来解决。
所以现在发消息时需要这样:

msg_content, is_replay = yield xxxxx

然后如果希望某段代码在replay的时候不要执行,可以通过判断is_replay的值来实现。

另外新增了一个自定义异常UnexpectAnswer,用于静默处理用户的不合法输入。
想一下这个场景:当公众号让用户回复YES或者NO的时候,用户回复了一个START会怎么样呢?

现在有两种处理方式:

  1. 回复一个信息告诉用户他的输入有误,让他重新输入。 --- 这个通过return实现
  2. 直接找一下是否有逻辑处理START这条信息。 --- 这个通过raise UnexpectAnswer实现

具体请看说明和代码注释


最近打算用微信公众号做一些玩票小项目,研究了半天后发现很多功能对个人订阅号都不开放(需要认证),比如自定义菜单啦,管理图文消息啦,获取用户信息等等很基本的功能..所以只能用被动回复消息,通过一问一答的方式实现用户交互。

然而这就需要记录会话状态,对于相同的信息要根据状态的不同给出不同的回复,比如在刚关注公众号的时候,如果用户发送了一个“苹果”过来,我要返回给他一段帮助消息:

用户:苹果
公众号:你发苹果给我干嘛呀?这里是一段帮助信息...

但如果前一条消息是一个问题,用户发送“苹果”那我就需要返回对应的回复给用户,比如下面这样:

用户:苹果
公众号:你发苹果给我干嘛呀?这里是一段帮助信息...
用户:那你问我一个问题吧
公众号:请发送一个水果名字
用户:苹果
公众号:给你一个苹果= ̄ω ̄=

但是呢微信给出的官方例子并没有相关会话处理的部分,也没有找到类似的解决方案。
我觉得我需要一个轮子来解决这个问题,没有现成轮子的话就自己造一个吧

于是就有了下面这个小轮子,用到了python generator和redis。
代码开源在GITHUB上:arthurmmm/wechat-dialog

部署demo

代码中附带了一个Flask写的小demo,可以直接部署测试。另外代码是在python3下写的。

安装步骤如下:

  1. 根据requirement.txt安装依赖包(其实就redis和flask..)
  2. 安装一个Redis,知道它的IP地址,端口号,密码等信息
  3. 在demo_dialog.py的开头更改相应的REDIS配置信息
  4. 启动demo_server.py: python ./demo_server.py
  5. 去微信公众平台绑定公众号服务器

使用方法

demo_dialog.py是示例用的会话逻辑程序,需要根据业务要求配置一个类似的文件,具体用法参考源码中的注释,简单来讲就是用yield在一个函数内处理一段对话的所有问答信息,比如示例中的accumulate:

def accumulator(to_user):
    yield None
    msg_content, is_replay = yield None

    num_count, is_replay = yield ('TextMsg', '您需要累加几个数字?')
    try:
        num_count = int(num_count)
    except Exception:
        return ('TextMsg', '输入不合法!我们需要一个整数,请输入"开始"重新开启累加器')
    res = 0
    for i in range(num_count):
        num, is_replay = yield ('TextMsg', '请输入第%s个数字, 目前累加和:%s' % (i+1, res))
        try:
            num = int(num)
        except Exception:
            return ('TextMsg', '输入不合法!我们需要一个整数,请输入"开始"重新开启累加器')
        res += num

    # 注意:最后一个消息一定要用return不要用yield!return用于标记会话结束。
    return ('TextMsg', '累加结束,累加和: %s' % res)

上面这段代码运行起来是这个效果:

Paste_Image.png

配置完dialog逻辑后,将类似下面这段代码加入服务器,下面是flask上的配置方法,也可以用到其他python web框架下:

import wechat.bot
import demo_dialog

@app.route('/', methods=['POST'])
def wechat_post():
    data = request.get_data()
    return wechat.bot.answer(data, demo_dialog).format()

answer方法接收data和dialog模块作为参数,用dialog中定义的逻辑处理收到的用户信息data,最后返回一个replyMsg,再调用format格式化后作为回复。(关于公众号的receiveMsg和replyMsg可以参考微信官方文档,我基本是照搬的..)

设计思路

主要的轮子代码在wechat/bot.py中。wechat/reply.py和receive.py是根据微信官方文档做的消息类。

在调用answer函数后,bot会先根据用户的open_id检查对应的redis key,如果redis key中没有值或者出现意外状况,那么就认为这是一段新的对话,通过ROUTER中配置的静态映射关系,进入对应的对话处理函数并返回。

如果key中有值,那么bot就认为用户的这条信息是针对这段会话的一个回复消息,会从redis中取出之前的历时消息记录,不断触发yield重现会话上下文,到达正确的断点后返回:

    # 新会话或者会话超时,创建新会话
    if not hist:
        dialog = _new_dialog(msg_type, msg_content, to_user)
        logger.debug('new_dialog')
    # 存在会话记录,重现上下文
    else:
        logger.debug('replay_dialog')
        try:
            dialog = _replay_dialog(hist, to_user)
        except StopIteration:
            logger.error('会话记录错误..重新创建会话..')
            dialog = _new_dialog(msg_type, msg_content, to_user)

redis key默认设置了60秒的过期时间,用户60秒内不回复就丢弃这段会话。
key中的数据结构是一段json序列化的数组,第一个元素存储了dialog_handler的名字作为入口,后面是一组历时消息用于重现上下文:

[<dialog_handler_name>, <msg1>, <msg2>, <msg3> ....]

如果发现生成器return了(即抛出了StopIteration异常)就结束这段对话,回复用户return的消息并清空redis key。

    # 发送消息
    while True:
        try:
            type, msg = _redis_send(hkey, dialog, msg_content)
            break
        except StopIteration as e:
            # 会话已结束,删去redis中的记录
            type, msg = e.value
            redis_db.delete(hkey)
            break
        except UnexpectAnswer:
            # 用户发送了一个不合法的回复时抛出这个异常
            # BOT会认为用户希望开启一段新的会话
            redis_db.delete(hkey)
            dialog = _new_dialog(msg_type, msg_content, to_user)
            continue

存在的问题

这里用重现消息的方式来保存会话状态,所以在会话过程中做数据改动要慎重,比如

answer = yield xxx
<write database>
answer = yield xxx

在重现过程中<write database>会被多次执行,可能导致重复数据插入。

解决的办法:

  • 写操作统一在return的时候做
  • 写操作尽量用UPDATE不要用INSERT,避免重复插入
  • 直接用singleton代替redis,在进程内存中存储generator,不过这样可能在一些多进程服务器上出现问题..
  • 新增加is_replay返回值。在代码中可以通过这个值来判断这次调用是否是replay造成的,避免重复写入。

考虑过用pickle持久化但好像不支持generator..

代码量不多,更多细节可以看源码,欢迎吐槽。
轮子是顺手造的,代码写的比较随意还请见谅。。


PS: redis的expire真的是神器..我要成redis脑残粉了

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容