Lingara Lingara 文档 学习指南 API 库 应用 构建 网页版
语言: 中文简体

Webhook 与事件

API 版本 2026-10-affable-towhee

Lingara 会把你账户的课程计划和用量所发生的事记录为事件,并接收来自你的游戏或应用的事件。每个事件无论以哪种方式传递,都使用同一个信封,事件目录列出了全部事件。

信封

每个事件都带有六个字段。id 以 lgr_evt_ 开头,唯一,是去重所依据的键。type 指明事件类型。created_at 是事件发生的时间。api_version 是 data 所采用的版本:即你的客户端固定的版本;在事件源和事件流中,则是你的请求在 Lingara-Version 中指定的版本。subject 以 lgr_sub_ 开头,表示事件涉及的对象:它对你的客户端保持不变,但每个客户端各不相同,而且绝不会是电子邮件地址、姓名或账户 ID。data 很小,只指明资源而不复制资源:请用所需的权限范围获取资源。

有两种类型各需要一句说明。lesson_plan.ready 对同一份计划可能到达两次,先是 data.status 为 partial,然后是 complete:可以根据第一次的事件获得一份可用的计划,或等待 complete 以获得每一组内容。usage.threshold_reached 只针对按用量计费的账户和客户端发送,而一次跨过多个阈值时只报告所跨过的最高阈值,因此不要期望每个阈值对应一个事件。

一份日志,三种接收方式

Webhook 适合拥有公开 HTTPS 端点的服务器。事件源和事件流适合没有公开端点的程序,例如在玩家机器上运行的游戏。每种方式上的信封都相同,因此程序可以先使用事件源,之后再改用 Webhook,而无需改变读取事件的方式。事件路由响应原生程序。在浏览器中运行的游戏暂时还无法调用它们,因为 /v1/ 不响应跨域预检请求。

谁会收到事件

当客户端持有 events:read 以及该事件类型自身的权限范围(见事件目录),并且事件涉及该客户端的所有者时,客户端就会收到该事件。在事件源和事件流中,访问令牌的权限范围会进一步缩小范围,而 types 会将其缩小到你指定的类型。webhook.test 只发送到它被发往的端点,从不进入事件源,也无法订阅。app.installed 和 app.uninstalled 只发送给该应用自己的客户端,绝不会发给同一账户的其他客户端。

注册端点

在 Lingara 网页应用的 Webhook 页面(地址为 app.getlingara.com/admin/webhooks)注册端点,先选择客户端。端点 URL 必须在端口 443 上使用 https,且其主机只能解析到公网地址。选择要发送的事件:只会列出该客户端的权限范围所允许的类型。URL 和要发送的事件之后都无法编辑:请添加新端点并删除旧端点。签名密钥以 lgr_whsec_ 开头,只显示一次。

验证投递

每次投递都是一个 POST,遵循 Standard Webhooks 规范,带有三个标头:webhook-id(事件的 id)、webhook-timestamp 和 webhook-signature。HMAC 密钥是签名密钥中 lgr_whsec_ 之后部分经 base64 解码的结果,绝不是密钥字符串本身。请在解析正文之前,基于其原始字节进行验证,如下所示。拒绝时间戳与当前时间相差超过五分钟的投递,这是 Standard Webhooks 库的默认值:这样可以防止被截获的投递被重放。

signed   = webhook-id + "." + webhook-timestamp + "." + raw request body
key      = base64_decode(the secret after its prefix)
expected = "v1," + base64(hmac_sha256(key, signed))
accept   if |now - webhook-timestamp| <= 5 minutes
         and some entry of webhook-signature (space-separated) equals expected
             (compare in constant time)

Standard Webhooks 为大多数语言发布了验证库。它们要求密钥写成 whsec_ 加 base64,或纯 base64,因此请把 Lingara 密钥中 lgr_whsec_ 之后的部分传给它们。Lingara 自己的库接受完整的密钥。

快速应答,预期重试

