Published on

对接飞书多维表格 | Feishu Bitable Integration: Webhook Sync & API Operations

Authors
  • avatar
    Name
    Shelton Ma
    Twitter

1. 使用飞书多维表格「自动化」将新增数据同步至系统

虽然可以通过定时轮询飞书多维表格 API 实现数据同步,但如果新增记录属于低频操作,不推荐使用轮询方式。

操作步骤如下:

  1. 系统端创建 Webhook 接口,用于接收飞书的回调请求。
  2. 在飞书多维表格中配置「自动化」流程:当用户新增记录时,触发调用上述 Webhook,实现系统端的数据写入。

2. 将多维表格的变更事件推送至系统

如果系统部署在公网环境中,可直接通过公开的 Webhook API 接收飞书的变更事件。

若系统未暴露在公网,可采取替代方案:

  • 在飞书多维表格中设置变更事件发送至某个群聊。
  • 系统侧通过运行 WebSocket 客户端监听该群聊中的机器人通知,从而感知并处理数据变更事件。

1. 基本文档说明

  1. npm install @larksuiteoapi/node-sdk
  2. 调用服务端 SDK
  3. 调用服务端 API

2. 使用长连接接收事件

与开放平台建立一条 WebSocket 全双工通道。后续当应用订阅的事件发生时,开放平台会通过该通道向你的服务器发送事件消息。无需提供外网可访问的服务

3. 总配置流程

飞书云文档事件 支持不太好, 通过 自动化 将变更发送到飞书群聊, 自建应用监听飞书群聊消息, 再通过系统调用多维表格 API 进行数据读写

  1. 创建应用

    • 应用中添加 机器人 能力
    • 在权限管理中添加应用身份权限: 查看、评论、编辑和管理多维表格 bitable:app im:message.group_msgim:message 读取群聊消息
    • 在事件与回调中, 订阅方式使用长连接接收事件, 至少订阅 drive.file.bitable_record_changed_v1 im.message.receive_v1
    • 记录 App IDApp Secret, 获取tenant_access_token - 服务端 API - 开发文档 - 飞书开放平台
  2. 创建多维表格, 需要添加创建的应用 ... > ...More > Add Applications,确保应用有权限调用多维表格 API。

  3. 配置多维表格自动化, 触发动作为 发送飞书消息到群聊, 同时发送人要是用户, 而不是应用或表格助手

    • 机器人发送的消息不会触发接收事件(事件只推送用户发送的消息), 这是飞书开放平台的主动消息防循环机制:
      • 防消息循环: 避免机器人发送消息后触发新事件,导致无限循环
      • 权限隔离: 机器人只能接收用户或其他机器人发送的消息(需开通 im:message.group_msg 权限)
      • 事件溯源: 所有事件均需明确来源于用户操作
    • 不必要(已经不再监听云文档变动, 而是通过自动化配置发送消息到群聊): 给多维表格订阅云文档事件
  4. 在本地启动websocket客户端, 并配置接收事件的回调函数, 将可以处理事件(3s内处理结束)

    import * as Lark from "@larksuiteoapi/node-sdk";
    
    const baseConfig = {
      appId: "xxx",
      appSecret: "xxx",
    };
    
    const client = new Lark.Client(baseConfig);
    
    const wsClient = new Lark.WSClient({
      ...baseConfig,
      loggerLevel: Lark.LoggerLevel.debug,
    });
    
    wsClient.start({
      // 处理「多维表格记录变更」事件,事件类型为 drive.file.bitable_record_changed_v1
      eventDispatcher: new Lark.EventDispatcher({})
        .register({
          "drive.file.bitable_record_changed_v1": async (data) => {
            console.log(data);
            // 示例操作:接收消息后,调用「发送消息」API 进行消息回复。
          },
        })
    });
    

3. 使用 飞书 SDK 对多维表格进行数据读写

1. 创建自建飞书应用

确保应用身份权限: 查看、评论、编辑和管理多维表格 bitable:app

2. 获取多维表格参数: app_token(obj_token)

3. 更新记录数据

4. 执行封装实现(不推荐)

import axios from "axios";
import keys from "../../configBridge";

interface FeishuConfig {
  app_id: string;
  app_secret: string;
}

interface TokenInfo {
  token: string;
  expiresAt: number; // Unix 时间戳(毫秒)
}

export class Feishu {
  private config: FeishuConfig;
  private tenantTokenInfo: TokenInfo | null = null;

  constructor() {
    this.config = {
      app_id: keys.feishu.FEISHU_APP_ID || "",
      app_secret: keys.feishu.FEISHU_APP_SECRET || "",
    };
  }

  // 判断 token 是否过期
  private isTokenExpired(tokenInfo: TokenInfo | null): boolean {
    if (!tokenInfo) return true;
    return Date.now() >= tokenInfo.expiresAt;
  }

  // 获取 tenant_access_token
  async getTenantAccessToken() {
    if (!this.isTokenExpired(this.tenantTokenInfo)) {
      return this.tenantTokenInfo!.token;
    }

    const response = await axios.post(
      "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
      {
        app_id: this.config.app_id,
        app_secret: this.config.app_secret,
      }
    );

    const data = response.data;
    if (data.code !== 0 || !data.tenant_access_token) {
      throw new Error(`获取 tenant_access_token 失败: ${data.msg}`);
    }

    const expiresIn = data.expire; // 秒
    this.tenantTokenInfo = {
      token: data.tenant_access_token,
      expiresAt: Date.now() + expiresIn * 1000 - 60_000, // 提前 60 秒过期
    };

    return this.tenantTokenInfo?.token;
  }

  // 获取 app_token (Bitable 应用级 token)
  async getAppToken(baseToken: string): Promise<string> {
    return baseToken;
  }

  // 更新记录
  async updateRecord(params: {
    appToken: string;
    tableId: string;
    recordId: string;
    fields: Record<string, any>;
  }): Promise<any> {
    const token = await this.getTenantAccessToken();

    const url = `https://open.feishu.cn/open-apis/bitable/v1/apps/${params.appToken}/tables/${params.tableId}/records/${params.recordId}`;
    const response = await axios.put(
      url,
      {
        fields: params.fields,
      },
      {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      }
    );

    if (response.data.code !== 0) {
      throw new Error(`更新记录失败: ${response.data.msg}`);
    }

    return response.data;
  }
}