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

作者: ArthurMao | 来源:发表于2017-02-05 22:44 被阅读0次

    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脑残粉了

    相关文章

      网友评论

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

        本文链接:https://www.haomeiwen.com/subject/ysxyittx.html