【飞书机器人】自动执行后台任务并通知

初次使用飞书机器人,一开始以为只能使用常规的webhook进行通知,后来才发现自定义机器人功能非常强大,同时也有个小功能可以借用自定义机器人去更好的完成,以下是探索使用的记录

一、建立企业自建应用

1、登录 飞书开放平台
2、创建企业自建应用(输入名称、描述、图标等,点击创建即创建完毕了)

在这里插入图片描述

二、自定义机器人配置

1、侧边栏选择-添加应用能力-选择“机器人”

在这里插入图片描述

2、添加成功后,可以在侧边栏设置机器人功能
1)如何开始使用:这里设置机器人卡片上的机器人使用说明

在这里插入图片描述
2)机器人自定义菜单:这里设置与机器人私聊界面的快捷菜单
菜单状态设置为开启,再设置具体的菜单展现形式。
在这里插入图片描述

3)配置菜单:
可以配置具体的功能按钮
录入按钮名称后,可以设置这个按钮的具体功能,目前有3个响应动作:
①跳转至指定链接:类似于一个超链接

在这里插入图片描述

②推送事件,这个可以推送用自定义事件的ID,功能简单来讲就是:点击按钮时直接调用后端服务,从而进行处理及响应。(需要提前配置“【事件】配置”,后面会讲到)

在这里插入图片描述

③发送文字消息:点了这个按钮,会发送“名称”处录入的文字消息,和自己输入文字没啥区别,就是快一点,主要作用也是在于打了文字之后,机器人通过【事件】接收消息,并发送给后端服务,后端可以对不同的消息进行处理及响应,和第二点推送事件效果类似,差异在于两者推送事件时,请求体中的event_type,一个是消息:im.message.receive_v1,一个是自定义事件:application.bot.menu_v6

在这里插入图片描述

三、事件与回调

侧边栏选择-开发配置-事件与回调

1、事件配置
①选择订阅方式,分为两种:一种为长连接,一种为将事件发送至开发者服务器。
a.长连接:需要使用官方SDK。
b.将事件发送至 开发者服务器:这个需要提前部署个接口在飞书可访问的服务器上,按要求返回响应内容,就配置成功了。后续请求地址路径不能改了,改了就要重新验证。

在这里插入图片描述

②、添加事件
当机器人被触发相关事件后,向后端服务发送具体消息body,后端根据body,做出具体的事务处理及响应。

在这里插入图片描述

③、开通事件权限
添加某些事件后,还需要开通权限,可以按需开通

在这里插入图片描述

2、回调配置
①、订阅方式与事件的订阅方式相似,不再累述了
②、添加回调
回调的可选内容比较少,我选择的时卡片回传交互,可以在用户点击卡片按钮时请求后端,另外使用卡片回调时,后端的提示可以通过前端toast进行提示,比较友好

return {'toast': {'type': 'success', 'content': '验证码已发送'}}

在这里插入图片描述

3、加密策略:用于加密事件或回调的请求内容,校验请求来源。当订阅方式为“将事件发送至开发者服务器”或“将回调发送至开发者服务器”时生效
我并没有使用(主要是调试会比较麻烦),想用的话见官方文档:
配置 Encrypt Key

四、版本管理与发布

侧边栏选择-应用发布-版本管理与发布
如果修改的内容需要通过发布才能生效,页面会有提示:

在这里插入图片描述

点击创建本进入发布内容编辑页面(或者通过创建版本按钮进入)
该页面主要编辑版本的修改内容,以及可用范围,涉及可用范围变更时,可能需要系统管理员进行审核

在这里插入图片描述

为了避免这种麻烦的流程,我只把可用范围定位了本人所在的部门,这样就避免了的审核。
发布成功后,飞书的开发小助手会提示发布成功。

在这里插入图片描述

五、飞书卡片

飞书卡片是交互的重要媒介,飞书提供了飞书卡片搭建工具,方便快速制作卡片
飞书卡片搭建工具

在这里插入图片描述
注意一下,已发布的卡片就不能删除了,我只能写“废弃”了

以我的卡片描述下具体功能:

1、左侧工具栏添加容器(不添加容器,直接使用文本框等,可能会有问题),我这边使用了表单容器,用于提交手机号、验证码。一共用到了2个表单容器,一个提交手机号,一个提交验证码。

在这里插入图片描述

2、设置输入框的属性,主要是一些前端的显示属性

在这里插入图片描述

输入框不支持事件哦,点击事件,进去后发现添加不了事件。

3、设置提交按钮的属性值:
其中表单标识符后续需要用到,用于辨识是哪个按钮提交的数据

button_name = callback_body['event']['action']['name']

在这里插入图片描述