请在 10 秒内以任意 2xx 应答,之后再处理工作。其他任何结果,包括超时或 3xx(不会跟随重定向),都会以逐渐拉长的间隔重试约一天。对自动投递应答 410 会立即停用该端点;对测试或重新投递应答 410 则不会。连续五天投递失败后,端点同样会被停用。无论哪种情况,都会给其所有者发送电子邮件。Lingara 一方的故障绝不会计入停用端点的条件。在 Webhook 页面上,你可以发送测试,或重新投递最近 30 天内的任何投递。每次都只尝试一次,从不重试,即使端点已停用也会发送。

投递至少一次,且不保证顺序。同一事件可能到达两次,重试也可能在较晚的事件之后才到达。webhook-id 在每次重试中都相同,在 30 天内的重新投递中也相同。请将你处理过的每个 id 记录 30 天,并忽略重复项。如果顺序很重要,请按 created_at 排序。

轮换签名密钥

一个端点可以同时持有两个签名密钥。两者都有效期间,webhook-signature 会带有两个 v1, 条目,接受其中任一个的接收方就能继续正常工作。请把新密钥添加到你的服务器并部署,然后撤销旧密钥。

事件源

用客户端的访问令牌调用 GET /v1/events,会返回 items(信封)、next_cursor 和 has_more。令牌需要 events:read 以及你想接收的每种类型的权限范围:只有 events:read 时,事件源为空。它从现在开始。传入 start=oldest 可获取大约最近 30 天的事件。它不需要公开端点,也不需要签名密钥:访问令牌证明了请求者的身份。下面的换取申请了课程计划事件所需的两个权限范围。

export LINGARA_TOKEN="$(curl -sS --fail-with-body -X POST "https://api.getlingara.com/oauth/token" \
  -u "$LINGARA_CLIENT_ID:$LINGARA_CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  --data-urlencode "scope=events:read lesson_plans:read" | jq -r '.access_token // error(.error)')"
curl "https://api.getlingara.com/v1/events" \
  -H "Authorization: Bearer $LINGARA_TOKEN"

next_cursor 始终存在:请保存它,并作为 cursor 传回。它是不透明的。has_more 为 true 表示立即再次调用,为 false 表示你已追上最新进度:稍后再轮询,或打开事件流。超过 30 天的游标会被拒绝,返回 410 和 cursor_expired。不带游标时,事件源从现在开始,中间的事件会被跳过。要找回它们,请用 start=oldest 调用,它会回溯到事件保留的最早时间,并跳过你已处理过的 id 值。

事件流

GET /v1/events/stream 以服务器发送事件的形式传送相同的事件。每个 event 帧的 data 是一个信封,每个帧的 id: 是一个游标,与 next_cursor 是同一种值,因此你可以在事件源和事件流之间切换而不遗漏。连接断开、收到 done 帧(事件流会不时自行结束)或 error 帧后,请将 Last-Event-ID 设为你收到的最后一个 id: 并重新连接。大多数 SSE 客户端会替你完成这一步,Lingara 库中的 tailEvents 也是如此(其中的 streamEvents 只是单个连接)。它是游标,而不是事件的 id。事件流会发送心跳,因此静默的连接就是已断开的连接。

curl -N "https://api.getlingara.com/v1/events/stream" \
  -H "Authorization: Bearer $LINGARA_TOKEN"

向 Lingara 发送事件

使用 events:write 调用 POST /v1/events,以 {type, data} 的形式向 Lingara 发送事件:world.context_changed(一个 scene、source_lang、target_lang、level,以及可选的带有 name 和 persona 的 npc,还有 tags)或 world.practice_requested(一个 topic,以及同样的语言和级别)。Idempotency-Key 是必需的:最多 255 个可见 ASCII 字符,例如 UUID。缺少它时,应答为 400 和 idempotency_key_required。每个事件设置一次,重试时发送相同的键。一个键就是一个事件:一天之内,使用相同键的第二个请求会得到第一次的应答(作为 JSON 相等,而非逐字节相同),即使其正文不同;此后它会得到同一个事件,如下所示。入站事件不签名:你的访问令牌就是证明。请描述世界,而不是玩家:scene、npc、topic 或 tags 中不要包含姓名或聊天内容。

export LINGARA_TOKEN="$(curl -sS --fail-with-body -X POST "https://api.getlingara.com/oauth/token" \
  -u "$LINGARA_CLIENT_ID:$LINGARA_CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  --data-urlencode "scope=events:write lesson_plans:write" | jq -r '.access_token // error(.error)')"
