飞书机器人接入博客

从零开始:将你的 Web 项目接入飞书机器人

本文以「智造中枢:销售生产一体化管理系统(SPMS)」为例,手把手演示如何用 Python + Flask + lark-oapi SDK,通过 WebSocket 长连接 将一个已有的 Web 系统接入飞书机器人,实现群聊指令驱动的业务操作。


一、为什么选择飞书机器人?

传统 ERP / MES 系统的痛点是:操作人员必须坐在电脑前打开浏览器才能干活。而在制造业场景中,车间工人、销售外勤更习惯用手机沟通。飞书机器人的价值在于——把系统功能搬进群聊,用一句话完成操作

传统方式 飞书机器人方式
打开浏览器 → 登录 → 找菜单 → 填表单 → 提交 群里 @机器人 发一句「报工MO202506001 50件」
必须在电脑前 手机随时随地
多系统切换 一个群搞定

二、技术选型:WebSocket 长连接 vs Webhook 回调

飞书开放平台提供了两种事件订阅方式:

对比项 Webhook 回调 WebSocket 长连接
公网要求 需要公网可访问的 URL ❌ 不需要
开发复杂度 需处理验签、加解密 SDK 全部内置
本地调试 需内网穿透(frp/ngrok) 直接本地运行
适用场景 生产环境部署 开发调试、内网环境
卡片交互 ✅ 支持 card.action.trigger ❌ 不支持

本文选择 WebSocket 长连接,原因很简单:开发阶段不需要公网 IP,本地直接跑,5 分钟就能验证。


三、飞书后台配置

3.1 创建企业自建应用

  1. 打开 飞书开放平台,登录后进入「开发者后台」
  2. 点击「创建企业自建应用」,填写应用名称和描述
  3. 创建完成后,在「凭证与基础信息」页面获取三个关键参数:
App ID:            cli_xxxxxxxxxxxxxxxx
App Secret:        xxxxxxxxxxxxxxxxxxxxxxxxxx
Verification Token: xxxxxxxxxxxxxxxxxxxxxxxxxx

这三个值后续会写进 Python 代码中。

3.2 配置权限

进入「权限管理」页面,搜索并开通以下权限:

权限名称 权限标识 用途
获取与发送单聊、群组消息 im:message 收发消息
读取用户发给机器人的单聊消息 im:message.receive_v1 接收用户消息
以应用的身份发消息 im:message:send_as_bot 机器人发消息
获取群组信息 im:chat:readonly 获取群聊 ID

3.3 配置事件订阅

  1. 进入「事件订阅」页面
  2. 订阅方式选择 「使用长连接接收事件」(重要!不要选 Webhook)
  3. 添加事件:搜索 im.message.receive_v1(接收消息),勾选并保存

⚠️ 避坑提示card.action.trigger(卡片按钮回调)不支持 WebSocket 长连接,只能走 Webhook。如果你需要卡片交互,必须额外配置公网回调地址。本文用纯文本指令方案,无需卡片。

3.4 发布应用

配置完成后,在「版本管理与发布」页面创建版本并提交审核。企业自建应用通常审核很快。

3.5 将机器人加入群聊

在飞书群聊设置中 →「群机器人」→「添加机器人」→ 选择你创建的应用。


四、安装 SDK

pip install lark-oapi

建议使用清华镜像源加速:pip install lark-oapi -i https://pypi.tuna.tsinghua.edu.cn/simple

验证安装:

import lark_oapi as lark
from lark_oapi.ws import Client as WsClient
print('SDK 加载成功')

五、编写飞书长连接客户端

5.1 核心架构

整个飞书机器人客户端的架构非常简洁:

