connector-x
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePosting to X with x-client
x-client使用 x-client
向 X 发帖
x-clientMotoko bindings for the X API v2,
generated from X's OpenAPI spec. The write path is
(); the request model is .
TweetsApi.createPostsPOST /2/tweetsTweetCreateRequest针对 X API v2 的 Motoko 绑定,
根据 X 的 OpenAPI spec 生成。发布路径是
();请求模型为 。
TweetsApi.createPostsPOST /2/tweetsTweetCreateRequestBackend
后端
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 means "not supplied":
nullmotoko
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 (the trailing underscore avoids the Motoko
keyword collision; it serialises to the JSON key ).
text_ : ?Text"text"一个最小化的 canister,可代表持有 OAuth 2.0 bearer token 的用户发布
tweet(token 的获取和刷新在 canister 端完成——见下文)。默认采用非复制
模式,因此只需传入 token;所有可选字段都必须显式提供,而 表示
“未提供”:
nullmotoko
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);
};
}文本字段为 (末尾的下划线用于避免与 Motoko keyword
冲突;它会序列化为 JSON 键 )。
text_ : ?Text"text"OAuth 2.0 setup — PKCE, no client secret
OAuth 2.0 设置——PKCE,无 client secret
Every write endpoint ( most prominently) needs a per-user OAuth 2.0
bearer token. is built for the PKCE flow, so there is no
client secret — only a public Client ID.
/2/tweetsx-client-
Visit the X Developer Portal, create a Project (Free tier = 1500 posts/month), and an App.
-
App → Settings → User authentication settings → Edit, toggle OAuth 2.0 on. Type of App:(PKCE). Do not pick
Web App, Automated App or Botor a "Confidential Client" — those force a client-secret flow this client does not emit.Native App -
Callback URI: your canister's HTTPS endpoint receiving, exact string match (e.g.
?code=…).https://<canister-id>.ic0.app/oauth/x/callback -
Scopes to request at authorise-time:
Scope Why tweet.writeRequired for / postingcreatePoststweet.readShow "connected as @…" in the UI users.readResolve the authenticated user offline.accessIssue a refresh token (access tokens last ~2 h) -
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 almost always means
was missing.
createPoststweet.write每个写入 endpoint(尤其是 )都需要一个按用户的 OAuth 2.0
bearer token。 基于 PKCE flow 构建,因此没有 client
secret——只需要公开的 Client ID。
/2/tweetsx-client-
访问 X Developer Portal, 创建一个 Project(Free tier = 每月 1500 条帖子)和一个 App。
-
App → Settings → User authentication settings → Edit,开启 OAuth 2.0。Type of App:选择(PKCE)。不要选择
Web App, Automated App or Bot或 “Confidential Client”——这些选项会强制采用 client-secret flow,而本 client 不会生成这种 flow。Native App -
Callback URI:你的 canister 用于接收的 HTTPS endpoint, 字符串必须完全匹配(例如
?code=…)。https://<canister-id>.ic0.app/oauth/x/callback -
授权时需要请求以下 Scopes:
Scope 用途 tweet.write/ 发帖所必需createPoststweet.read在 UI 中显示“已连接为 @…” users.read解析已认证的用户 offline.access签发 refresh token(access token 有效期约 2 小时) -
保存设置;复制 OAuth 2.0 Client ID(一个约 30 个字符的公开字符串)。 它不是 secret,可以安全地提交到代码库、记录到日志或硬编码。
Deployment models——可以选择一种或同时支持:单一 canister-wide
Client ID,由管理员一次性设置(默认);或者为不应共享 rate-limit quota 的
多租户应用提供 per-user Client ID。
Scopes 会在授权时请求,但如果未勾选,就会被静默地从签发的 token 中省略——
报“Insufficient OAuth scope”时,几乎总是因为缺少
。
createPoststweet.writeCalls are non-replicated by default
默认情况下调用采用非复制模式
Every call is an on the IC. The package ships
in : 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
only if you specifically need consensus.
x-clienthttp_requestis_replicated = ?falsedefaultConfigis_replicated = ?true每个 调用都是 IC 上的 。该 package 的
中包含 :X 调用会产生副作用
(发帖会改变状态),并且其 rate-limit headers 和 response timestamps
会随请求而变化,因此 replicated outcall——即每个 subnet node 都发出
请求、IC 要求返回逐位相同的响应、消耗约 13 倍 cycles——将导致重复发帖并
造成共识失败。你无需自行设置;默认值是正确的。只有在你明确需要共识时,
才将其覆盖为 。
x-clienthttp_requestdefaultConfigis_replicated = ?falseis_replicated = ?trueOptional fields: leave them null
null可选字段:保持为 null
nullx-clientserde-coreskip_null_fields/2/tweetsTweetCreateRequesttext_ = ?"…"nullnullx-clientserde-coreskip_null_fields/2/tweetsTweetCreateRequesttext_ = ?"…"nullnullSub-object rules for the non-null optionals
非 null 可选子对象的规则
If you set , , , , or to , X
enforces that sub-object's own required fields — you cannot send an empty object,
so either leave the field or populate it fully:
pollreplygeomediaedit_options?Somenull- —
poll(≥ 2) andoptions.duration_minutes - —
reply.in_reply_to_tweet_id - —
media(must be pre-uploaded).media_ids - —
geo.place_id
如果将 、、、 或 设置为 ,
X 会强制要求该子对象自身包含必填字段——不能发送空对象,因此要么将字段
保持为 ,要么完整填写:
pollreplygeomediaedit_options?Somenull- ——
poll(至少 2 个)和options。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 , POSTing to
with the stored and Client ID.
X rotates refresh tokens on every refresh — store the new
and ; reusing the old refresh token returns 400 and forces
re-authorisation. has no knowledge of refresh — it's canister-side;
see the extension for the canonical code shape.
expires_atgrant_type=refresh_tokenhttps://api.x.com/2/oauth2/tokenrefresh_tokenaccess_tokenrefresh_tokenx-clientposting-to-xAccess token 约 2 小时后过期。每次调用前,如果距离 已进入
安全缓冲时间,canister 应刷新 token:向
POST ,同时提交
存储的 和 Client ID。X 每次刷新都会轮换 refresh
token——务必存储新的 和 ;复用旧的 refresh
token 会返回 400,并被迫重新授权。 不包含 refresh 逻辑——它由
canister 端处理;规范代码结构请参阅 扩展。
expires_athttps://api.x.com/2/oauth2/tokengrant_type=refresh_tokenrefresh_tokenaccess_tokenrefresh_tokenx-clientposting-to-xRate 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 不会解析它们。