4、设置提交按钮的事件
点击创建事件,可以设置点击时的具体动作,我们这里选择请求回调,前端填写的手机号将被赋值到input_mobile这个变量上(变量可以在事件处直接定义,也可以在外面"X"标记处进行定义)

submit_data = callback_body['event']['action'].get('form_value')
if button_name == 'button_get_code':
    if submit_data:
        mobile = submit_data.get('input_mobile') or ''

在这里插入图片描述

5、同理设置短信验证码的输入框与提交框,我这边需要把已经获取到的手机号传入卡片,一起呈现在前端,需要把变量值和卡片一起发送。
在这里插入图片描述

return await feishu_send_msg_internal(
                                receive_id_type = 'open_id',
                                send_msg = SendMessageRequestModel(
                                    receive_id = open_id,
                                    msg_type = 'interactive',
                                    content = get_format_card('AAqjQxxxxxx', exist_mobile = exist_mobile)
                                ),
                                redis_pool = redis_pool
                            )
# 返回格式化的卡片字符串
def get_format_card(template_id: str, **template_variable) -> str:
    json_data = {
        'type': 'template',
        'data': {
            'template_id': template_id,
            'template_variable': template_variable
            }
        }
    return json.dumps(json_data, ensure_ascii = False)

6、其他类型的前端组件传入变量也是相同操作

在这里插入图片描述

在这里插入图片描述

return await feishu_send_msg_internal(
                                receive_id_type = 'open_id',
                                send_msg = SendMessageRequestModel(
                                    receive_id = open_id,
                                    msg_type = 'interactive',
                                    content = get_format_card('AAqjQZxxxxxx', query_status_array = [
                                        {'run_time': morning_time, 'run_status': status_dict[morning_status]},
                                        {'run_time': afternoon_time, 'run_status': status_dict[afternoon_status]},
                                    ])
                                ),
                                redis_pool = redis_pool
                            )

7、循环容器的使用
主要是生成数据列表,使用方法如下:
在这里插入图片描述
在这里插入图片描述

在变量设置内设置好模拟数据,即可在卡片编辑页面,看到预览样式
在这里插入图片描述
文本内容也可以设置样式,加粗,颜色,等等
在这里插入图片描述

后端组织好数据后,通过发送消息接口发给用户

  # 考试列表字典
  task_list = request_info.get('task_list', {}).get('list')
  if task_list:
      # 飞书卡片内得字段值与响应体得字段值对照
      key_mapping = {
          'title': 'name',
          'create_user_name': 'avatar',
          'makeup_count': 'count',
          'score': 'score',
          'total_score': 'total_score',
          'begin_time': 'start_time',
          'end_time': 'end_time',
          'task_id': 'task_id',
          'id': 'exam_id'
          }

      def convert_value(key, value):
          if key in ['begin_time', 'end_time']:
              return datetime.datetime.fromtimestamp(int(value) / 1000).strftime('%Y-%m-%d %H:%M:%S')
          return value

      exam_list = [{key_mapping.get(old_key, old_key): convert_value(old_key, value) for old_key, value in item.items() if old_key in key_mapping.keys()} for item in task_list]

      # 发送飞书卡片
      send_message(open_id, is_text = False, template_id = 'AAqSDQnjaoppy', exam_list = exam_list)

7、设计完成后,可以把卡片发送给自己,看下具体UI效果

在这里插入图片描述
8、全部完成后即可以发布了,发布前需要绑定之前添加的机器人

在这里插入图片描述

9、后续使用卡片时,使用卡片的唯一ID即可(如何发送消息后面讲)

在这里插入图片描述

六、发送飞书消息

我使用的飞书消息为私聊消息,群组消息,以及卡片消息,都是通过飞书提供的发送消息接口发送的。
发送消息

其实也没什么好多说的,就是调用飞书提供的接口,发送各种消息,以下是我的代码:

async def feishu_send_msg_internal(
        redis_pool: aioredis.Redis,
        receive_id_type: ReceiveIdType,
        send_msg: SendMessageRequestModel
        ) -> dict:
    try:
        access_token = await get_access_token_internal(redis_pool)
    except Exception as e:
        raise Exception(f'获取tenant_access_token失败:{e}')

    url = 'https://open.feishu.cn/open-apis/im/v1/messages'
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {access_token}'
        }
    params = {"receive_id_type": receive_id_type}
    json_data = send_msg.model_dump()

    try:
        response = await fetch(method = 'POST', url = url, headers = headers, params = params, data = json_data)
        try:
            if response['code'] == 0:
                return {'code': 0, 'msg': '发送成功'}
            else:
                raise Exception(f'发送飞书消息失败:{response}')
        except KeyError as e:
            raise Exception(f'解析飞书返回数据失败,{e}:{response}')

    except Exception as e:
        raise Exception(f'请求发送飞书消息接口失败:{e}')

