简介:本资源是一套面向抖音企业号开发者的私信自动回复卡片功能源码实现,适用于具备Python/Go基础的后端或全栈工程师,解决企业号高频咨询场景下的即时响应与交互升级问题。压缩包共17个文件,含3个核心Python脚本(app.py、main.py、get_token.py)负责业务逻辑与接口对接,1个Go主程序(main.go)支撑高性能服务,2个SQLite数据库(douyin_cards.db、douyin_messages.db)管理卡片配置与消息记录,2个HTML模板与DEPLOYMENT_GUIDE.md等文档构成可快速部署的完整工程结构,整体8.37MB。已有713人学习下载,提供从Token获取、卡片结构体(AutoReplyCard)定义、JumpLink白名单跳转适配、关键词触发到自动撤回的全链路代码支持,目录模块划分清晰,含.env.example环境配置、deploy.sh一键部署脚本及详细README,开箱即用,显著提升用户互动效率与企业服务专业度。
1. 抖音私信自动回复卡片代码:不是「机器人」,而是「结构化消息触发器」的轻量落地实践
你肯定见过这类需求:某高校招生办老师想在抖音私信里,对“报名条件”“学费多少”“有没有宿舍”等高频问题,不手动打字,而是点一下就发出带标题、图标、按钮的富文本卡片;某本地生活商家希望用户私信“菜单”时,自动弹出可点击的菜品分类卡片,而不是一长串文字。但注意——这不是“全自动聊天机器人”,抖音平台明确禁止未经用户主动触发的连续对话、AI拟人化闲聊、或绕过用户点击的静默推送。所谓“抖音私信自动回复卡片”,本质是:当用户发送特定关键词(如“价格”“预约”“地址”)后,服务端即时构造一条符合抖音开放平台规范的结构化消息(即「卡片」),通过官方接口推送给该用户一次,且必须由用户最新一条消息触发。它不依赖网页爬虫、不模拟人工点击、不突破平台鉴权,完全走抖音开放平台的message.send接口,用的是标准 OAuth2 授权 + 消息模板 + 关键词匹配逻辑。适合中小团队、个体运营者、教育机构等需要快速响应高频咨询,又不想养客服人力的场景。本文不讲“如何黑进抖音”,只讲怎么用官方路径,在合规前提下,把一条私信变成一张可交互的卡片。
2. 从零跑通:用 Flask + 抖音开放平台 SDK 构建最小可用卡片服务
要让“用户发‘营业时间’→ 你回一张含营业时段+地图按钮的卡片”,整个链路必须闭环:用户消息 → 你的服务器收到事件 → 解析关键词 → 匹配预设卡片模板 → 调用抖音接口发送。这中间没有魔法,只有三步硬配置:抖音应用创建、Webhook 地址注册、服务端接收与响应。下面用最简技术栈(Python 3.9+、Flask 2.3+、requests)带你本地跑通第一条卡片。
2.1 创建抖音开放平台应用并获取凭证
登录抖音开放平台(developer.open-douyin.com),进入「应用管理」→「创建应用」。类型选「服务号应用」(非小程序、非小游戏),名称填“客服卡片服务”,简介写明用途(如“用于高校招生咨询自动应答”)。创建成功后,记下三个关键值:
APP_ID:应用唯一标识,形如ttaa1234567890abcAPP_SECRET:密钥,仅首次可见,请立即复制保存(后续不可再查)VERIFICATION_TOKEN:自定义字符串(如douyin_card_2024),用于校验 Webhook 请求合法性
提示:
VERIFICATION_TOKEN不是密钥,但必须和你服务端代码中写的完全一致,否则抖音会拒绝推送事件。它不加密、不传输敏感信息,纯作签名比对用。
2.2 配置 Webhook 并完成服务器接入验证
抖音要求你的服务必须提供一个 HTTPS 接口(不能是 HTTP),用于接收用户消息事件。本地开发时,可用ngrok或localtunnel将localhost:5000映射为公网地址(如https://abc123.ngrok.io)。在开放平台「事件订阅」中,填入该地址,并勾选「消息事件」→「私信消息」。提交后,抖音会向该地址发起一次GET请求,携带challenge和echostr参数。你的服务必须原样返回echostr,才算接入成功。
# app.py from flask import Flask, request, jsonify import hashlib import hmac app = Flask(__name__) APP_ID = "ttaa1234567890abc" # 替换为你的真实 APP_ID VERIFICATION_TOKEN = "douyin_card_2024" # 替换为你设置的 token @app.route('/webhook', methods=['GET']) def verify_webhook(): # 抖音 GET 验证请求:/webhook?challenge=xxx&echostr=yyy&signature=zzz challenge = request.args.get('challenge') echostr = request.args.get('echostr') signature = request.args.get('signature') # 校验 signature:sha256(VERIFICATION_TOKEN + challenge) expected_sig = hmac.new( VERIFICATION_TOKEN.encode(), challenge.encode(), hashlib.sha256 ).hexdigest() if signature == expected_sig: return echostr # 必须原样返回 echostr else: return "Verification failed", 403这段代码做了三件事:提取 URL 参数、用VERIFICATION_TOKEN和challenge生成预期签名、比对后返回echostr。这是所有后续功能的前提——如果这一步失败,抖音永远不会把用户消息推给你。部署时注意:Flask 默认不支持 HTTPS,生产环境需用 Nginx 反向代理或 Gunicorn + SSL 证书。
2.3 接收私信事件并解析关键词
接入验证通过后,抖音会向同一/webhook地址发POST请求,Body 是 JSON 格式的消息事件。关键字段包括:open_id(用户唯一 ID)、text(用户发送的纯文本)、create_time(时间戳)。我们只需提取text,做简单关键词匹配即可触发卡片。
import json from datetime import datetime # 预设关键词-卡片映射表(实际项目建议存数据库或 YAML) CARD_MAP = { "营业时间": { "title": "⏰ 营业时间", "desc": "周一至周日 9:00 - 22:00(节假日照常)", "buttons": [ {"text": "查看门店地图", "action": "map"}, {"text": "联系客服", "action": "contact"} ] }, "价格": { "title": "💰 课程费用", "desc": "基础班:¥1980/期|进阶班:¥3280/期|含教材+实操耗材", "buttons": [ {"text": "立即报名", "action": "enroll"}, {"text": "下载价目表", "action": "pdf"} ] } } @app.route('/webhook', methods=['POST']) def handle_message(): try: data = request.get_json() # 验证事件类型是否为私信消息 if data.get("event_type") != "message": return "Ignored non-message event", 200 user_openid = data["open_id"] user_text = data["text"].strip() # 精确匹配关键词(不区分大小写,去首尾空格) matched_key = None for key in CARD_MAP.keys(): if user_text.lower() == key.lower(): matched_key = key break if not matched_key: return "No keyword match", 200 # 不回复,避免误触 # 构造卡片消息体(抖音要求格式,不可增减字段) card_payload = { "open_id": user_openid, "msg_type": "interactive", "card": { "title": CARD_MAP[matched_key]["title"], "description": CARD_MAP[matched_key]["desc"], "buttons": CARD_MAP[matched_key]["buttons"] } } # 调用抖音消息发送接口(需先获取 access_token) access_token = get_access_token() if not access_token: return "Failed to get access token", 500 resp = requests.post( f"https://open.douyin.com/api/message/send/?access_token={access_token}", json=card_payload, timeout=10 ) return jsonify({"status": "sent", "code": resp.status_code}), 200 except Exception as e: print(f"[ERROR] Handle message: {e}") return "Internal error", 500这段逻辑清晰:收到 POST → 提取text→ 查表匹配 → 构造card字段 → 调用message.send。注意msg_type必须为"interactive",card对象内字段名(title/description/buttons)必须一字不差,抖音后端是强 Schema 校验。buttons数组最多支持 3 个按钮,每个按钮text最长 12 字符,action是你自定义的字符串(用于前端跳转逻辑,如map可触发 H5 地图页)。
2.4 获取 access_token:OAuth2 三步换码流程
抖音不接受长期有效的access_token,每次调用message.send前,必须用APP_ID+APP_SECRET换取一个 2 小时有效期的 token。这是标准 OAuth2 的client_credentials流程:
import time import requests TOKEN_CACHE = {"token": "", "expires_at": 0} # 内存缓存,生产环境建议用 Redis def get_access_token(): now = int(time.time()) if now < TOKEN_CACHE["expires_at"]: return TOKEN_CACHE["token"] url = "https://open.douyin.com/oauth/access_token/" params = { "grant_type": "client_credential", "appid": APP_ID, "secret": APP_SECRET } resp = requests.get(url, params=params, timeout=5) data = resp.json() if "access_token" not in data: print(f"[ERROR] Failed to get access token: {data}") return None TOKEN_CACHE["token"] = data["access_token"] TOKEN_CACHE["expires_at"] = now + data["expires_in"] - 60 # 提前 60 秒刷新 return TOKEN_CACHE["token"]这里用了内存缓存(TOKEN_CACHE)避免频繁请求。expires_in通常为 7200(2 小时),我们减去 60 秒作为安全余量。这是新手最容易卡住的点:很多人直接把APP_SECRET当access_token用,结果接口永远返回 401。记住:APP_SECRET只用于换 token,绝不外泄;access_token才是调用业务接口的凭据。
3. 卡片内容设计:抖音交互卡片的 4 个硬性约束与 3 种实用模板
抖音的「交互卡片」不是微信小程序那种自由渲染的页面,而是一个高度受限的 JSON 结构。它的设计目标很明确:在私信对话流中,用最少的视觉元素、最短的交互路径,把用户导向下一个确定动作。因此,所有字段都有明确上限和语义,乱填不仅不显示,还会导致整条消息发送失败。下面拆解真实踩过的坑,再给出三种经测试能稳定生效的模板。
3.1 抖音卡片字段的 4 个硬性约束(违反必失败)
| 字段 | 类型 | 最大长度 | 是否必填 | 说明 |
|---|---|---|---|---|
title | string | 20 字符 | ✅ | 卡片主标题,加粗显示,第一眼看到的内容 |
description | string | 60 字符 | ✅ | 副标题/说明文字,常规字体,紧贴 title 下方 |
buttons | array | 最多 3 个 | ✅ | 按钮数组,每个按钮含text(≤12 字)和action(≤32 字) |
cover_url | string | — | ❌ | 封面图 URL,必须是 HTTPS、尺寸 300×168px、小于 1MB,否则不显示 |
注意:
cover_url是可选字段,但一旦填写,就必须满足全部要求。很多开发者上传了本地file://路径或 HTTP 图片,结果卡片变成纯文字。抖音 CDN 不会帮你转协议、不缩放、不裁剪——它只做“原样加载”,失败则静默丢弃封面。
3.2 三种高复用率卡片模板(附 JSON 示例)
模板一:单行动作卡片(适合“立即预约”“查看课表”类)
核心是减少认知负荷,让用户一眼看懂下一步。description用符号分隔关键信息,buttons只留一个强动词。
{ "title": "📅 预约试听课", "description": "✅ 免费|⏱ 45分钟|👨🏫 名师1对1", "buttons": [ {"text": "马上预约", "action": "book_trial"} ] }模板二:双路径引导卡片(适合“价格”“资料”类需分流的场景)
用两个按钮覆盖主要意图,避免用户追问。action命名体现业务语义,方便后端路由。
{ "title": "📚 学习资料包", "description": "含电子教材+练习题+录播回放", "buttons": [ {"text": "下载PDF", "action": "download_pdf"}, {"text": "观看视频", "action": "watch_video"} ] }模板三:信息聚合卡片(适合“校区地址”“联系方式”类静态信息)
把多行文字压缩进description,用换行符\n和符号分隔(抖音支持),按钮指向地图或电话。
{ "title": "📍 校区地址", "description": "朝阳校区:建国路88号SOHO现代城A座3层\n🚇 地铁10号线呼家楼站C口\n☎ 400-123-4567", "buttons": [ {"text": "导航到店", "action": "navigate"}, {"text": "拨打电话", "action": "call"} ] }血泪经验:
description中的\n在抖音客户端会真实换行,但必须用双引号包裹的 JSON 字符串(Python 中写成"\\n")。曾有开发者用 Python 的print()直接拼接字符串,\n被当成字面量,结果卡片里显示一堆\\n符号。
4. 避坑指南:上线前必须验证的 5 个致命细节
这套方案看似简单,但抖音平台的校验极其严格。以下 5 条是某高校招生办、某连锁教培机构在真实灰度上线时,反复翻车、逐条排查才定位的问题。每一条都对应一个具体现象、根本原因和可执行的解决动作,照着检查,能省掉至少 80% 的调试时间。
4.1 现象:Webhook 接入验证通过,但用户发消息后服务端收不到任何 POST 请求
原因:抖音要求 Webhook 地址必须是HTTPS,且证书由受信任 CA 签发(不能是自签名证书)。本地用ngrok时,若未升级到付费版,免费域名(如xxx.ngrok.io)的证书可能被抖音后端拒绝。
解决:用localtunnel(lt --port 5000)替代,其默认提供有效证书;或购买域名 + Let's Encrypt 免费证书,Nginx 配置 SSL 后再接入。
4.2 现象:message.send接口返回{"error_code": 10001, "description": "invalid access_token"}
原因:access_token过期后未及时刷新,或APP_ID/APP_SECRET填错(尤其注意APP_SECRET是创建应用时生成的,不是APP_ID后缀)。
解决:在get_access_token()函数中,打印resp.text日志;确认APP_ID和APP_SECRET与开放平台后台完全一致(包括大小写、隐藏字符);强制清空TOKEN_CACHE后重启服务重试。
4.3 现象:卡片发送成功(HTTP 200),但用户手机端只看到空白气泡或“消息发送失败”
原因:card对象内字段名拼写错误(如descripton少个 i)、buttons数组为空或超过 3 个、text字段超长(>12 字符)导致整条消息被抖音服务端静默丢弃。
解决:用 JSONLint 校验你构造的card_payloadJSON;用len(button["text"])在代码中加断言;发送前print(json.dumps(card_payload, indent=2))人工核对字段。
4.4 现象:用户点击按钮无反应,H5 页面打不开
原因:action字段只是透传字符串,抖音不会自动跳转。你必须在前端(如企业自建 H5 页面)监听window.webkit.messageHandlers(iOS)或AndroidJSBridge(Android),根据action值执行对应逻辑。
解决:在你的 H5 页面<head>中注入抖音 JS-SDK(https://s01.douyinpic.com/resource/douyin_open_js_sdk.js),按文档初始化后,用dd.ready()绑定action处理函数。没有这一步,按钮就是摆设。
4.5 现象:同一用户多次发送“价格”,但只收到第一次卡片,后续无响应
原因:抖音为防骚扰,对同一open_id在短时间内(约 30 秒)的重复关键词,会做频率限制,不推送事件。这不是你代码的问题,而是平台策略。
解决:在CARD_MAP匹配逻辑后,加日志记录user_openid和user_text;若发现高频重复,可在卡片description中提示“已发送,请查收↑”,避免用户误以为失效而反复发送。
5. 进阶技巧:用 YAML 管理卡片模板 + 自动化热更新,告别改代码重启
当卡片数量超过 10 个,硬编码在 Python 字典里就成了维护噩梦:改一个文案要改代码、提 PR、走发布流程;运营同学想临时调整按钮文字,得等工程师下班后上线。我的做法是——把所有卡片内容抽离为独立 YAML 文件,服务启动时加载,且支持运行时热重载。这样,运营同学用 VS Code 修改cards.yaml,保存即生效,工程师零介入。
5.1 设计 YAML 卡片配置文件结构
创建cards.yaml,用清晰层级组织内容。keywords支持数组(一个卡片匹配多个同义词),buttons保持简洁,cover_url为可选字段:
# cards.yaml - keywords: ["营业时间", "几点开门", "上班时间"] title: "⏰ 营业时间" description: "周一至周日 9:00 - 22:00(节假日照常)" cover_url: "https://example.com/images/hours.jpg" buttons: - text: "查看门店地图" action: "map" - text: "联系客服" action: "contact" - keywords: ["价格", "多少钱", "收费"] title: "💰 课程费用" description: "基础班:¥1980/期|进阶班:¥3280/期" buttons: - text: "立即报名" action: "enroll" - text: "下载价目表" action: "pdf"5.2 实现 YAML 加载与热更新机制
用PyYAML解析文件,关键在load_cards()函数:它读取文件、构建反向索引(keyword_to_card字典),并监听文件修改时间。当检测到变更,原子性地替换全局变量,无需重启服务。
import yaml import os import threading import time CARD_CONFIG_PATH = "cards.yaml" cards_cache = {"data": [], "mtime": 0, "keyword_map": {}} def load_cards(): """加载 YAML 配置,构建 keyword → card 映射""" try: mtime = os.path.getmtime(CARD_CONFIG_PATH) if mtime <= cards_cache["mtime"]: return cards_cache["keyword_map"] with open(CARD_CONFIG_PATH, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) or [] # 构建映射:所有 keywords 转小写,指向同一张卡片 keyword_map = {} for card in data: for kw in card.get("keywords", []): keyword_map[kw.strip().lower()] = card cards_cache["data"] = data cards_cache["mtime"] = mtime cards_cache["keyword_map"] = keyword_map print(f"[INFO] Reloaded {len(data)} cards from YAML") return keyword_map except Exception as e: print(f"[ERROR] Load cards failed: {e}") return cards_cache["keyword_map"] def watch_yaml_changes(): """后台线程:每 5 秒检查 YAML 文件是否修改""" while True: try: current_mtime = os.path.getmtime(CARD_CONFIG_PATH) if current_mtime > cards_cache["mtime"]: load_cards() # 触发重载 except: pass time.sleep(5) # 启动监听线程(应用启动时调用) threading.Thread(target=watch_yaml_changes, daemon=True).start()现在,handle_message()中的匹配逻辑就简化为:
# 替换原来的 CARD_MAP 查找 keyword_map = load_cards() matched_card = keyword_map.get(user_text.lower()) if not matched_card: return "No keyword match", 200 card_payload = { "open_id": user_openid, "msg_type": "interactive", "card": { "title": matched_card["title"], "description": matched_card["description"], "cover_url": matched_card.get("cover_url"), # 可选 "buttons": matched_card["buttons"] } }5.3 运营侧协作流程:从修改 YAML 到用户看到新卡片
- 运营同学:用任意文本编辑器打开
cards.yaml,在末尾添加新卡片(如针对“奖学金”关键词),保存; - 系统自动:5 秒内检测到文件变更,重新解析 YAML,更新
keyword_map; - 用户触发:下次发送“奖学金”,服务端立即匹配新卡片,调用接口发送;
- 效果验证:用户手机端实时收到新卡片,全程无需工程师参与、无需服务重启、无需发布新版本。
我在某跨平台教育系统的落地中,用这套机制将卡片迭代周期从“天级”压缩到“分钟级”。运营同学甚至自己写了 Bash 脚本,一键备份旧 YAML、拉取 Git 最新配置、触发 reload。真正的“所见即所得”。希望帮到你。
本文还有配套的精品资源,点击获取