其他
在 Caffeine AI 构建中需要向…
当 canister 需要代表已登录用户向 X 发布推文、状态更新等写入操作时使用。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 35/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 55/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 39/100 ×0.15
适用场景
在 Caffeine AI 构建中需要向特定
解决思路
- 在构建规格中把 X/Twitter 发帖作为 Caffeine AI 一等平台能力处理;提及发推时必须声明 `x-client` 依赖并引用此技能,避免后端回退到手写 HTTP。
- 使用 `x-client` mops 包作为唯一支持路径;不要手写 `ic.http_request` 或 `icBooking.http_request` 调用 `api.x.com/2/tweets`、`api.x.com/2/oauth2/token` 或其他 X endpoint。
- 将读取和写入分开:此技能只覆盖 X 写入(tweet、retweet、quote-tweet、status update、live-tweet);读取(timeline、search、user lookup)用 `extension-http-outcalls`。
- 添加依赖:用 `mops add x-client@0.3.0` 更新 `mops.toml` 和 `mops.lock`,不要手动编辑。
- 保证最低版本 `x-client ≥ 0.3.0`;0.3.0 的 `init` 构造器在 Motoko 中把可选字段默认 `null` 并在线路上省略,避免 `/2/tweets` 因 `field: null` 返回最多 16 个校验错误。
- 后端 ingredient:使用 `x-client` mops 包生成的 Motoko bindings,包含 `TweetsApi.createPosts` 等。
- 后端 ingredient:实现 OAuth 2.0 Authorization Code with PKCE,每个终端用户独立授权;存储 `access_token` + `refresh_token`,按 `caller : Principal` 键。
- 后端 ingredient:使用 X Developer App 的 Client ID(公开标识,不是 secret);三选一:Admin Client ID(默认 §4)、Per-user Client ID(§10)、Fallback(§11)。
- 后端 ingredient:`Config` 固定 `is_replicated = ?false`。
- 先决条件:集成 `extension-authorization`;X 每个有意义的 endpoint 都要求签名调用者;per-user OAuth 按 `caller` 存 token;admin/fallback 的 Client ID setter 需要 `#admin`。没有它 `caller.isAnonymous()` 总为 true,部署 canister 拒绝每次发帖。
- 使用 `extension-authorization` 提供的前端 `useInternetIdentity`、登录/登出按钮、auth-state-aware routing、`useActor` plumbing,以及后端 caller/role infrastructure。
- OAuth scopes 在 authorize 时请求 `tweet.read`、`users.read`、`tweet.write`、`offline.access`;`tweet.write` 是 `/2/tweets` 必需,`offline.access` 用于拿 `refresh_token`,`tweet.read`/`users.read` 用于显示 connected as @… 和解析用户。
- Token 存储:用 `Map<Principal, XAuth>` 按 caller 存 token;bearer 永不离开 canister;前端只学 `Bool` 连接状态。
- 只暴露 endpoints:`isMyXConnected`、`startXOAuth`、`completeXOAuth`、`tweet`、可选 `disconnectMyX`;每个 endpoint 检查 `not caller.isAnonymous()`。
- 不要添加返回 `access_token` / `refresh_token` / 完整 `XAuth` 记录的 endpoint;内部只用在 `tweet`/`ensureFreshToken` 中的 `Map.get` 读取,且不在该 caller 范围外迭代 map。
- Upgrade 时 map 默认保留;只有在想强制所有用户重新授权时才丢弃。
- 在 `Config` 上始终设置 `is_replicated = ?false`:避免 replicated HTTP outcall 在每个 subnet 节点发送 bearer 导致泄漏,避免 N 倍 API 调用和 ~13× cycles billing,避免 X 可变 rate-limit headers 让副本共识 diff 失败。
- 采用默认 canonical layout(admin Client ID + per-user OAuth):canister owner 注册一个 X Developer App,把 Client ID 贴入 canister-level config;每个终端用户针对该 Client ID 跑 OAuth PKCE,最终各自持有 token。
- 后端文件布局:`src/backend/main.mo` actor state + `include`;`src/backend/migrations/00000000_000000.mo` migration chain head;`src/backend/mixins/x-config.mo` admin Client ID;`src/backend/mixins/x-posting.mo` per-user OAuth + posting;`src/backend/lib/x.mo` x-client glue。
- `main.mo` 中:import `Map`、`Principal`、`AccessControl`、`MixinAuthorization`、`MixinXConfig`、`MixinXPosting`、`LibX`;声明 `accessControlState`,`include MixinAuthorization(accessControlState, null)`;声明 `xClientId : { var value : ?Text }`,`include MixinXConfig(accessControlState, xClientId)`;声明 `xAuthByUser : Map.Map<Principal, LibX.XAuth>`,`include MixinXPosting(xClientId, xAuthByUser)`。
- Migration head 中:定义 `XAuth` 字段 `access_token`、`refresh_token`、`expires_at : Nat64`、`scope : [Text]`;定义 `NewActor` 字段 `accessControlState`、`xClientId`、`xAuthByUser`;`migration(_old : {})` 返回 `AccessControl.initState()`、`{ var value : ?Text = null }`、`Map.empty<Principal, XAuth>()`。
- `x-config.mo` 中:mixin 参数 `accessControlState` 和 `xClientId`;`isXClientIdConfigured()` query 返回 `xClientId.value != null`;`setXClientId(id : Text)` shared 检查 `AccessControl.hasPermission(accessControlState, caller, #admin)`,不是 admin 时 `Runtime.trap(Unauthorized: Only admins can set the X Client ID)`,然后赋值。
- `x-posting.mo` 中:mixin 参数 `xClientId` 和 `xAuthByUser`;`isMyXConnected()` query 返回 `Map.containsKey(xAuthByUser, Principal.compare, caller)`。
- `startXOAuth(redirectUri : Text)`:匿名 trap `Sign in to connect X`;Client ID 缺失 trap `X is not configured (admin must set the Client ID)`;返回 `await* LibX.startAuthorize(clientId, redirectUri, caller)`。
- `completeXOAuth(code : Text, redirectUri : Text)`:匿名 trap `Sign in to connect X`;Client ID 缺失 trap `X is not configured`;调用 `await* LibX.exchangeCode(clientId, code, redirectUri, caller)`,然后 `Map.add(xAuthByUser, Principal.compare, caller, auth)`。
- `tweet(body : Text)`:匿名 trap `Sign in to post`;Client ID 缺失 trap `X is not configured`;没有该 caller 的 auth 时 trap `Connect your X account first`;调用 `await* LibX.ensureFreshToken(clientId, auth)`;如果 `fresh.access_token != auth.access_token`,把新 token pair `Map.add` 持久化;调用 `await* LibX.runCreatePost(LibX.configForToken(fresh.access_token), body)`。
- `disconnectMyX()`:匿名 trap `Sign in to disconnect`;`Map.remove(xAuthByUser, Principal.compare, caller)`。
- `lib/x.mo` 中:定义 `XAuth` 类型(同 migration);`configForToken(token : Text)` 返回 `{ defaultConfig with auth = ?#bearer token; is_replicated = ?false }`。
- `runCreatePost(config : Config, body : Text)`:用 `{ TweetCreateRequest.init {} with text_ = ?body }` 构造请求;调用 `await* TweetsApi.createPosts(config, req)`;`resp.data` 必须有值,否则 `Runtime.trap(createPosts returned no data)`;返回 `data.id`。
- 实现 OAuth PKCE 三个函数(`x-client` 只提供 post-token 调用面;OAuth 握手本身用 `ic.http_request` 直接实现),把 per-caller `code_verifier` 存在与 `xAuthByUser` 平行的 `Map<Principal, Text>` actor state 中。
- `startAuthorize`:生成 43-128 字符且属于 `[A-Za-z0-9-._~]` 的 `code_verifier`;按 `caller` 持久化;计算 `code_challenge = base64url(sha256(code_verifier))`;返回 `https://x.com/i/oauth2/authorize?response_type=code&client_id={clientId}&redirect_uri={redirectUri}&scope=tweet.read+tweet.write+users.read+offline.access&state={fresh-csrf-token persisted alongside the verifier}&code_challenge={challenge}&code_challenge_method=S256`。
- `exchangeCode`:用 `ic.http_request` 且 `is_replicated=false` POST `https://api.x.com/2/oauth2/token`;`Content-Type: application/x-www-form-urlencoded`;body 为 `grant_type=authorization_code&code={code}&redirect_uri={redirectUri}&client_id={clientId}&code_verifier={the verifier persisted in startAuthorize for caller}`;解析 JSON 返回 `XAuth { access_token; refresh_token; expires_at = Time.now() + expires_in*1_000_000_000; scope }`。
- `ensureFreshToken`:如果 `Time.now() + 60s < auth.expires_at`,原样返回 auth;否则 POST `https://api.x.com/2/oauth2/token`,body 为 `grant_type=refresh_token&refresh_token={auth.refresh_token}&client_id={clientId}`;X 会轮换 refresh token,总是持久化新 pair(由调用 mixin 处理保存)。
- Admin Client ID 变体不变量:admin 设置 Client ID,绝不设置 access token;Client ID 与 access token 使用不同存储形状(`{ var value : ?Text }` vs `Map<Principal, XAuth>`)和不同 gate(`#admin` vs logged in)。
- Admin 变体不要 `getXClientId` endpoint;只暴露 `isXClientIdConfigured : Bool`,前端只需要知道是否渲染 Connect X 按钮,不需要显示 Client ID。
- Admin 变体 `xAuthByUser` 只按 caller 访问;不要生成 `getMyXAuth`、`getX`、`myAccessToken` 或任何返回 `?XAuth`/`?Text`/`Text` 的 shared/query 函数;不要 console.log bearer。
- Admin 变体缺前置条件时用不同 trap 消息:`X is not configured`(缺 Client ID,管理员任务)、`Connect your X account first`(用户未授权,前端应启动 `startXOAuth`)、`Sign in to ...`(匿名,需要登录)。
- 两种调用形态:函数形态 `TweetsApi.createPosts(config, req) : async* T` 用 `await*`;suite 形态 `let api = TweetsApi(config); api.createPosts(req) : async T`;两者可互换,但同一个 `shared` body 中不要混用。
- 可用 API surface:`x-client@0.3.0` 中 `TweetsApi.createPosts` 发推 `/2/tweets`;`TweetsApi.deleteTweetById` 删除 `/2/tweets/{id}`;`UsersApi.findMyUser` 获取认证用户 handle/profile。
- X 读取(timeline、search、lookup)不在 `x-client` 覆盖范围,用 `extension-http-outcalls`;如果 spec 需要 `x-client@0.3.0` 未覆盖的 X 写入(media upload、replies-to-replies、retweet endpoints 等),在 `caffeinelabs/skills-internal` 提 issue,不要手写 `ic.http_request`。
- Cycles:`defaultConfig.cycles = 30_000_000_000`(约 0.04 USD at 4 USD/T)足够典型 `createPosts`;长推文(premium 最多 25000 字符)设 `cycles = 60_000_000_000`;OAuth token-exchange 小,默认预算足够。
- 避免 `x-client < 0.3.0`,因为旧版为每个可选字段发 `field: null`,`/2/tweets` 拒绝并可能返回最多 16 个校验错误。
- 不要暴露 access token;`xAuthByUser` 只在 `tweet`/`ensureFreshToken` 内通过 `Map.get(xAuthByUser, ..., caller)` 读取;不要 getter、iterator、log。
- 持久化轮换后的 refresh token;X 每次 refresh 返回新 `refresh_token`,继续用旧的下一次 refresh 会 400;mixin 用 `if (fresh.access_token != auth.access_token)` 分支保存新 pair。
- Token 约 2 小时过期;authorize scopes 必须包含 `offline.access` 才拿 `refresh_token`,否则用户每次都要重新授权。
- Callback URI 必须与 Developer Portal 注册的 URI 逐字符匹配(trailing slash、query string、port);否则 X 返回 `redirect_uri_mismatch`。
- 不要自己滚 JSON;`x-client` 已通过 `JSON.toCandid`/`JSON.fromCandid` 和 `serde-core` 的 null-elision 处理请求/响应 JSON。
- 永远不要有 `getApiKey` 风格 endpoint;任何 shared/query 返回 `?XAuth`、`?Text`(access token)或 bearer 前缀都是泄漏。
- Rate limit:`/2/tweets` 按 per-user-per-app 限制;replicated outcalls 会把 RPM 乘以 subnet 大小,进一步要求 `is_replicated = ?false`;HTTP 429 时退避。
- 前端绝不持有 token;React 调后端 `tweet(body)`,后端中介一切;OAuth 用 redirect-and-back 经 `x.com`,前端只调 `startXOAuth(redirectUri)` 和 `completeXOAuth(code, redirectUri)`,token 不达浏览器。
- Per-user Client ID 变体:当每个终端用户必须自带 X Developer App(多租户 rate-limit 隔离、每用户 Developer Portal 控制);把 Client ID 存储从单个 `{ var value : ?Text }`(admin 设置)翻转为 `Map<Principal, Text>`(per-user);§4 的 OAuth+posting mixin 除 Client ID 查找外不变。
- Per-user 变体 actor:移除 admin-Client-ID mixin,加入 per-user-Client-ID mixin;`let xClientIdByUser : Map.Map<Principal, Text>`;`include MixinXClientIdPerUser(xClientIdByUser)`;保留 `xAuthByUser`,`include MixinXPostingPerUserClientId(xClientIdByUser, xAuthByUser)`。
- Per-user 变体 migration:`NewActor` 用 `xClientIdByUser` 替代 `xClientId`;`migration` 返回 `AccessControl.initState()`、`Map.empty<Principal, Text>()`、`Map.empty<Principal, XAuth>()`。
- Per-user 变体 mixin 适配:`mixins/x-clientid-per-user.mo` 把 admin gate 换成 signed-in-caller gate,`setMyXClientId(id)` 写 caller 的 slot,`isMyXClientIdConfigured` 读同一 slot;`mixins/x-posting-per-user-clientid.mo` 按 caller 查 Client ID,其余同 §4。
- Per-user 变体同样 no-getter:不要 `getMyXClientId`,即使 Client ID 公开,也保持边界一致,避免 agent grep key/id 后加 getter。
- Fallback 变体:当运营者想给 casual 用户无配置路径、给 power 用户自注册;admin 设置 canister-wide 默认 Client ID,用户可以 override。
- Fallback lookup:在 OAuth start 时 `clientIdFor(caller)`:`switch (Map.get(xClientIdByUser, Principal.compare, caller)) { case (?id) ?id; case null adminClientId.value }`(admin 值也可能 null,调用者必须提供)。
- Fallback 实现:同一 actor 同时装 §4 和 §10 的 mixin;admin 用 `setXClientId` 设默认,用户用 `setMyXClientId` override;`startXOAuth` 调 `clientIdFor(caller)` 而不是读单槽;其余不变。
- 前端必须提供登录流程(所有变体必需):来自 `extension-authorization` 的 `useInternetIdentity`、登录/登出按钮、`useActor` plumbing,把认证 identity 注入每个后端调用;如果没有,把 sign-in screen 放入同一 task graph。
- 前端必须提供 Client ID 配置界面:Admin 变体在 admin-gated `/settings/x` 页面用 password-input 绑定 `setXClientId(id)`;Per-user 变体在任意登录用户可见的个人 `/settings/x` 绑定 `setMyXClientId(id)`;Fallback 两个页面都有。
- 前端始终提供 Connect X 页面:per-user 且非 admin-gated,通过 `startXOAuth(redirectUri)` 启动,重定向到 X 同意,回到同一页面带 `?code=...`,调 `completeXOAuth(code, redirectUri)`;根据 `isMyXConnected()` 显示 X connected as @handle 或 Connect X。
- 默认选 Variant A(admin Client ID + per-user OAuth),除非 spec 明确选择 per-user(§9)或 fallback(§10)。
- Variant A 页面:admin settings `/settings/x` admin-gated:password-input 绑定 `setXClientId(id)`,enter 提交,成功后清空;状态用 `isXClientIdConfigured()` 显示 Configured/Not configured,绝不显示 Client ID 或 getter;用 `extension-authorization` 的 `isCallerAdmin` query 隐藏非 admin 的 nav 链接和页面,并用 router guard 绑定 admin-only routes。
- Variant A Connect X `/connect/x` any signed-in:Connect X 按钮绑定 `startXOAuth(window.location.origin + '/connect/x')`,浏览器跳到返回 URL;返回 leg 解析 `?code=...&state=...`,调 `completeXOAuth(code, redirectUri)`(同一 redirectUri),再跳回原处/首页;状态由 `isMyXConnected()` 驱动,显示 Connected as @…(handle 通过单独的 `getMyXHandle` endpoint 调 `UsersApi.findMyUser` 获取,绝不在 JS 解码 bearer);可选 Disconnect 按钮绑定 `disconnectMyX()`。
- Variant A 在发推 UI 空状态加 nudge:`isMyXConnected()` 为 false 时渲染 inline Connect X to post 链接到 `/connect/x`,避免用户只看到 Connect your X account first 无下一步。
- Variant A 建议路由:`/` 主 UI(任意登录用户;无 X 连接时 empty-state);`/settings/x` admin-only;`/connect/x` per-user OAuth(任意登录用户)。
- Variant B 页面:`/settings/x` 个人 Client ID 页面,password-input 绑定 `setMyXClientId(id)`,同样不显示;状态用 `isMyXClientIdConfigured()`;除登录外无 router guard。`/connect/x` 同 Variant A,但 `startXOAuth` 用用户自己的 Client ID;用户必须先配置 Client ID 再连接。路由 `/`、`/settings/x`、`/connect/x`。
- Variant C 页面:`/admin/settings/x` admin-gated 设 `setXClientId` 默认;`/settings/x` 任意登录用户设 `setMyXClientId` override;`/connect/x` 任意登录用户做 OAuth,使用 §10 lookup 顺序。Connect X 按钮在 caller 能解析出某个 Client ID(admin default 或 per-user override)前保持 disabled。
- 所有变体通用:每个 X 相关 route 都需要登录;把 `/settings/...` 和 `/connect/x` 接到 `extension-authorization` auth guard(`useInternetIdentity` + `!isAuthenticated` 时 redirect);匿名调用者先撞 please sign in 墙再发后端调用,否则 endpoint trap Sign in to ...。
- 所有变体通用:前端绝不持久化 token;不用 localStorage、IndexedDB、cookies;canister 中介一切;浏览器只看到 `Bool` 状态标志(`isMyXConnected`、`isXClientIdConfigured`)和 OAuth redirect URL。
- 所有变体通用:OAuth `state` 参数由 canister 负责;在 `startXOAuth` 服务端生成,和 `code_verifier` 一起持久化,在 `completeXOAuth` 交换 code 前验证;不要让前端生成或回显 state,否则破坏 CSRF 保护。
- 所有变体通用:发推 UI 很简单:textarea、submit 按钮、最近推文列表绑定 `tweet`/history endpoints;无客户端 X SDK、无 token 处理、无 JSON 序列化逻辑,canister 就是 X client。
- 相关参考:`mops add x-client@0.3.0`;`skills/connector-x/SKILL.md`;X Developer Portal;OAuth 2.0 Authorization Code with PKCE X docs;`/2/tweets` API reference;RFC 7636;`extension-authorization` 是每个变体必需前置;`extension-http-outcalls` 处理 X reads。
适用边界 · 注意事项
- 读取 X(timeline、search、user lookup)不适用此技能,改用 `extension-http-outcalls`。
- 需要 `x-client@0.3.0` 未覆盖的 X 写入(如 media upload、replies-to-replies、retweet endpoints)时,不要手写 `ic.http_request`,应提 issue。
- 没有 Caffeine AI / mops / `x-client` / `caffeineai-authorization` / Internet Identity / X Developer App Client ID 的环境不适用。