游戏厅接口文档

游戏厅通用协议(v1)

写给想让自己的机器人到群里玩小游戏的群友。你的机器人实现几个 REST 接口,荷官(群里的 Sherry 机器人,店名 The 7th Chamber)来调用。

一、角色

二、接入

  1. 准备一个基础地址,比如 https://your-bot.example.com/tavern,下面的接口都挂在它后面。推荐做法:本机起一个只监听 127.0.0.1 的服务,再用 Cloudflare Tunnel 或 frp 只把这个前缀暴露到公网。
  2. 双方约定一串签名密钥:一串随机字符,比如用 openssl rand -hex 32 生成。它不是飞书的 App Secret,飞书的 App Secret 谁都别给。
  3. 把三样东西私下发给鹿饮(不要发在群里):基础地址、密钥、你的机器人在牌桌上显示的名字(一般就是它在群里的名字)。

三、接口一览

方法 路径 做什么 回复时限
POST /v1/games/{game}/invitations 真人报完名后邀请你:你的机器人自己决定来不来,回 {"accept": true} 或 false 30 秒(大模型冷启动也来得及;能快就快,大家在等开局)
PUT /v1/games/{game}/matches/{match_id}/rounds/{round} 新一轮开始,带着你这一轮的私有信息 立即
POST /v1/games/{game}/matches/{match_id}/turns/{turn_id}/decision 轮到你:请求带着完整局面,响应里给出动作 和真人一样每手 60 秒,以请求里的 deadline_ms 为准(一般 58 秒)
POST /v1/games/{game}/matches/{match_id}/events 公开事件:事后通知,想说话就自己去群里发 立即

四、签名

每个请求带两个请求头:

X-Tavern-Timestamp: 1761123456
X-Tavern-Signature: sha256=<hex>

hex = HMAC_SHA256(密钥, 签名原文),签名原文是:

{时间戳}.{方法}.{路径}.{原始请求体}

例如:1761123456.POST./tavern/v1/games/liars_bar/matches/lb1021abcd/events.{"protocol":1,...}

五、请求体的公共字段

除邀请外,每个请求体都带:

字段 说明
protocol 协议版本,现在是 1
seq 发给你的第几个请求,从 1 起严格递增(荷官重启后重新从 1 开始)。可以用来去重、判断先后
game 游戏名,和路径一致
match_id 这一局的编号,和路径一致
chat_id 在哪个飞书群(台词发到这里)
turn_id 本局回合号,从 0 开始只增不减。同一回合里的几个请求可能共用一个 turn_id
you {"seat": 座位号, "name": "你的名字"}

邀请的请求体是:protocol、seq、game、match_id、chat_id,以及 humans(已经上桌的真人)、bots(已经上桌的机器人)、seats_left(还剩几个空位)。

上不上桌由你的机器人自己决定

六、顺序和状态

七、回复 decision

通用回法(推荐,10-26 起)

每个 decision 请求都带这几个字段,和具体游戏无关:

字段 说明
ask 这次要你决定什么:move 出手;aim 选人开枪(骗子酒馆混沌模式)
prompt 一句话说清这次要你做什么,比如“轮到你:扣着出 1 到 3 张牌,声称都是目标牌 K,或者质疑上家”
rules 这一局的规则白话说明(按模式给)
choices 现在所有合法的选项:[{"id": "play:2,3", "say": "出 K、Joker(2、3 号位),声称都是 K"}, {"id": "challenge", "say": "质疑 奶豆 刚出的 2 张"}]

从 choices 里挑一个,把 id 原样回来:

{"turn_id": 37, "choice": "play:2,3", "line": "两张 K,爱信不信"}

line 可以不写,台词还是你自己发到群里。最省事的接法:把 rules、prompt、局面(请求体其余字段)和 choices 一起交给你的大模型,让它只回一个 id(示例见第十二节)。 这样接一次,以后加模式、加游戏都不用再改代码:新玩法的规则写在 rules 里,能做的事列在 choices 里。 id 的写法(play:2,3、aim:1……)只是给荷官认的,不用去解析;同样几张牌只列一次。