curl -X POST "https://api.getlingara.com/v1/events" \
  -H "Authorization: Bearer $LINGARA_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"world.context_changed","data":{"scene":"A night market in Taipei, just after rain","npc":{"name":"Auntie Lin","persona":"a street-food vendor who likes to haggle"},"source_lang":"en","target_lang":"zh","level":3,"tags":["market","food","chapter-2"],"generate":true}}'

每个文本字段都是一行可见字符,按去除首尾空白后计数:scene 和 topic 最多 160,npc.name 最多 32,npc.persona 最多 120。换行符、制表符和其他控制字符会被拒绝,不可见字符和格式字符同样会被拒绝:方向覆盖字符、零宽字符(某些文字和表情符号所需的连接符除外)、标签区块字符以及私用区字符。tags 最多包含 8 个小写机器标记,每个最多 24 个字符,且绝不会进入课程计划。level 为 1 到 9,两种语言必须不同。超出这些限制的请求会被拒绝,返回 400,且不记录任何事件。

使用 "generate": true(world.practice_requested 的默认值)时,令牌还需要 lesson_plans:write。缺少它时,请求会被拒绝,返回 403,且不记录任何事件。具备它时,Lingara 会开始一份课程计划,检查和计费与直接创建课程计划相同,202 应答中的 reaction 说明发生了什么。若为 started 且 plan_status 为 generating,随后会在你使用的每种方式上收到一个 lesson_plan.ready 或 lesson_plan.failed,其 data.plan_id 即应答中的 plan_id。若为 partial 或 complete,则计划来自计划库,现在即可读取,且不承诺发送事件:事件仍可能到达,所以只有 generating 值得等待。若为 refused 或 failed,事件依然成立。它不会在同一个键下重试,因此请发送新事件再试。

一天之内的重试会得到第一次的应答。此后的重试会根据存储的事件重建应答,它保留了事件所启动的计划,但不保留反应被拒绝的原因。因此,较晚的重试可能以 reaction failed 和 internal 应答:这表示第一次的结果没有被记录,而不是说计划不存在。如果你保留了 plan_id,请按它读取计划,或者发送新事件。

Tidewater Games:没有服务器的游戏

Tidewater Games 是一家虚构的工作室,正在用 Godot 制作一款让玩家探索夜市的游戏。它的开发者在自己的机器上、用自己的客户端运行这款游戏。

玩家走进一家面摊。游戏发布带有 "generate": true 的 world.context_changed(即上文“向 Lingara 发送事件”一节中的命令),并保留应答中的 plan_id。

如果 plan_status 为 generating,游戏就读取事件流或轮询事件源,直到带有该 plan_id 的 lesson_plan.ready 到达。然后它用 lesson_plans:read 读取计划,如下所示。如果计划已经是 complete,它会立即读取。

export LINGARA_TOKEN="$(curl -sS --fail-with-body -X POST "https://api.getlingara.com/oauth/token" \
  -u "$LINGARA_CLIENT_ID:$LINGARA_CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  --data-urlencode "scope=lesson_plans:read" | jq -r '.access_token // error(.error)')"
curl "https://api.getlingara.com/v1/lesson-plans/$ID" \
  -H "Authorization: Bearer $LINGARA_TOKEN"

后来,工作室添加了一台带有 HTTPS 端点的小型服务器,并为 lesson_plan.ready 注册了它。同一事件会以相同的 id 到达那里,而游戏读取事件的代码无需改变。

客户端密钥绝不能随游戏构建一起发布,因为玩家设备上的任何东西都能被读取。在 Lingara 支持代表玩家登录之前,运行在玩家机器上的游戏应与自己的服务器通信,只有开发者自己的副本才直接与 Lingara 通信。

事件的费用

与每次 /v1/ 调用一样,你的客户端用量会计入每个被接受的入站事件、每次事件源调用和每次打开的事件流。使用 "generate": true 发送的事件还会计为一份课程计划。每次 Webhook 投递按每个事件每个端点计一次,在其首次 2xx 时计入,无论那是第几次尝试。它绝不会再次计入,测试也从不计入。GET /v1/usage 显示本月迄今的用量。

如译文与英文参考文档不一致,以英文参考文档为准。