飞书服务器 (wss://msg-frontier.feishu.cn)
        ↕  WebSocket 长连接
本地 Python 进程 (lark_oapi SDK)
        ↕  事件回调
   handle_message()
        ↕  操作数据库
   Flask app / SQLAlchemy

5.2 完整代码

以下是项目中 feishu_ws.py 的核心结构,去掉业务逻辑后的骨架:

"""
飞书长连接事件订阅客户端
使用 lark_oapi SDK 的 WebSocket 长连接接收事件,无需公网URL
"""
import json
import os
import sys
import logging
import re

# ========== 1. 应用凭证 ==========
APP_ID = 'cli_xxxxxxxxxxxxxxxx'
APP_SECRET = 'xxxxxxxxxxxxxxxxxxxxxxxx'
VERIFICATION_TOKEN = 'xxxxxxxxxxxxxxxxxxxxxxxx'

# ========== 2. 导入 SDK ==========
import lark_oapi as lark
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
from lark_oapi.ws import Client as WsClient
from lark_oapi.event.dispatcher_handler import EventDispatcherHandlerBuilder

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger('feishu_bot')


# ========== 3. 发送消息函数 ==========
def send_message(client, chat_id, text):
    """发送文本消息到飞书群聊"""
    try:
        req = CreateMessageRequest.builder() \
            .receive_id_type('chat_id') \
            .request_body(
                CreateMessageRequestBody.builder()
                    .receive_id(chat_id)
                    .msg_type('text')
                    .content(json.dumps({'text': text}, ensure_ascii=False))
                    .build()
            ) \
            .build()
        resp = client.im.v1.message.create(req)
        if resp.success():
            logger.info(f'消息已发送到 {chat_id}')
        else:
            logger.error(f'发送失败: code={resp.code} msg={resp.msg}')
    except Exception as e:
        logger.error(f'发送异常: {e}')


# ========== 4. 业务处理函数 ==========
def query_orders():
    """示例:查询最近订单"""
    from app import create_app
    app = create_app()
    with app.app_context():
        from models.sales_order import SalesOrder
        orders = SalesOrder.query.order_by(
            SalesOrder.created_time.desc()
        ).limit(5).all()
        if orders:
            lines = ['📋 最近5个订单\n']
            for o in orders:
                lines.append(f'• {o.order_id} | {o.customer_name} | ¥{float(o.total_amount):,.0f}')
            return '\n'.join(lines)
        return '暂无订单数据'


# ========== 5. 消息处理路由 ==========
def handle_message(client, event):
    """处理收到的消息事件"""
    try:
        msg = event.event.message
        chat_id = msg.chat_id
        message_type = msg.message_type

        # 只处理文本消息
        if message_type != 'text':
            return

        # 解析消息内容
        content = json.loads(msg.content)
        text = content.get('text', '').strip()

        # 清理 @机器人 的飞书内部ID
        text = re.sub(r'@_user_\d+', '', text).strip()
        text_clean = re.sub(r'\s+', '', text.lower())

        # ===== 命令路由 =====

        # 帮助
        if any(kw in text_clean for kw in ['帮助', 'help', '?', '?']) or not text_clean:
            reply = (
                '📋 机器人命令列表\n\n'
                '  • 订单 - 查询最近订单\n'
                '  • 库存 - 查询库存预警\n'
                '  • 帮助 - 显示本菜单'
            )
            send_message(client, chat_id, reply)

        # 查询订单
        elif '订单' in text_clean:
            reply = query_orders()
            send_message(client, chat_id, reply)

        # 未识别
        else:
            send_message(client, chat_id,
                         f'未识别命令: "{text[:50]}"\n发送"帮助"查看可用命令')

    except Exception as e:
        logger.error(f'handle_message error: {e}', exc_info=True)


# ========== 6. 创建事件处理器 ==========
def create_event_handler(client):
    """注册事件处理器"""
    builder = EventDispatcherHandlerBuilder(
        encrypt_key='',                          # WebSocket 模式无需加密密钥
        verification_token=VERIFICATION_TOKEN,
    )
    # 注册「接收消息」事件
    builder.register_p2_im_message_receive_v1(
        lambda event: handle_message(client, event)
    )
    return builder.build()


# ========== 7. 启动长连接 ==========
def start_ws_client():
    """启动飞书长连接客户端(阻塞)"""
    logger.info('飞书长连接客户端启动中...')

    # 创建 API Client(用于主动发消息)
    client = lark.Client.builder() \
        .app_id(APP_ID) \
        .app_secret(APP_SECRET) \
        .log_level(lark.LogLevel.INFO) \
        .build()

    # 创建事件处理器
    event_handler = create_event_handler(client)

    # 创建 WebSocket 长连接客户端
    ws_client = WsClient(
        app_id=APP_ID,
        app_secret=APP_SECRET,
        event_handler=event_handler,
        log_level=lark.LogLevel.INFO,
        auto_reconnect=True,           # 断线自动重连
    )

    logger.info('正在连接飞书服务器...')
    logger.info('请在飞书群聊中 @机器人 发送消息测试')

    # 启动(阻塞主线程)
    ws_client.start()


if __name__ == '__main__':
    start_ws_client()

5.3 代码结构解析

整个客户端由 7 个部分组成,下面逐一说明:

序号 模块 作用
1 应用凭证 App ID / App Secret / Token,从飞书后台获取
2 SDK 导入 导入 lark_oapi 的 Client、WsClient、EventDispatcher
3 send_message 封装发消息 API,传入 chat_id 和文本内容
4 业务函数 查询/创建/修改/删除等业务逻辑,操作数据库
5 handle_message 消息路由中心,解析用户输入并分发到对应业务函数
6 create_event_handler 用 Builder 注册 im.message.receive_v1 事件
7 start_ws_client 创建 Client + WsClient,调用 ws_client.start() 阻塞运行

六、与 Flask 应用集成

飞书机器人需要读写数据库,但你的 Web 应用用的是 Flask + SQLAlchemy 的应用上下文模式。直接在机器人代码中查询数据库会报 RuntimeError: Working outside of application context

解决方案很简单——在机器人代码中手动创建 Flask 应用上下文

def query_orders():
    from app import create_app
    app = create_app()
    with app.app_context():
        from models.sales_order import SalesOrder
        orders = SalesOrder.query.order_by(
            SalesOrder.created_time.desc()
        ).limit(5).all()
        # ... 处理查询结果

每个业务函数都独立创建一次 appapp_context,这样既能复用 Flask 的模型定义,又不会与 Web 服务的 Flask 实例冲突。


七、多行文本指令解析

飞书机器人不仅能处理单行命令(如「工单」),还能解析多行结构化输入。在 SPMS 项目中,创建工单的指令是这样的:

新建工单
编码: P001
名称: 铝合金外壳
部门: 第一车间
数量: 1000
开工: 2025-06-25
完工: 2025-07-10
优先级: 3
订单: SO202406001

解析逻辑使用正则按行提取键值对:

def create_mo(text):
    import re

    # 键名映射表
    key_map = {
        '编码': 'product_code', '产品编码': 'product_code',
        '名称': 'product_name', '产品名称': 'product_name',
        '部门': 'production_dept', '生产部门': 'production_dept',
        '数量': 'plan_qty', '计划数量': 'plan_qty',
        '开工': 'plan_start', '计划开工': 'plan_start',
        '完工': 'plan_end', '计划完工': 'plan_end',
        '优先级': 'priority',
        '订单': 'order_id', '关联订单': 'order_id',
    }

    # 逐行解析
    fields = {}
    for line in text.split('\n'):
        line = line.strip()
        if not line:
            continue
        m = re.match(r'(.+?)[::]\s*(.+)', line)   # 支持中英文冒号
        if not m:
            continue
        key_raw = m.group(1).strip()
        val_raw = m.group(2).strip()
        if key_raw in key_map:
            fields[key_map[key_raw]] = val_raw

    # 校验必填字段
    required = ['product_code', 'product_name', 'production_dept',
                'plan_qty', 'plan_start', 'plan_end']
    missing = [f for f in required if f not in fields]
    if missing:
        return f'❌ 缺少必填字段: {", ".join(missing)}'

    # ... 创建工单逻辑

关键设计点:

  • 键名映射表支持多种叫法(如「编码」和「产品编码」等价)
  • 冒号同时支持中文 和英文 :
  • 必填字段校验 + 友好的错误提示
  • 日期支持多种格式(2025-06-256月25日2025/06/25

八、运行多个机器人

在 SPMS 项目中,我们创建了两个独立的飞书应用,分别负责不同业务域:

机器人 App ID 职责 脚本文件
销售通用机器人 cli_aaba20... 订单 CRUD、库存查询 feishu_ws.py
生产专用机器人 cli_aab3fb... 工单 CRUD、排产、报工 feishu_ws_production.py

两个机器人的代码结构完全相同,区别仅在于:

  1. App ID / App Secret 不同(各自对应一个飞书应用)
  2. 业务函数不同(销售机器人操作订单,生产机器人操作工单)
  3. 命令路由不同(各自只响应自己业务域的命令)

这样做的好处是职责隔离——销售群只加销售机器人,生产群只加生产机器人,互不干扰。


九、一键启动脚本

Web 服务和飞书机器人是独立进程,需要同时启动。我们用 start.py 统一管理:

"""
SPMS 项目统一启动脚本
一键启动: Flask服务 + 飞书销售机器人 + 飞书生产机器人
"""
import os
import sys
import time
import signal
import subprocess
import threading

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PYTHON = sys.executable

processes = []          # [(name, proc), ...]
lock = threading.Lock()


def log(msg):
    print(f'[{time.strftime("%H:%M:%S")}] {msg}', flush=True)


def start_flask():
    """启动 Flask Web 服务"""
    log('启动 Flask 服务...')
    proc = subprocess.Popen(
        [PYTHON, '-u', os.path.join(BASE_DIR, 'run_flask.py')],
        cwd=BASE_DIR,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        bufsize=1,
        universal_newlines=True,
    )
    processes.append(('Flask', proc))
    log(f'Flask 已启动 (PID={proc.pid}),访问: http://127.0.0.1:5000')


def start_feishu_ws(script_name, description):
    """启动飞书长连接脚本"""
    log(f'启动 {description}...')
    proc = subprocess.Popen(
        [PYTHON, '-u', os.path.join(BASE_DIR, script_name)],
        cwd=BASE_DIR,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        bufsize=1,
        universal_newlines=True,
    )
    processes.append((description, proc))
    log(f'{description} 已启动 (PID={proc.pid})')


def shutdown(sig=None, frame=None):
    """优雅关闭所有子进程"""
    log('正在停止所有服务...')
    with lock:
        for name, proc in processes[:]:
            if proc.poll() is None:
                log(f'  终止 {name} (PID={proc.pid})...')
                proc.terminate()
        for name, proc in processes[:]:
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
    log('所有服务已停止')
    sys.exit(0)


def main():
    signal.signal(signal.SIGINT, shutdown)
    signal.signal(signal.SIGTERM, shutdown)

    log('=' * 50)
    log('SPMS 智造中枢 - 启动中...')
    log('=' * 50)

    start_flask()
    time.sleep(2)

    start_feishu_ws('feishu_ws.py', '飞书-销售机器人')
    time.sleep(2)
    start_feishu_ws('feishu_ws_production.py', '飞书-生产机器人')

    log('=' * 50)
    log('所有服务已启动!')
    log('  Flask:      http://127.0.0.1:5000')
    log('  飞书销售:   运行中')
    log('  飞书生产:   运行中')
    log('提示: 按 Ctrl+C 停止所有服务')
    log('=' * 50)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        shutdown()


if __name__ == '__main__':
    main()

启动:

python start.py

停止:
Ctrl+C,所有子进程优雅退出。


十、实际运行效果

启动后,控制台输出:

[22:30:01] ==================================================
[22:30:01] SPMS 智造中枢 - 启动中...
[22:30:01] ==================================================
[22:30:01] 启动 Flask 服务...
[22:30:01] Flask 已启动 (PID=12345),访问: http://127.0.0.1:5000
[22:30:03] 启动 飞书-销售机器人...
[22:30:03] 飞书-销售机器人 已启动 (PID=12346)
[22:30:05] 启动 飞书-生产机器人...
[22:30:05] 飞书-生产机器人 已启动 (PID=12347)
[22:30:07] ==================================================
[22:30:07] 所有服务已启动!
[22:30:07]   Flask:      http://127.0.0.1:5000
[22:30:07]   飞书销售:   运行中
[22:30:07]   飞书生产:   运行中
[22:30:07] 提示: 按 Ctrl+C 停止所有服务

在飞书群聊中 @机器人,发送命令:

用户: @生产机器人 帮助
机器人: 🔧 SPMS 生产计划机器人
        查询类:
          • 工单 - 查询最近工单
          • MOxxx - 查看工单详情
          ...
用户: @生产机器人 报工MO202506001 50件
机器人: 📊 报工成功
        工单号: MO202506001
        产品: 智能控制器A型
        本次完成: 50件
        累计完成: 50/500 (10.0%)

十一、踩坑记录

坑1:飞书长连接启动时阻塞

ws_client.start() 是阻塞调用。如果在 Flask 的 app.py 中直接启动,会卡住 Web 服务。

解决方案: 将飞书长连接放在独立进程中运行,用 start.py 统一管理。

坑2:card.action.trigger 不支持 WebSocket

想在飞书群里发交互卡片(带按钮),用户点击按钮触发回调——这个事件只能走 Webhook,WebSocket 长连接模式下收不到。

解决方案: 改用纯文本指令方案,所有操作通过消息文本完成。

坑3:@_user_N 污染消息内容

用户在群里 @机器人 时,飞书会在消息文本中插入 @_user_1 这样的内部标记,直接用 text 做命令匹配会失败。

解决方案: 用正则清理:

text = re.sub(r'@_user_\d+', '', text).strip()

坑4:数据库操作需要 Flask 上下文

飞书机器人是独立进程,不在 Flask 请求上下文中,直接查数据库会报错。

解决方案: 每个业务函数手动创建应用上下文:

from app import create_app
app = create_app()
with app.app_context():
    # 数据库操作

坑5:控制台 emoji 显示乱码

Windows 控制台默认 GBK 编码,print 含 emoji 的字符串会报 UnicodeEncodeError

解决方案: 重定向标准输出编码:

import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

十二、总结

将 Web 项目接入飞书机器人,核心只需要四步:

1. 飞书后台创建应用,获取凭证
2. pip install lark-oapi
3. 写一个 WebSocket 长连接客户端(~100 行代码)
4. 在 handle_message 中做命令路由 → 调用业务函数

不需要公网 IP,不需要内网穿透,不需要 Nginx——这是 WebSocket 长连接模式最大的优势。对于内部使用的企业管理系统,这套方案开发快、部署简单、维护成本低。

完整项目代码结构:

spms/
├── app.py                      # Flask 应用工厂
├── config.py                   # 配置(含飞书凭证)
├── start.py                    # 一键启动脚本
├── run_flask.py                # Flask 启动入口
├── feishu_ws.py                # 飞书-销售机器人
├── feishu_ws_production.py     # 飞书-生产机器人
├── models/                     # 数据模型
│   ├── sales_order.py
│   ├── production.py
│   └── material.py
├── blueprints/                 # Flask 路由
└── templates/                  # Web 页面

本文基于真实项目「智造中枢:销售生产一体化管理系统(SPMS)」,项目使用 Flask + SQLAlchemy + SQLite + lark-oapi,包含销售订货、生产计划、物料管控、销售发货等完整业务模块,两个飞书机器人分别覆盖销售和生产场景。

技术栈:Python 3.13 / Flask / SQLAlchemy / lark-oapi / WebSocket

posted on 2026-06-29 23:02  不耻  阅读(142)  评论(0)    收藏  举报

导航