以下是使用飞书sdk的发送消息的代码,并做了一定修改:

# 发送个人即时消息
def send_message(
        receive_id: str,
        is_text: bool = True,
        is_image: bool = False,
        image_key: str = None,
        is_personal: bool = True,
        universal_text: str = None,
        target: Union[List[str], str] = None,
        template_id: str = None,
        client: Client = callback_client,
        max_retries: int = 3,
        retry_interval: int = 2,
        **template_variable
        ):
    """
    飞书发送消息
    :param receive_id:接收者ID
    :param is_text:是否为文本消息,不是则为卡片消息
    :param is_image: 新增发送图片消息参数
    :param image_key: 图片的key,通过create_image方法上传图片后获取
    :param is_personal:是否发给个人,不是则发给群组
    :param universal_text:发送的文本消息内容
    :param target:发送文本消息时需要@的用户/用户列表
    :param template_id:飞书卡片模板ID
    :param client:飞书sdk客户端
    :param max_retries:请求最大重试次数
    :param retry_interval:重试间隔时间
    :param template_variable:卡片模板变量
    :return:
    """
    request: CreateMessageRequest = CreateMessageRequest.builder() \
        .receive_id_type('open_id' if is_personal else 'chat_id') \
        .request_body(
        CreateMessageRequestBody.builder()
        .receive_id(receive_id)
        .msg_type(('text' if is_text else 'interactive') if not is_image else 'image')
        .content((get_format_text(universal_text, target) if is_text else get_format_card(template_id, **template_variable)) if not is_image else get_format_image(image_key))
        .build()
        ) \
        .build()

    for retry_count in range(max_retries):
        try:
            # 发起请求
            response: CreateMessageResponse = client.im.v1.message.create(request)

            if response.success():
                return response.data.message_id
            else:
                lark.logger.error(
                    f"client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent = 4, ensure_ascii = False)}"
                    )
                return None

        except Exception as e:
            if retry_count < max_retries - 1:
                time.sleep(retry_interval)
                continue
            else:
                lark.logger.error(f"send message failed after {max_retries} retries, error: {e}")
                return None

# 返回格式化的text字符串
def get_format_text(universal_text: str, target: Union[List[str], str] = None) -> str:
    if isinstance(target, list):
        full_str = ''.join([f'<at user_id="{open_id}"></at>' for open_id in target])
        return json.dumps({'text': universal_text + full_str}, ensure_ascii = False)
    elif isinstance(target, str):
        return json.dumps({'text': universal_text + f'<at user_id="{target}"></at>'}, ensure_ascii = False)
    else:
        return json.dumps({'text': universal_text}, ensure_ascii = False)


# 返回格式化的卡片字符串
def get_format_card(template_id: str, need_str: bool = True, **template_variable) -> Union[str, dict]:
    json_data = {
        'type': 'template',
        'data': {
            'template_id': template_id,
            'template_variable': template_variable
            }
        }
    if need_str:
        return json.dumps(json_data, ensure_ascii = False)
    else:
        return json_data


# 返回格式化的图片字符串
def get_format_image(image_key: str) -> str:
    return json.dumps({'image_key': image_key}, ensure_ascii = False)

七、关于长连接,发送至开发者服务器的详细说明

1、发送至开发者服务器:
之前提到过,这个借助于开发者自己部署的服务,去和飞书服务器进行交互,我这边使用的是fastapi。
下面是项目启动的main.py

import asyncio
import time

import uvicorn
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware

from apis import receive_code_router, send_code_router, trigger_login_router, remove_bind_router
from backgroundTask import background_task, gen_today_times
from caches.cache import create_pool, close_pool
from feishu.feishuAccessToken import feishu_access_token_router
from feishu.feishuCallback import feishu_callback_router
from feishu.feishuEvent import feishu_event_router
from feishu.feishuGetGroup import feishu_get_group_router
from feishu.feishuSendMsg import feishu_send_msg_router
from feishu.feishuGetGroupMember import feishu_get_group_member_router
from feishu.feishuSubscribe import feishu_subscribe_router

# openApi文档设置
app = FastAPI(
    title = 'GXT Auto Login API Docs',
    description = '自动登录使用的接口,使用的fastApi的后端接口文档',
    version = '1.0.0',
    docs_url = '/docs',
    redoc_url = '/redoc'
    )

# 挂载static文件
app.mount(
    path = '/static',
    app = StaticFiles(directory = 'static'),
    name = 'static'
    )

# 初始化后台任务调度器
scheduler: AsyncIOScheduler = AsyncIOScheduler()


