connector-x

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Posting to X with
x-client

使用
x-client
向 X 发帖

Motoko bindings for the X API v2, generated from X's OpenAPI spec. The write path is
TweetsApi.createPosts
(
POST /2/tweets
); the request model is
TweetCreateRequest
.
针对 X API v2 的 Motoko 绑定, 根据 X 的 OpenAPI spec 生成。发布路径是
TweetsApi.createPosts
(
POST /2/tweets
);请求模型为
TweetCreateRequest
。

Backend

后端

A minimal canister that posts a tweet on behalf of a user holding an OAuth 2.0 bearer token (token acquisition/refresh is canister-side — see below). Non- replicated is the default, so you just supply the token; every optional field must be present, and
null
means "not supplied":
motoko
import { createPosts } "mo:x-client/Apis/TweetsApi";
import { type TweetCreateRequest } "mo:x-client/Models/TweetCreateRequest";
import { defaultConfig } "mo:x-client/Config";

persistent actor {
  // Post a tweet on behalf of a user holding an OAuth 2.0 bearer token.
  public func postTweet(accessToken : Text, body : Text) : async () {
    let cfg = { defaultConfig with auth = ?#bearer accessToken };
    let req : TweetCreateRequest = {
      text_ = ?body;
      for_super_followers_only = null; poll = null; reply = null;
      reply_settings = null; media = null; geo = null; quote_tweet_id = null;
      nullcast = null; direct_message_deep_link = null; community_id = null;
      card_uri = null; edit_options = null; made_with_ai = null;
      paid_partnership = null; share_with_followers = null;
    };
    ignore await* createPosts(cfg, req);
  };
}
The text field is
text_ : ?Text
(the trailing underscore avoids the Motoko keyword collision; it serialises to the JSON key
"text"
).
一个最小化的 canister,可代表持有 OAuth 2.0 bearer token 的用户发布 tweet(token 的获取和刷新在 canister 端完成——见下文)。默认采用非复制 模式,因此只需传入 token;所有可选字段都必须显式提供,而
null
表示 “未提供”:
motoko
import { createPosts } "mo:x-client/Apis/TweetsApi";
import { type TweetCreateRequest } "mo:x-client/Models/TweetCreateRequest";
import { defaultConfig } "mo:x-client/Config";

persistent actor {
  // Post a tweet on behalf of a user holding an OAuth 2.0 bearer token.
  public func postTweet(accessToken : Text, body : Text) : async () {
    let cfg = { defaultConfig with auth = ?#bearer accessToken };
    let req : TweetCreateRequest = {
      text_ = ?body;
      for_super_followers_only = null; poll = null; reply = null;
      reply_settings = null; media = null; geo = null; quote_tweet_id = null;
      nullcast = null; direct_message_deep_link = null; community_id = null;
      card_uri = null; edit_options = null; made_with_ai = null;
      paid_partnership = null; share_with_followers = null;
    };
    ignore await* createPosts(cfg, req);
  };
}
文本字段为
text_ : ?Text
(末尾的下划线用于避免与 Motoko keyword 冲突;它会序列化为 JSON 键
"text"
)。

OAuth 2.0 setup — PKCE, no client secret

OAuth 2.0 设置——PKCE,无 client secret

Every write endpoint (
/2/tweets
most prominently) needs a per-user OAuth 2.0 bearer token.
x-client
is built for the PKCE flow, so there is no client secret — only a public Client ID.
  1. Visit the X Developer Portal, create a Project (Free tier = 1500 posts/month), and an App.
  2. App → Settings → User authentication settings → Edit, toggle OAuth 2.0 on. Type of App:
    Web App, Automated App or Bot
    (PKCE). Do not pick
    Native App
    or a "Confidential Client" — those force a client-secret flow this client does not emit.
  3. Callback URI: your canister's HTTPS endpoint receiving
    ?code=…
    , exact string match (e.g.
    https://<canister-id>.ic0.app/oauth/x/callback
    ).
  4. Scopes to request at authorise-time:
    ScopeWhy
    tweet.write
    Required for
    createPosts
    / posting
    tweet.read
    Show "connected as @…" in the UI
    users.read
    Resolve the authenticated user
    offline.access
    Issue a refresh token (access tokens last ~2 h)
  5. Save; copy the OAuth 2.0 Client ID (a ~30-char public string). It is not a secret — safe to commit, log, or hard-code.
Deployment models — pick one or support both: a single canister-wide Client ID set once by an admin (default), or per-user Client IDs for multi-tenant apps that shouldn't share rate-limit quota.
Scopes are requested at authorise-time but silently absent from the issued token if unticked — "Insufficient OAuth scope" on
createPosts
almost always means
tweet.write
was missing.
每个写入 endpoint(尤其是
/2/tweets
)都需要一个按用户的 OAuth 2.0 bearer token。
x-client
基于 PKCE flow 构建,因此没有 client secret——只需要公开的 Client ID。
  1. 访问 X Developer Portal, 创建一个 Project(Free tier = 每月 1500 条帖子)和一个 App。
  2. App → Settings → User authentication settings → Edit,开启 OAuth 2.0。Type of App:选择
    Web App, Automated App or Bot
    (PKCE)。不要选择
    Native App
    或 “Confidential Client”——这些选项会强制采用 client-secret flow,而本 client 不会生成这种 flow。
  3. Callback URI:你的 canister 用于接收
    ?code=…
    的 HTTPS endpoint, 字符串必须完全匹配(例如
    https://<canister-id>.ic0.app/oauth/x/callback
    )。
  4. 授权时需要请求以下 Scopes:
    Scope用途
    tweet.write
    createPosts
    / 发帖所必需
    tweet.read
    在 UI 中显示“已连接为 @…”
    users.read
    解析已认证的用户
    offline.access
    签发 refresh token(access token 有效期约 2 小时)
  5. 保存设置;复制 OAuth 2.0 Client ID(一个约 30 个字符的公开字符串)。 它不是 secret,可以安全地提交到代码库、记录到日志或硬编码。
Deployment models——可以选择一种或同时支持:单一 canister-wide Client ID,由管理员一次性设置(默认);或者为不应共享 rate-limit quota 的 多租户应用提供 per-user Client ID。
Scopes 会在授权时请求,但如果未勾选,就会被静默地从签发的 token 中省略——
createPosts
报“Insufficient OAuth scope”时,几乎总是因为缺少
tweet.write
。

Calls are non-replicated by default

默认情况下调用采用非复制模式

Every
x-client
call is an
http_request
on the IC. The package ships
is_replicated = ?false
in
defaultConfig
: X is side-effecting (posting mutates state) and its rate-limit headers / response timestamps vary per request, so a replicated outcall — every subnet node issuing the request, the IC demanding a bit-identical response, ~13× cycles — would post duplicates and fail consensus. You don't set it yourself; the default is correct. Override with
is_replicated = ?true
only if you specifically need consensus.
每个
x-client
调用都是 IC 上的
http_request
。该 package 的
defaultConfig
中包含
is_replicated = ?false
:X 调用会产生副作用 (发帖会改变状态),并且其 rate-limit headers 和 response timestamps 会随请求而变化,因此 replicated outcall——即每个 subnet node 都发出 请求、IC 要求返回逐位相同的响应、消耗约 13 倍 cycles——将导致重复发帖并 造成共识失败。你无需自行设置;默认值是正确的。只有在你明确需要共识时, 才将其覆盖为
is_replicated = ?true
。

Optional fields: leave them
null

可选字段:保持为
null

x-client
strips null-valued optional fields from the outbound JSON (via the
serde-core
skip_null_fields
option), so
/2/tweets
sees only the fields you set. Construct a
TweetCreateRequest
with
text_ = ?"…"
and every other field
null
(as in the snippet above) and the body validates. Motoko requires all record fields to be present at the value site — the
null
s are how you say "not supplied".
x-client
会通过
serde-core
的
skip_null_fields
option,从发往外部的 JSON 中剔除值为 null 的可选字段,因此
/2/tweets
只会看到你设置的字段。 构造
TweetCreateRequest
时,设置
text_ = ?"…"
,并将其他所有字段设为
null
(如上面的代码片段所示),请求体便可通过校验。Motoko 要求在构造值时 提供 record 的所有字段——这些
null
就是表示“未提供”的方式。

Sub-object rules for the non-null optionals

非 null 可选子对象的规则

If you set
poll
,
reply
,
geo
,
media
, or
edit_options
to
?Some
, X enforces that sub-object's own required fields — you cannot send an empty object, so either leave the field
null
or populate it fully:
  • poll
    —
    options
    (≥ 2) and
    duration_minutes
    .
  • reply
    —
    in_reply_to_tweet_id
    .
  • media
    —
    media_ids
    (must be pre-uploaded).
  • geo
    —
    place_id
    .
如果将
poll
、
reply
、
geo
、
media
或
edit_options
设置为
?Some
, X 会强制要求该子对象自身包含必填字段——不能发送空对象,因此要么将字段 保持为
null
,要么完整填写:
  • poll
    ——
    options
    (至少 2 个)和
    duration_minutes
    。
  • reply
    ——
    in_reply_to_tweet_id
    。
  • media
    ——
    media_ids
    (必须预先上传)。
  • geo
    ——
    place_id
    。

Token refresh

Token 刷新

Access tokens expire (~2 h). Before each call the canister should refresh when within a safety buffer of
expires_at
, POSTing
grant_type=refresh_token
to
https://api.x.com/2/oauth2/token
with the stored
refresh_token
and Client ID. X rotates refresh tokens on every refresh — store the new
access_token
and
refresh_token
; reusing the old refresh token returns 400 and forces re-authorisation.
x-client
has no knowledge of refresh — it's canister-side; see the
posting-to-x
extension for the canonical code shape.
Access token 约 2 小时后过期。每次调用前,如果距离
expires_at
已进入 安全缓冲时间,canister 应刷新 token:向
https://api.x.com/2/oauth2/token
POST
grant_type=refresh_token
,同时提交 存储的
refresh_token
和 Client ID。X 每次刷新都会轮换 refresh token——务必存储新的
access_token
和
refresh_token
;复用旧的 refresh token 会返回 400,并被迫重新授权。
x-client
不包含 refresh 逻辑——它由 canister 端处理;规范代码结构请参阅
posting-to-x
扩展。

Rate limits

速率限制

Free tier: 1500 posts/month, 500 reads/month per app. Back off on HTTP 429 in production; never silently retry a post (a retry may duplicate the tweet). X's rate-limit headers come back in the response body but the package does not interpret them.
Free tier:每个 app 每月 1500 条帖子、500 次读取。生产环境中遇到 HTTP 429 时应进行退避;绝不要静默重试发帖(重试可能导致重复 tweet)。X 的 rate-limit headers 会在响应体中返回,但该 package 不会解析它们。