先交代一下背景。我所在的公司,日常写字、拉群、审批、日程统统在飞书里完成,但视频会议采购的是腾讯会议。两个平台各干各的,本来井水不犯河水,直到我发现每天最重复、最没价值的操作之一,就是先在腾讯会议客户端创建会议、复制入会链接,再切回飞书群把链接贴进去,顺手还要在群里艾特一下参会人。一天重复四五次,心情真的会变差。终于有一次周一开了四个会、贴了四次链接之后,我决定不再忍了,直接把飞书和腾讯会议对接起来。
这个对接实践要解决的,就是两件看起来简单、做起来琐碎的事:在飞书群里发出创建腾讯会议的指令,然后让机器人把会议号、入会链接和会议时间一键回传到群里。整套流程跑通之后,团队不用再打开腾讯会议客户端,也不用人工复制粘贴链接。后续我还顺手扩展了会前提醒和会议纪要回传,但最核心的,还是“飞书消息触发腾讯会议创建”这条链路。
这篇文章会把整个对接过程拆开讲清楚,包括飞书自建应用的创建、权限申请、事件订阅,腾讯会议开放平台的接入、签名鉴权、创建会议接口,最后落到一段可以直接改改就跑的工程代码上。适合什么人来参考?企业内部系统集成开发、效率工具爱好者、正在做飞书或者腾讯会议API对接的工程师,以及那些想在群里直接开会的运维和产品同学。
1. 项目背景与整体方案设计
1.1 为什么飞书和腾讯会议要打通
先说需求来源。当时公司推行飞书作为统一工作入口,文档、日程、机器人应用都往飞书迁。但视频会议因为合同和历史原因,一直用的是腾讯会议。这时候问题就出来了:如果说“飞书是办公室,腾讯会议是会议室”,那我的日常工作等于办公室和会议室之间隔着一条走廊,每天来回跑。
具体跑起来什么样?每周例会、跟客户的临时沟通、跨部门对齐会,都是在飞书群里临时约。约的过程是:发起人打开腾讯会议客户端,创建会议,选好开始时间、结束时间,生成链接,再把链接复制回飞书群,最后还要手动补充一句“明天10点入会”。如果会议多,这个动作就会高频重复。而且入口不统一,有人走客户端,有人走网页版,链接格式还不一样,经常出现群里几串链接找不到哪个是当前会议的情况。
自动化之后的目标就清晰了:用户在飞书群里@机器人,附带一句“明天10点和客户对需求”,机器人自动创建腾讯会议,并把会议主题、会议号、入会链接、时间一起回传到群里。人只需要在飞书这个入口里完成所有操作,腾讯会议从“主动打开的工具”变成“后台服务”。
另外还有一个深层考虑——数据沉淀。人工贴链接的方式,会议信息是不留痕的,后续想统计某个月的会议量、平均时长、参与人,没有数据可用。而通过API创建腾讯会议,每次调用的参数和返回结果都可以存到自己的数据库里,这为后面的会议数据统计和纪要归档打下了基础。这一点我觉得比省去几次复制粘贴更有价值。
1.2 三条对接路线,我为什么选了API直连
做这个需求之前,我大概调研了三种路线。
路线A:飞书机器人直接调用腾讯会议开放API。这也是我最终采用的方案。飞书提供机器人消息能力和事件订阅能力,腾讯会议提供创建会议和查询会议的开放API,中间用一个自建服务串起来。优点是链路完全可控,数据、权限、运维都在自己手里,而且两个平台的API能力都足够支撑这个场景。缺点是得自己写代码、自己部署一个公网可访问的服务。
路线B:用低代码平台中转。简道云、氚云这类平台都有飞书和腾讯会议的连接器,可以通过可视化流程配置完成部分场景。优点是不用写代码,但不是每个低代码平台的腾讯会议连接器都成熟,只有一个创建会议动作还好,一旦要处理消息事件、解析不同格式的会议指令,低代码平台就非常别扭。而且这类平台的连接器往往有额外费用,调用频率也有限制。
路线C:通过Webhook硬拼。腾讯会议本身有Webhook通知能力,飞书也可以配置Webhook机器人,但两者的Webhook都是单向的——飞书的Webhook只能收消息,腾讯会议的Webhook只能往外发事件。这相当于两个单向阀门接不到一起,要做双向联动还得自己写中间层。既然最终都要写代码,那不如直接走官方API。
三条路线对比下来,直接选路线A。核心判断依据是我对接口成熟度的评估:腾讯会议开放API支持创建会议、查询会议、修改会议、查询参会人,飞书开放API支持接收消息事件、发消息、发卡片、创建日程和文档,两个平台的能力交集恰好覆盖“群内指令创建会议并回传结果”这个闭环,外加会前提醒和会后纪要。
| 方案 | 开发成本 | 灵活性 | 稳定性 | 额外费用 |
|---|---|---|---|---|
| 飞书机器人 + 腾讯会议API | 中 | 高 | 高 | 无 |
| 低代码平台中转 | 低 | 低 | 中 | 通常有 |
| Webhook硬拼 | 中 | 低 | 中 | 无 |
1.3 整体数据流转:一次“开会”指令的完整旅程
整个链路我拆成了四个角色:飞书客户端、飞书开放平台、自建服务、腾讯会议开放API。用户感知到的只是“在群里发一句指令”,但背后经过了五次HTTP交互。
用户发起指令之后,飞书开放平台通过事件订阅把消息内容推送到我的自建服务。自建服务解析出会议主题、时间这些关键信息,组装一个创建会议的请求体,签名后调用腾讯会议开放API。腾讯会议创建成功,返回meeting_id和join_url。自建服务再调用飞书API,把会议信息以消息或卡片的形式发回对应的飞书群。
这个链路里,自建服务是绝对核心。为什么不能直接让飞书调用腾讯会议?因为两个平台都是封闭的云服务,没有公网上的直接“桥接器”,腾讯会议不知道飞书群里发生了什么,飞书也不知道腾讯会议创建了什么。必须有一个中间层,它同时持有飞书应用的凭证和腾讯会议的签名密钥,把两个平台传递的参数翻译成对方能理解的语言。
我当时部署的是一台轻量云服务器,公网上放一个Flask应用,只需要一个端口接收飞书事件回调。整条链路是同步处理的,飞书回调过来后,服务先创建腾讯会议,再把结果回复到群里,耗时大概1到2秒。对于会议创建这种低频操作,完全没有性能压力。
2. 飞书侧接入:应用创建、权限与凭证获取
2.1 创建一个拥有机器人能力的自建应用
飞书的开放体系里,机器人不是一个独立账号,而是“应用”的能力之一。所以第一步是创建一个企业自建应用,并给应用开启机器人能力。
操作路径是:进入飞书开放平台,在选择企业后点击“创建企业自建应用”,填上应用名称和应用图标。这里有个细节:应用名称和图标是员工在飞书里搜索、使用这个机器人时会看到的,建议起一个直白的名字,比如“会议助手”,而不是用内部代号。创建成功后,在“应用能力”页面里找到“机器人”,点击启用。启用之后,这个应用就拥有了一个“机器人”身份,可以被拉进群、被@、可以发消息。
创建完成之后,在“凭证与基础信息”页面能看到两个关键的字段:App ID和App Secret。App ID是应用的公开标识,相当于身份证号;App Secret是签发的密钥,相当于密码。之后凡是调用飞书开放平台API,都需要用这两个值来换取访问凭证。务必把App Secret保存在服务端环境变量或者配置中心,不要写进前端代码,也不要提交到Git仓库。
还有一个很容易被忽略的点:自建应用区分企业版和个人版,创建的时候一定要选对所属企业。如果选错,后面申请权限和发布版本的时候会遇到审核流程不匹配的问题。企业内部自建应用走的是企业内部审核,上线速度要快很多。
2.2 权限配置与应用发布
飞书的权限模型是申请制,应用默认没有任何API权限,需要明确申请才会被授予。这个设计刚开始觉得繁琐,但实际用下来很合理——最小权限原则,避免一个机器人拿太多不相关的API能力。
我这次用到的权限主要是三个:一是以应用的身份发送消息(im:message:send_as_bot),这个权限让机器人能往群里发消息;二是读取群消息(im:message:read),用来接收用户@机器人的指令;三是获取群信息(im:chat:read),用来解析消息来源的群名称和群ID。如果后面要扩展日程同步,还要申请日程相关的权限,但初期这三个就够。
权限申请提交之后,需要企业管理员在管理后台审批。如果是测试阶段,飞书提供了一个“测试企业”的环境,也可以直接在测试企业里授权,省去审批等待。审批通过后还有一个非常容易踩的坑:权限不是申请完就立刻生效的,必须在“版本管理与发布”里创建一个新版本,提交发布出去,运行时才会带上新权限。我最初就是申请完权限直接调接口,报了一下午的no permission,后来才反应过来是版本没有重新发。
2.3 事件订阅与URL验证
要让飞书在群里收到@消息时通知我的服务,需要配置“事件订阅”。这一步是整个飞书接入里最容易卡住的地方,因为飞书要求在配置订阅URL的时候做一次双向验证。
具体逻辑是:在飞书开放平台的事件订阅页面,填上你的回调URL,比如https://your-server.com/webhook/feishu,然后飞书会向这个URL发送一个POST请求,请求体里带一个challenge字段。你的服务必须原样返回这个challenge值,验证才算通过。
这个机制本质上是为了确认URL背后是一个真实受控的服务,而不是随便填的一个地址。第一次配置的时候,我直接在浏览器里打开URL,发现飞书提示验证失败,后来才意识到需要服务端处理POST请求并返回JSON。正确的做法是在Web框架里写一个接口,接收POST,判断请求里有没有challenge字段,有就原样回传。
验证通过之后,就可以订阅具体事件了。我订阅的是“接收消息”事件,对应的事件类型是im.message.receive_v2。这里有个新老版本的区别:老的v1版本事件结构简单,但官方已经在逐步下架,新开发统一用v2。v2版本的事件内容会多一层header和event的封装,解析的时候要对应调整。
如果担心回调内容被窃听,飞书还支持在事件订阅里设置Encrypt Key,回调请求体里的encrypt字段会用AES加密。加密解密的逻辑飞书官方SDK里有现成的类,直接用就行。不是强制的,但如果你对安全性要求高,建议开启。
2.4 获取tenant_access_token并发送消息到群里
飞书API的访问凭证有两种:tenant_access_token和user_access_token。tenant_access_token代表“应用自己”的身份,适用于机器人发消息、读消息这些不需要用户维度的场景;user_access_token代表“某个用户”的身份,一般用于代用户创建日程、读取用户日历这类场景。
我这次对接腾讯会议创建会议,核心动作是机器人收到群消息后自动回复,所以全程用tenant_access_token就够了。获取接口是POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal,请求体传app_id和app_secret,返回结果里带一个tenant_access_token。这个token的有效期官方文档标注是两个小时,但实际测试大概是1小时50分钟左右,代码里要加缓存和自动刷新,不要每次调用都去申请一次。
顺便提一个很多人在dify或者其他AI工具里接飞书云文档时遇到的困惑:首次使用飞书云文档授权,到底去哪拿凭证?本质上也是走这一套token体系——如果工具是以应用身份访问飞书文档,就会要求填App ID和App Secret,然后工具自己调用上面的接口换token;如果是以用户身份访问,还需要走OAuth授权流程获取user_access_token。所以理解了飞书token体系,这些工具的授权配置就不难了。
发消息的接口是POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id,请求头带Authorization: Bearer {token},请求体里传receive_id(群ID)、msg_type(消息类型)和content(消息内容)。msg_type我用过text和interactive两种,text是最简单的纯文本,interactive是消息卡片,支持按钮、Markdown、分栏等丰富布局。我最终回传会议信息用的是interactive卡片,用户体验比纯文本好很多。
import requests import json def get_tenant_access_token(app_id, app_secret): url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" payload = {"app_id": app_id, "app_secret": app_secret} resp = requests.post(url, json=payload, timeout=10) data = resp.json() if data.get("code") == 0: return data["tenant_access_token"] raise Exception(f"获取飞书token失败: {data['msg']}") def send_feishu_text(chat_id, text, token): url = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8", } body = { "receive_id": chat_id, "msg_type": "text", "content": json.dumps({"text": text}, ensure_ascii=False), } resp = requests.post(url, headers=headers, json=body, timeout=10) return resp.json()3. 腾讯会议侧接入:鉴权签名与创建会议
3.1 开通腾讯会议开放平台API
腾讯会议的开放平台和飞书不同,API接入不是默认开放的。需要在腾讯会议官网找到开放平台入口,用企业账号提交开通申请。个人免费版的腾讯会议账号是没有API权限的,这是硬性条件。测试的时候,腾讯会议开放平台提供了一个沙箱环境,可以在里面模拟API调用,不用真实创建会议。
申请通过后,开放平台的控制台里会生成一对密钥:Secret ID和Secret Key。很多人第一次看到这两个名词会懵,以为一个是账号一个是密码。实际上Secret ID是一个公开标识,用来告诉腾讯会议“我是哪个应用”;Secret Key才是真正参与签名计算的密钥,相当于应用级别的私密钥匙。这两个值要妥善保存,因为后面每次调用API都要用它们计算签名。
这里要特别提醒一点:腾讯会议开放平台的所有API调用,几乎都要求请求头里带上四个字段:X-TC-Key、X-TC-Nonce、X-TC-Timestamp、X-TC-Signature。缺一个或者算错一个,服务端都会直接拒绝。我刚接入的时候就是因为漏了X-TC-Nonce这个随机串,排查了很久才发现。
3.2 腾讯会议的签名机制,到底在签什么
腾讯会议的签名机制,本质上是HMAC-SHA256。签名的作用有两个:一是确认调用方持有合法的Secret Key,二是保证请求参数在传输过程中没有被篡改。这种思路在云厂商的开放API里非常常见,飞书用的是直接用token,腾讯会议则更接近腾讯云API的签名风格。
签名串的拼接规则是固定的:HTTP方法、请求路径、时间戳、随机串、请求体,这五个部分用换行符拼成一个长字符串。然后以Secret Key作为密钥,对这个长字符串做HMAC-SHA256摘要,最后对摘要结果做Base64编码,得到最终的签名。
具体到创建会议的请求,HTTP方法是POST,请求路径是/v1/meetings,时间戳是当前Unix秒级时间戳,随机串是非ce可随机生成,请求体就是准备发送给腾讯会议的JSON字符串。这里有个非常容易出错的地方:请求体字符串必须和实际发送的请求体完全一致,哪怕多一个空格、少一个字段,签名校验都会失败。所以组装好签名之后,真正发送请求时,要直接把签名字符串对应的那串文本传过去,而不是重新序列化一遍。
import time import uuid import hmac import hashlib import base64 def generate_signature(method, uri, secret_key, timestamp, nonce, body_str): signing_str = f"{method}\n{uri}\n{timestamp}\n{nonce}\n{body_str}" digest = hmac.new( secret_key.encode("utf-8"), signing_str.encode("utf-8"), hashlib.sha256, ).digest() return base64.b64encode(digest).decode("utf-8")这个签名算法,建议你写成一个独立函数,后面所有腾讯会议API调用都复用。不要每个接口重复粘贴一份签名逻辑,一旦要改算法或者补充字段,改动量太大会很痛苦。
3.3 创建腾讯会议接口与关键参数
创建会议室的核心接口是POST https://api.meeting.qq.com/v1/meetings。这个接口的请求体字段比较多,但核心就这几个:userid(创建者ID)、topic(会议主题)、start_time(开始时间)、end_time(结束时间)、type(会议类型,1代表即时会议,0代表预约会议)。
userid不是随便填的。首先需要确定这个userid对应的用户已经在腾讯会议企业账号下存在,并且拥有创建会议的权限。一般建议用企业的管理员账号或者专门的API调用账号,这样创建的会议归属清晰,后续通过接口查询、修改、删除会议也会方便很多。userid可以在腾讯会议开放平台的管理后台查看,也可以调用/v1/users/list接口查询。
start_time和end_time的格式是Unix时间戳(秒级),注意是UTC时间。如果你直接传了一个本地时间(比如东八区的2025-01-01 10:00:00对应的Unix时间戳),那在接口这边得到的会议时间会偏差8小时。建议统一在服务端把本地时间转换成UTC时间戳再传。
接口的响应里会返回meeting_id、meeting_code和join_url。meeting_id是会议的全局唯一标识,后续查询、修改会议都靠它;meeting_code是用户入会时输入的9位会议号;join_url是入会链接,可以直接嵌入飞书消息卡片里,用户点击就能入会。
import json import requests def create_tencent_meeting(userid, topic, start_time, end_time, secret_id, secret_key): uri = "/v1/meetings" timestamp = str(int(time.time())) nonce = uuid.uuid4().hex body_dict = { "userid": userid, "topic": topic, "type": 1, "start_time": str(start_time), "end_time": str(end_time), } body_str = json.dumps(body_dict, ensure_ascii=False, separators=(",", ":")) signature = generate_signature("POST", uri, secret_key, timestamp, nonce, body_str) headers = { "X-TC-Key": secret_id, "X-TC-Nonce": nonce, "X-TC-Timestamp": timestamp, "X-TC-Signature": signature, "Content-Type": "application/json", } url = f"https://api.meeting.qq.com{uri}" resp = requests.post(url, headers=headers, data=body_str.encode("utf-8"), timeout=10) return resp.json()3.4 userid从哪来
userid是腾讯会议创建会议时的必填字段,缺了它接口直接报“用户不存在”。很多人在这一步卡住,其实是没搞清楚腾讯会议的userid和飞书的userid是两个体系、需要单独获取。
腾讯会议的userid,一般要从腾讯会议开放平台的成员管理里找。如果你是管理员,登录开放平台后会看到企业成员列表,每个成员都有一个唯一的userid。为了对接方便,我建议单独创建一个机器人身份或者在已有管理员账号基础上,固定使用同一个userid调用创建会议接口。这样所有自动创建的会议,创建者都是同一个账号,权限管理和日志排查都很清晰。
如果一个账号的权限不够,还可以通过/v1/users/list接口拉取企业用户列表,拿到具体的userid。但这个接口本身也有权限要求,开发阶段可以先在管理后台人工确认一个账号的userid,把这个值配到配置文件里先跑通流程,后面再考虑用户维度动态切换。
4. 核心流程实现:从飞书群消息到腾讯会议链接
4.1 解析飞书消息指令的细节
飞书把消息推送到自建服务的回调地址,事件内容在event.message.content字段里,这是一个JSON字符串,需要先解析。content的结构根据消息类型不同而变化,如果是text消息,content长这样:{"text":"@会议助手 明天10点和客户对需求"}。
接收到的文本里,会带上@机器人的内容。这里有一个细节:飞书的@在文本中不是一个可见的@符号,而是一个open_id占位符,形如at_open_id。所以不能直接拿整段文本去做关键词匹配,需要先通过event.message.mentions数组找到机器人自己的open_id,然后把文本里的@占位符替换成空字符串,剩下的才是用户真正输入的自然语言指令。
我设计的指令格式很简单:两个字段,时间加主题,中间用空格或者逗号分隔。比如“明天10点 和客户对需求”。考虑到用户习惯,我还支持了一个更宽松的写法:“开会 15:00 每周例会”。这里的关键不是做一个复杂的NLP,而是用最简单的规则解析,覆盖大多数使用场景。解析失败的时候,默认创建半个小时后开始的会议,并在回传消息里提醒用户注意时间。
另外,消息里有图片、表情等非文本类型时,或者用户在群里发消息但根本没@机器人时,事件也会推到我的服务里。所以在处理逻辑的最前面要做一个判断:只有message_type等于text,并且mentions里包含机器人自己的open_id,才进入创建会议的流程,其他情况直接忽略。避免群里正常聊天内容也被误触发生成会议。
4.2 组会议请求与异常兜底
解析出指令之后,紧接着就需要组装腾讯会议的请求体。这一步有几个参数需要提前算好:开始时间、结束时间、会议主题。
时间处理是我在这个项目里踩坑最多的部分。飞书推送的事件里没有直接给“用户当前时区”的信息,而腾讯会议API要求传UTC时间戳。我的做法是:在配置项里定一个默认时区,比如Asia/Shanghai,然后把用户输入的时间字符串解析成这个时区的datetime对象,再用pytz或者zoneinfo转换成UTC时间,最后转成Unix时间戳。
如果用户没传时间,我默认开始时间是当前时间加30分钟,结束时间是开始时间加1小时。如果用户传了具体时间,比如15:00,那就用今天的15:00作为开始时间、16:00作为结束时间。这里要特别检查一个边界:如果用户说的时间在今天已经过去了,比如现在是16:30,用户说15:00开会,那就自动顺延到明天的15:00。这个判断逻辑虽然简单,但对使用体验的提升非常明显,因为真实情况下很多用户懒得写日期,只写时间,结果下午开会忘了带上午的时间。
组装好请求体之后,调用create_tencent_meeting函数。因为网络抖动、接口限流、参数错误都可能发生,我统一用try-except包住,捕获异常后往飞书群里发一条失败提示,而不是让事件回调直接报错。用户看到失败提示,可以自己修正指令再发一次,比看服务日志友好得多。
4.3 把会议卡片发回飞书群
腾讯会议创建成功之后,返回的join_url和meeting_code是用户最关心的两个信息。为了体验友好,我没有用纯文本,而是用飞书的消息卡片发回群里。
消息卡片用msg_type=interactive,content是一个符合飞书卡片JSON格式的对象。卡片里我放了四个字段:会议主题、开始时间、会议号、入会链接。入会链接做成一个可点击的按钮,按钮文字是“点击入会”或“加入会议”。卡片右下角还能放一个“复制会议号”的辅助按钮,方便用户把会议号发给外部参会人。
构造卡片JSON的时候有一个坑:飞书卡片支持的元素和属性非常丰富,但版本不同支持的语法也不同。刚开始我按最新的card 2.0语法写,结果在部分客户端上显示异常。后来干脆用官方“消息卡片搭建工具”可视化配置生成JSON,再粘到代码里,省去了反复查文档的麻烦。如果你也是第一次写飞书卡片,非常推荐这个路子,先拖拽生成再微调,比手写JSON快很多。
卡片里还有一个可选的做法:把“添加到飞书日历”做成一个跳转链接。飞书开放平台支持通过URL Scheme直接创建日程,不过这个能力需要额外的权限和配置。我没有在初版实现,但这是一个很自然的扩展点。
4.4 完整可运行的工程骨架
为了让整体逻辑更清晰,我按职责拆了几个文件。app.py负责接收飞书事件、处理URL验证、路由分发;meeting_service.py负责腾讯会议签名和创建会议;feishu_service.py负责飞书token申请和消息发送;config.py放配置项。
下面这个结构比较接近我当时跑通的版本,去掉了一些业务细节和日志代码,但主链路是完整的。
# app.py import json from flask import Flask, request, jsonify import feishu_service import meeting_service import config app = Flask(__name__) @app.route("/webhook/feishu", methods=["POST"]) def feishu_callback(): body = request.get_json(force=True) # 飞书URL验证 challenge = body.get("challenge") if challenge: return jsonify({"challenge": challenge}) header = body.get("header", {}) event = body.get("event", {}) if header.get("event_type") != "im.message.receive_v2": return jsonify({"code": 0}) message = event.get("message", {}) if message.get("message_type") != "text": return jsonify({"code": 0}) mentions = event.get("mentions", []) bot_open_id = config.FEISHU_BOT_OPEN_ID if not any(m.get("id", {}).get("open_id") == bot_open_id for m in mentions): return jsonify({"code": 0}) content = json.loads(message.get("content", "{}")) raw_text = content.get("text", "") # 简单指令解析:去掉@占位符后的文本 clean_text = raw_text.replace(f"at_{bot_open_id}", "").strip() topic, start_time, end_time = meeting_service.parse_meeting_request(clean_text) try: meeting = meeting_service.create_tencent_meeting( userid=config.TMEETING_USERID, topic=topic, start_time=start_time, end_time=end_time, secret_id=config.TMEETING_SECRET_ID, secret_key=config.TMEETING_SECRET_KEY, ) token = feishu_service.get_tenant_access_token( config.FEISHU_APP_ID, config.FEISHU_APP_SECRET ) chat_id = message.get("chat_id") feishu_service.send_meeting_card(chat_id, meeting, token) except Exception as e: token = feishu_service.get_tenant_access_token( config.FEISHU_APP_ID, config.FEISHU_APP_SECRET ) feishu_service.send_feishu_text( message.get("chat_id"), f"创建会议失败: {str(e)}", token ) return jsonify({"code": 0}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)# meeting_service.py import json import re import time import uuid from datetime import datetime, timedelta from zoneinfo import ZoneInfo import requests from signature_util import generate_signature DEFAULT_TZ = ZoneInfo("Asia/Shanghai") def parse_meeting_request(text): # 简单解析:尝试匹配时间 HH:MM,其余部分作为主题 time_match = re.search(r"(\d{1,2})[::](\d{2})", text) if time_match: hour, minute = int(time_match.group(1)), int(time_match.group(2)) start_local = datetime.now(DEFAULT_TZ).replace( hour=hour, minute=minute, second=0, microsecond=0 ) else: start_local = datetime.now(DEFAULT_TZ) + timedelta(minutes=30) if start_local <= datetime.now(DEFAULT_TZ): start_local += timedelta(days=1) end_local = start_local + timedelta(hours=1) topic = re.sub(r"\d{1,2}[::]\d{2}", "", text).strip() or "快捷会议" start_ts = int(start_local.timestamp()) end_ts = int(end_local.timestamp()) return topic, start_ts, end_ts def create_tencent_meeting(userid, topic, start_time, end_time, secret_id, secret_key): uri = "/v1/meetings" timestamp = str(int(time.time())) nonce = uuid.uuid4().hex body_dict = { "userid": userid, "topic": topic, "type": 1, "start_time": str(start_time), "end_time": str(end_time), } body_str = json.dumps(body_dict, ensure_ascii=False, separators=(",", ":")) signature = generate_signature("POST", uri, secret_key, timestamp, nonce, body_str) headers = { "X-TC-Key": secret_id, "X-TC-Nonce": nonce, "X-TC-Timestamp": timestamp, "X-TC-Signature": signature, "Content-Type": "application/json", } url = f"https://api.meeting.qq.com{uri}" resp = requests.post(url, headers=headers, data=body_str.encode("utf-8"), timeout=10) if resp.status_code != 200: raise Exception(f"腾讯会议接口异常: {resp.status_code} {resp.text}") return resp.json()["meeting_info_list"][0]["meeting_info"]# signature_util.py import base64 import hashlib import hmac def generate_signature(method, uri, secret_key, timestamp, nonce, body_str): signing_str = f"{method}\n{uri}\n{timestamp}\n{nonce}\n{body_str}" digest = hmac.new( secret_key.encode("utf-8"), signing_str.encode("utf-8"), hashlib.sha256, ).digest() return base64.b64encode(digest).decode("utf-8")# feishu_service.py import json import time import requests token_cache = {"token": "", "expire_at": 0} def get_tenant_access_token(app_id, app_secret): if token_cache["token"] and token_cache["expire_at"] > time.time() + 60: return token_cache["token"] url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" resp = requests.post( url, json={"app_id": app_id, "app_secret": app_secret}, timeout=10 ) data = resp.json() if data.get("code") != 0: raise Exception(f"获取飞书token失败: {data['msg']}") token_cache["token"] = data["tenant_access_token"] token_cache["expire_at"] = time.time() + data["expire"] - 120 return token_cache["token"] def send_meeting_card(chat_id, meeting, token): url = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8", } card = { "config": {"wide_screen_mode": True}, "header": { "title": {"tag": "plain_text", "content": meeting["topic"]}, "template": "blue", }, "elements": [ {"tag": "div", "text": {"tag": "lark_md", "content": f"会议号:**{meeting['meeting_code']}**"}}, {"tag": "div", "text": {"tag": "lark_md", "content": f"入会链接:[点击入会]({meeting['join_url']})"}}, { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "复制会议号"}, "type": "primary", "value": {"meeting_code": meeting["meeting_code"]}, } ], }, ], } body = { "receive_id": chat_id, "msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False), } resp = requests.post(url, headers=headers, json=body, timeout=10) return resp.json()这套代码在我当时的环境里跑通之后,整个团队的体验变化非常直观。以前“创建会议+贴链接”要30秒到1分钟,现在群里发一句话,1到2秒后会议卡片就出现了。
5. 常见问题与排查技巧实录
5.1 飞书事件订阅验证不通过,90%是这里的问题
配置飞书事件订阅URL的时候,验证失败是出现频率最高的报错。如果你在飞书开放平台点“验证”直接红了,先别急着怀疑代码逻辑,优先检查三件事。
第一,URL是不是公网可访问的,并且是HTTPS或者HTTP都能通?本地开发跑localhost肯定不行,需要用内网穿透工具映射到公网。穿透工具有免费版,但地址会变,所以开发测试时每次都要更新飞书后台的URL。生产环境还是建议固定域名加Nginx反代,稳定很多。
第二,接口是不是只处理了POST?飞书验证回调是POST请求,如果你在浏览器直接打开URL看到404,不代表接口有问题,要看服务日志里请求有没有进来。最直接的办法是在回调函数第一行加个print(request.get_json()),然后看服务器日志。
第三,返回的JSON格式是否正确?验证成功必须返回{"challenge": "xxx"},除了这个键之外最好不要带其他字段。如果走了加密通道,也就是配置了Encrypt Key,那challenge是加密在encrypt字段里的,需要先解密才能取到明文challenge。很多人忽略这一点,配置了Encrypt Key却用明文方式解析,验证永远过不去。
我当时卡了最久的地方是:飞书新版事件回调的外层结构从{"challenge": "xxx"}变成了{"schema": "2.0", "header": {...}}这种结构,导致我解析challenge的代码第一次没匹配到。现在的处理方式是兼容两种结构,先解析最外层的challenge,如果没有再从header里找,稳妥很多。
5.2 腾讯会议签名报错:时间戳、请求体和密钥的三方校验
调用腾讯会议API最常见的是401错误,也就是签名校验失败。排查这个问题的思路,是把签名串原样打印出来,和官方调试工具算出来的结果逐字符对比。一般就三个原因。
第一个原因是服务器时钟不准。签名机制里时间戳参与计算,而且服务端会校验时间戳和当前时间差是否在可接受范围内。如果你的服务器时间偏差超过5分钟,签名就算正确也会被拒绝。解决办法是配置NTP时钟同步,别用一台时间偏了好几个月的机器。
第二个原因是请求体不一致。我遇到过的情况是,签名时用的body_str用json.dumps生成的,但发送请求时用requests库的json参数,Python的requests会重新序列化一遍,序列化结果可能和签名时不完全一样,尤其是中文字符的ensure_ascii、字典键的顺序,都会导致签名串和服务端验签串对不上。解决方法是签名用的body_str原封不动地放进data参数里发送,并且设置Content-Type为application/json。
第三个原因是Secret Key配错。这里有一个特别容易搞混的点:签名用的密钥是Secret Key,不是Secret ID。如果你把Secret ID当成密钥去HMAC,结果必然不对。腾讯会议的密钥体系分两层,确实比较容易搞混淆。
5.3 摄像头不能用?这个锅不一定在腾讯会议
这个话题我要单独拿出来说,因为真的有人把“摄像头调不出来”归咎于腾讯会议客户端。包括在对接过程中,也有同事反馈说“腾讯会议不能使用电脑自带摄像头吗”。实际情况是,腾讯会议完全支持电脑自带摄像头,问题一般出在设备占用和系统权限上。
设备占用是最常见的原因。Windows上如果已经打开了一个视频软件,比如钉钉、飞书视频、微信视频,或者浏览器正在用摄像头做在线会议,再开腾讯会议,摄像头就会提示被占用。因为同一个硬件在同一时间只能被一个进程抢占。解决的办法就是先退出其他占用摄像头的程序,然后再重新进入腾讯会议。
系统权限也要查一下。macOS的隐私设置里有摄像头权限列表,如果腾讯会议不在允许列表里,摄像头画面就是黑的。Windows 10以上同理,在设置-隐私-摄像头里检查允许桌面应用访问摄像头是否开启。还有一类是驱动问题,多见于老款笔记本,需要更新摄像头驱动,这个只能根据具体设备型号去官网找驱动。总体排查顺序,建议先退其他软件,再查系统权限,最后考虑驱动。
5.4 时区差了8小时:Unix时间戳的坑
整个对接过程中,关于时间我踩了不少坑,在这里集中说一下。
腾讯会议API的start_time和end_time是Unix时间戳,单位是秒,而且是UTC标准。Unix时间戳本身没有时区概念,它就是自1970年1月1日UTC以来的秒数。问题出在把“用户输入的本地时间”转换成时间戳这一步。如果你用datetime.now()拿到本地时间,再直接调用timestamp(),这个转换是没问题的,因为timestamp()会基于系统本地时区换算成UTC时间戳。但如果你手动做字符串拼接,比如把2025-01-01 10:00:00直接当成UTC时间戳取秒数,那就会多算8小时。
排查时区问题有个很有效的办法:创建完会议后,在腾讯会议后台看这个会议的时间显示。如果显示的时间比预期早或者晚8小时,说明时间戳转换环节有偏差。还有一个办法是会议创建成功后,在回传飞书的卡片里显示本地时间,让用户来反馈时间是否准确,这样可以不断积累日志,反向调整解析逻辑。
我自己后来干脆写了一个公共函数,所有时间转换都走同一个函数,不在业务代码里散落着各种各样的datetime操作。这样一旦发现时区有问题,只需要改一个地方。
5.5 接口限流与重试策略
飞书和腾讯会议的开放API都有访问频率限制。飞书侧按应用维度限流,单位时间内请求次数过多会返回HTTP 429和错误码。腾讯会议侧的限流策略更严格,创建会议接口如果调用过于频繁,会被临时封禁一小段时间。
我在自建服务里加了两层防护。第一层是在调用腾讯会议接口之前,判断当前时间距离上次调用是否小于一个最小间隔,比如5秒。如果用户连发两次开会指令,第二次直接提示“正在创建会议,请稍候”,而不是真的去调接口。第二层是封装一个带指数退避的重试函数。遇到网络错误和HTTP 5xx错误时,最多重试3次,每次间隔按2的指数递增,比如1秒、2秒、4秒。
还有一点要特别注意:飞书事件回调的请求如果返回超时,飞书会重试推送事件。这会导致同一句“开会”指令被推送两次,如果服务端没有做幂等处理,腾讯会议会被创建出两个重复会议。我的做法是在Redis里按消息ID做去重,处理过的消息ID直接忽略。如果没有Redis,用一个本地字典加过期时间也能实现基础去重,只不过多实例部署时会有问题。
5.6 权限不足与常见错误速查
把我在实际对接过程中遇到的典型错误和解决办法整理成一张表,方便你直接对照排查。
| 错误现象 | 可能原因 | 处理建议 |
|---|---|---|
| 飞书接口返回no permission | 应用未申请对应权限,或权限未随新版本发布 | 重新申请权限并发布新版本,确认发布成功 |
| 飞书事件订阅URL验证失败 | URL不可访问、未处理challenge、加密Key未解密 | 检查公网可达性,兼容多种challenge结构,开启加密时先解密 |
| 腾讯会议接口401 | 时间戳偏差、签名串不一致、Secret Key配错 | 同步NTP时钟,打印签名串对比,区分Secret ID和Secret Key |
| 腾讯会议接口403 | API权限未开通或账号权限不足 | 确认企业账号已开通API能力,检查userid权限 |
| 创建会议时间不对 | Unix时间戳换算时区错误 | 统一走UTC时间戳转换,回传卡片显示本地时间 |
| 会议创建成功但无法入会 | 腾讯会议账号未激活或被禁用 | 检查腾讯会议企业后台账号状态 |
| 重复创建同主题会议 | 飞书事件回调重试触发 | 基于消息ID做去重处理 |
写在最后:这个对接后续还能怎么扩展
整个“飞书-腾讯会议对接”做完到现在,团队的会议创建流程已经彻底从客户端转移到了飞书群里。最近我又加了一个每天早上的定时任务,扫描当天所有自动创建的会议,提前30分钟在飞书群里发提醒卡片,附带会议链接。前两周上线后,迟到率明显下降,这个效果是我预期之外的。
如果你也想做类似的事情,我的建议是先把主链路跑通,不要一上来就想着把所有功能都做完。就按“群里发指令创建会议、机器人回传链接”这一个闭环来,哪怕先不支持时间解析,直接固定默认开会时间,也可以让流程的先跑起来。跑通之后再慢慢加时间识别、卡片美化、会前提醒、会议纪要归档。
还有一个可以尝试的方向是把飞书云文档和会议纪要串起来。腾讯会议的转写文本拿到之后,通过飞书云文档API生成一篇在线文档,然后把文档链接发到会议卡片里。我在做AI知识库相关项目时试过类似思路,飞书云文档的授权凭证体系就是前面讲的token那一套,理解了底层逻辑,扩展其实很顺畅。
最后再分享一个工程上的小技巧:把飞书和腾讯会议的API凭证统一放到一个配置中心管理,不要让每个人各存一份。因为这两个平台的密钥泄漏都可能导致会议被乱建、消息被乱发,权限影响面很大。我当时就吃过亏,有一次同事把Secret Key贴到了聊天群里,后来只能重新生成密钥,代价不大但很折腾。对接类项目,密钥管理从一开始就要规范起来。