@app.on_event('startup')
async def startup_event():
    # 项目启动时创建redis连接池
    create_pool_task = asyncio.create_task(create_pool())
    await asyncio.wait([create_pool_task])
    redis_pool = create_pool_task.result()

    # 设置使用代理初始值
    await redis_pool.set('use_proxy', 1)

    # 爬取代理地址的网站,代理地址可用性太差,没几个能用的,放弃使用代理池了
    # scheduler.add_job(proxy_pool_task, 'cron', hour = 22, minute = 0, second = 0, args = [redis_pool])

    # 启动时先添加生成第二天任务运行时间的任务
    scheduler.add_job(gen_today_times, 'cron', hour = 1, minute = 0, second = 0, args = [redis_pool])

    # 添加后台任务,3分钟执行一次,任务内部判断是否需要执行
    scheduler.add_job(background_task, 'cron', minute = '*/3', args = [redis_pool])
    scheduler.start()


@app.on_event('shutdown')
async def shutdown_event():
    await close_pool()
    scheduler.shutdown()


# 设置CORS跨域
app.add_middleware(
    CORSMiddleware,
    allow_origins = ["*"],
    allow_credentials = True,
    allow_methods = ["GET", "POST"],
    allow_headers = ["*"], 
    )

# 挂载router
app.include_router(feishu_event_router, prefix = '', tags = ['飞书接口'])
app.include_router(feishu_callback_router, prefix = '', tags = ['飞书接口'])

if __name__ == '__main__':
    uvicorn.run(
        app = 'main:app',
        host = '0.0.0.0',
        port = 16666,
        # reload = True
        )

2、飞书长连接
长连接的模式就类似于一个服务,启动服务后,再调用相关sdk接口,即可进行交互,自己无需再启动其他的后端服务,如django,flask等。当然启动长连接服务时,我有考虑使用异步,但是目前的sdk并不支持,所以其性能犹未可知。
下面是长连接的服务器启动代码:

import concurrent.futures
from functools import partial

import lark_oapi as lark

from apscheduler.schedulers.background import BackgroundScheduler

from caches.cache import create_pool, close_pool
from caches.cacheConfig import *
from feishu.feishuConfig import APP_ID, APP_SECRET
from feishu.feishuEvent import do_p2_im_message_receive_v1, do_p2_im_chat_member_bot_added_v1, do_p2_im_chat_member_user_added_v1, do_chat_create, do_card_action_trigger
from timedTask.autoLogin import auto_login_task
from timedTask.genTodayTimes import gen_today_times
from timedTask.syncRedisData import sync_redis_data

if __name__ == '__main__':
    # 创建redis连接池
    redis_client = create_pool()

    # 删除原有的代理设置,恢复成默认状态
    redis_client.delete(USE_PROXY_KEY)
    redis_client.delete(USE_TIMED_TASK_KEY)

    # 由于需要在事件中传入redis_client,所以需要使用partial函数进行封装,同时也不能分文件写,需要在main.py中定义,统一管理
    do_p2_im_message_receive_v1_with_redis = partial(do_p2_im_message_receive_v1, redis_client)
    do_card_action_trigger_with_redis = partial(do_card_action_trigger, redis_client)

    # 注册飞书事件
    # https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/server-side-sdk/python--sdk/handle-events
    event_handler = lark.EventDispatcherHandler.builder("", "") \
        .register_p2_im_message_receive_v1(do_p2_im_message_receive_v1_with_redis) \
        .register_p2_im_chat_member_bot_added_v1(do_p2_im_chat_member_bot_added_v1) \
        .register_p2_im_chat_member_user_added_v1(do_p2_im_chat_member_user_added_v1) \
        .register_p1_customized_event('p2p_chat_create', do_chat_create) \
        .register_p2_card_action_trigger(do_card_action_trigger_with_redis) \
        .build()

    # 创建飞书事件客户端
    event_client = lark.ws.Client(
        APP_ID, APP_SECRET,
        event_handler = event_handler,
        log_level = lark.LogLevel.DEBUG
        )

    # 创建线程池
    executor = concurrent.futures.ThreadPoolExecutor(max_workers = 30)


    def start_client():
        event_client.start()


    # 启动定时任务
    scheduler = BackgroundScheduler()
    scheduler.add_job(sync_redis_data, 'cron', hour = 3, minute = 0, second = 0)
    scheduler.add_job(gen_today_times, 'cron', hour = 1, minute = 0, second = 0, args = [redis_client])
    scheduler.add_job(auto_login_task, 'interval', minutes = 3, args = [redis_client])
    scheduler.start()

    # 启动事件监听
    future = executor.submit(start_client)

    try:
        future.result()
    except KeyboardInterrupt:
        executor.shutdown()
        scheduler.shutdown()
        close_pool()

八、关于标题提到的定时任务

在上面的启动代码中已经写到了,使用apscheduler可以实现后台任务的执行,其支持各种定时任务,也兼容异步框架,也不再累述了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值