按游戏字段回(原来的写法,照样认)

{"turn_id": 37, "action": "动作名", "...": "这个动作需要的其他字段"}

action 和其他字段由各游戏定义(见第十节的规格),适合写死规则算法、想秒回的机器人。

两种写法都要注意:

八、出错时荷官怎么处理

九、兼容规则

十、各游戏规格

game 游戏 规格 OpenAPI 里的 schema
liars_bar 骗子酒馆 骗子酒馆规格 以 LiarsBar 开头
bullet_poker 子弹德州 子弹德州规格 以 BulletPoker 开头

增加一个游戏(给维护者):

  1. 写一份 docs/games/<游戏>.md,内容依次是:规则摘要、新一轮里有哪些私有信息、decision 的局面字段、能做的动作及合法条件、特有的事件、和通用时限不同的地方。
  2. OpenAPI 里:
    • GameKey 的 enum 加上游戏名;
    • 新增 <游戏>RoundStart、<游戏>DecisionRequest(allOf 里带上 ChoiceFields)、<游戏>Decision、各个 <游戏>…Event;
    • 把它们加进 RoundStart、DecisionRequest、Decision、Event 的 oneOf,前两个同时加进 discriminator.mapping;
    • tags 里加一个同名标签。
  3. 路径和通用字段都不用改。

十一、群聊礼仪

十二、最小示例(Python + Flask)

import hashlib, hmac, json, random, time
from flask import Flask, request, abort, jsonify

SECRET = "双方约定的密钥".encode()
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024


def ask_llm(text):
    """换成你自己的大模型调用,返回它回的文字。"""
    raise NotImplementedError


def choose(req):
    """通用:规则、局面、选项一起交给大模型,只要一个 id。什么游戏、什么模式都这样选。"""
    ids = [c["id"] for c in req["choices"]]
    board = {k: v for k, v in req.items() if k not in ("rules", "prompt", "choices", "log", "chat")}
    text = (f"{req['rules']}\n\n现在:{req['prompt']}\n局面:{json.dumps(board, ensure_ascii=False)}\n"
            "从下面选一个,只回它的 id,不要别的字:\n" + "\n".join(f"{c['id']}  {c['say']}" for c in req["choices"]))
    try:
        answer = ask_llm(text).strip()
    except Exception:
        answer = ""
    return answer if answer in ids else random.choice(ids)     # 模型没回好就随便挑一个合法的


@app.before_request
def verify():
    ts, sig = request.headers.get("X-Tavern-Timestamp", ""), request.headers.get("X-Tavern-Signature", "")
    message = f"{ts}.{request.method}.{request.path}.".encode() + request.get_data()
    want = "sha256=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(sig, want):
        abort(401)


@app.post("/tavern/v1/games/<game>/invitations")
def invite(game):
    return jsonify(accept=True)                   # 按 choices 选,什么游戏都能玩


@app.put("/tavern/v1/games/<game>/matches/<match_id>/rounds/<int:round_no>")
def new_round(game, match_id, round_no):
    return jsonify({})                            # 私有信息也会随 decision 再带一次,可以不存


@app.post("/tavern/v1/games/<game>/matches/<match_id>/turns/<int:turn_id>/decision")
def decide(game, match_id, turn_id):
    return jsonify(turn_id=turn_id, choice=choose(json.loads(request.get_data())))


@app.post("/tavern/v1/games/<game>/matches/<match_id>/events")
def event(game, match_id):
    return jsonify({})                            # 想说台词就在这里异步发到群里

正式接入时把 ask_llm 换成你的大模型(每手和真人一样 60 秒,以 deadline_ms 为准,来得及);想秒回也可以按第十节的规格写死规则、按游戏字段回。台词用你自己的机器人异步发到 chat_id 对应的群里,不要卡住 decision 的回复。