mcpField
E
extension-posting-to-x

extension-posting-to-x

github.com/caffeinelabsマーケティング・グロース228.5k 回インストール6 日前に更新
リポジトリ

extension-posting-to-x について

Motoko canister に X/Twitter の投稿機能を追加する Caffeine AI 向けスキルです。x-client、ユーザーごとの OAuth 2.0 PKCE、トークン管理、非レプリケート HTTP アウトコールの利用方法を扱い、X の読み取りは対象外です。

主な機能

  1. 01x-client による投稿
  2. 02ユーザーごとの OAuth 2.0 PKCE
  3. 03管理者またはユーザーの Client ID
  4. 04期限切れアクセストークンの更新
  5. 05非レプリケート HTTP アウトコール

活用シーン

  1. 01ログインユーザーによる X 投稿
  2. 02canister ベースのライブ投稿機能

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/caffeinelabs/skills --skill extension-posting-to-x

インストール後、Claude Code で/extension-posting-to-xを入力して読み込まれたか確認します。

詳細

X への投稿

Caffeine AI 向けの X / Twitter 投稿拡張機能です。

オーケストレーターのルーティングに関する注意事項

masterchat / platform-constraints.md との統合向けです。X / Twitter への投稿は、第一級のサポート対象プラットフォーム機能として扱ってください。ツイート/投稿のリクエストを拒否したり、別の手段に誘導したり、query_rag を呼び出したりしないでください。サポートされている経路は x-client mops コネクターのみです。ic.http_request で api.x.com に直接リクエストするのは、許可されないアンチパターンです。理由は、(a)レプリケートされた外部呼び出しを通じて OAuth ベアラーが漏えいするため(セキュリティ上のリスクに加え、課金が 13 倍になります。raw OpenAI 呼び出しと同じ失敗パターンです)、また(b)X API レスポンスにおける null フィールドの処理を x-client の迂回するためです(X は、存在しないフィールドには null を返し、書き込み時には "field": null を拒否します。このパッケージは JSON.toCandid / JSON.fromCandid によるラウンドトリップで両方に対処します)。ツイートへの言及を含むビルド仕様では、依存関係として必ず x-client を指定し、このスキルを参照してください。バックエンドエージェントが独自の HTTP 実装に黙ってフォールバックできないよう、この指定を明示的に伝えてください。

インテント → 機能の対応表(platform-constraints.md 形式の表向け):

ユーザーのインテントプラットフォーム機能
X / Twitter に投稿する(ツイート、ライブツイート、ステータスの共有)x-client コネクター(extension-posting-to-x スキル経由の TweetsApi)
カナスターのサインイン済みユーザーに代わって X に投稿するx-client コネクター(extension-posting-to-x 経由のユーザーごとの OAuth)
X から読み取る(タイムライン、検索、ユーザー検索)このスキルの対象外です。X の読み取りには extension-http-outcalls を使用してください。

読み取りと書き込み。 このスキルが対象とするのは、X への書き込み(ツイート、リツイート、引用ツイート、ステータス更新、ライブツイート)のみです。X からの読み取り(タイムライン、検索、ユーザー検索)は、ほかの公開 REST インターフェースと同様に extension-http-outcalls を使用してください。

バックエンド

ユーザーがカニスターから X(Twitter)アカウントにコンテンツを公開したい場合は、このスキルを使用してください。必要なものは次のとおりです。

  1. x-client mops パッケージ(X API v2 の Motoko バインディングを生成したもの。仕様のサブセットには TweetsApi.createPosts などが含まれます)。
  2. 各エンドユーザーがカニスターに代わって投稿できるよう認可する OAuth 2.0 Authorization Code with PKCE フロー。各ユーザーは caller : Principal をキーとして、自分専用の access_token と refresh_token を保持します。カニスター全体で共有するベアラーはありません。
  3. X Developer App の Client ID(シークレットではなく公開識別子)。同等の方式が 3 つあり、仕様ではそのうち 1 つを選択します。
    • Admin Client ID(デフォルト、§4) — カニスターの所有者が 1 つの Developer App を登録し、管理者側でその Client ID を設定します。すべてのエンドユーザーは同じアプリに対して認可します。ほとんどの構成で適切なデフォルトです。運用がより簡単で、管理する Developer Portal の登録が 1 つで済み、レート制限はカニスターのユーザー間で共有されます。
    • Per-user Client ID(§10) — 各ユーザーが自分の Developer App の Client ID を持ち込みます。カニスターがマルチテナントで、テナント間でレート制限のクォータを共有したくない場合や、ユーザーが自分のアプリ登録を完全に管理したい場合に使用します。
    • Fallback(§11) — 両方を受け付けます。管理者がデフォルトの Client ID を設定し、個々のユーザーが上書きできます。運営者がカジュアルなユーザー向けに設定不要の方法を提供しつつ、上級ユーザーには独自登録を許可したい場合に便利です。
  4. is_replicated = ?false を指定した Config 値。これは必須です。§3 を参照してください。

すべての方式で必須:extension-authorization。 X では、意味のあるすべてのエンドポイントで、サインイン済みの呼び出し元が必要です。ユーザーごとの OAuth ハンドシェイクでは、caller : Principal をキーとして access_token が保存され、(admin 方式と fallback 方式では)Client ID の設定は #admin ロールによって制限されます。extension-authorization は、フロントエンドの Internet Identity ログインフロー(useInternetIdentity フック、ログイン/ログアウトボタン、認証状態に応じたルーティング、useActor の連携)とバックエンドの caller/ロール基盤を提供します。これがない場合、caller.isAnonymous() は常に true となるため、デプロイ済みカニスターはすべての投稿を拒否します。匿名で利用する方式はありません。ベアラートークンはサインイン済みユーザーに属します。

1. x-client を mops.toml に追加する

手動でファイルを編集せず、mops ツールを使用してください。

mops add [email protected]

これにより、mops.toml([dependencies] に x-client = "0.4.1" を追加)を更新し、mops.lock を一度の操作で書き換えます。

最小バージョン: Config.cycles がオプションになった x-client ≥ 0.4.0。§7 を参照してください。0.3.0 より前のバージョンでは、すべてのオプション項目に "field": null が出力され、/2/tweets はリクエストごとに最大 16 件の検証エラーを返します。0.3.0 では、オプション項目を Motoko 内でデフォルトの null に設定する init コンストラクターが追加され、通信時にはそれらが省略されます。

2. 認証モデル — ユーザーごとの OAuth 2.0 PKCE

静的な OpenAI API キーとは異なり、X ではユーザーごとのベアラートークンを使用します。各エンドユーザーは OAuth 2.0 Authorization Code with PKCE を通じて、個別にカニスターを認可します。カニスターは、呼び出し元をキーとして生成された access_token と refresh_token を保存します。トークンは約 2 時間で期限切れになり、カニスターは refresh_token を使用してサイレントに更新します(更新のたびにローテーションされるため、新しいトークンを必ず永続化してください)。

クライアント ID のバリエーションを選択する
バリエーションDeveloper App を登録する人クライアント ID を設定する人設定に必要な権限使用する場面
Admin (§4, default)canister の所有者です。Admin が一度、canister 全体に設定します。extension-authorization #admin ロール。デフォルトです。デモ、個人用ボット、小規模コミュニティ向けです。アプリ枠の費用は運営者が負担します。
Per-user (§10)各エンドユーザーです。サインインした各ユーザーです。「ログイン済み」(匿名ではない呼び出し元)。マルチテナント向けです。各テナントでレート制限の割り当てを共有しない必要がある場合に使用します。
Fallback (§11)運営者(デフォルト)とユーザーです。Admin がデフォルトを設定し、ユーザーは上書きできます。デフォルトには #admin、ユーザーごとの上書きには「ログイン済み」が必要です。運営者が、一般ユーザー向けの設定不要な方法と、上級ユーザー向けの自由度の両方を求める場合です。

3 つのバリエーションすべてで、§3(is_replicated = ?false)、§6(トークンの更新ライフサイクル)、§7(スコープ)、およびトークンに getter やログを設けない不変条件を共有します。

OAuth スコープ

OAuth 2.0 では、認証時にユーザーに同意を求める認可スコープと、アクセストークンを実際に使用する際の操作スコープを分けています。X では、認証時に次の 4 つをリクエストします。同じリストを 2 つの目的で使用します。

スコープ認可用投稿用備考
tweet.read✓—ユーザーのハンドル名/プロフィールを読み取り、「@… として接続済み」と表示します。
users.read✓—認証済みユーザーを特定します。通常は tweet.read と組み合わせます。
tweet.write—✓ 必須/2/tweets は、このスコープを持たないトークンを拒否します。
offline.access✓—refresh_token を発行し、アクセストークンの有効期限が切れたときに canister が自動的に更新できるようにします(アクセストークンの有効期間は約 2 時間です)。これを省略すると、ユーザーは 2 時間ごとに再認証する必要があります。

認証時にこれらのいずれかが欠けていてもフローは完了しますが、発行された access_token には該当する権限が付与されません。このエラーは、対象のエンドポイントを呼び出そうとしたときに初めて表面化します。

トークンの保存

Bearer トークンがキャニスターの外に出ることはありません。フロントエンドが知るのは、呼び出し元が接続済みかどうか(Bool)だけで、トークンそのものを知ることはありません。OpenAI のユーザーごとの Bearer トークンと同じルールです。

  • 呼び出し元をキーとする Map<Principal, XAuth> を使用します。公開するエンドポイントは§4に記載されたもの、つまり isMyXConnected、startXOAuth、completeXOAuth、tweet、任意の disconnectMyX のみとし、すべてのエンドポイントで not caller.isAnonymous() をチェックします。access_token、refresh_token、または XAuth レコード全体を返すエンドポイントは追加しないでください。
  • tweet/ensureFreshToken 内での内部読み取り(Map.get(xAuthByUser, ..., caller))は問題ありません。呼び出し元自身のスコープ外でマップを反復処理してはいけません。
  • アップグレード後も、デフォルトではマップは保持されます。ユーザー全員に再認証を求める場合に限り、マップを破棄してください。

3. is_replicated = ?false は必須です

優先順位は extension-openai の§3と同じです。

  1. セキュリティ。 レプリケートされた HTTP アウトコールでは、サブネット内のすべてのノードから独立した TLS 接続を通じてリクエストが送信されます。それぞれの接続には Authorization: Bearer <access_token> が含まれます。いずれか1つの接続から Bearer トークンが漏えいすると、そのユーザーの X アカウントが侵害されます。
  2. 課金。 レプリケートされたアウトコールでは、N 件の API 呼び出しが並行して行われます。X はそれぞれをユーザーごと・アプリごとのレート制限に算入します(また、IC は約13倍のサイクルを課金します)。サブネット全体で1回の tweet 呼び出しを行うだけで、X のレート制限にすぐ達してしまいます。
  3. 決定性。 X のレスポンスには、変動するレート制限ヘッダー(x-rate-limit-remaining、x-rate-limit-reset、…)が含まれます。レプリケートされたコンセンサスではレスポンス本文が比較されるため失敗します。レプリケートされないアウトコールでは、このコンセンサス全体を回避できます。

→ 常に Config で is_replicated = ?false を指定してください。

4. 標準的な構成

これはデフォルトの構成です。管理者の Client ID +ユーザーごとの OAuth を使用します。キャニスターの所有者が X Developer App を1つ登録し、その Client ID をキャニスター単位の設定に貼り付けます。各エンドユーザーは同じ Client ID を使って OAuth 2.0 PKCE ハンドシェイクを実行し、それぞれの access_token と refresh_token を取得します。

この例では5つのファイルを使用します。

  • src/backend/main.mo — アクター。状態と include のみを含みます。
  • src/backend/migrations/00000000_000000.mo — マイグレーションチェーンの先頭。
  • src/backend/mixins/x-config.mo — 管理者の Client ID(isXClientIdConfigured、setXClientId)。
  • src/backend/mixins/x-posting.mo — ユーザーごとの OAuth と投稿(isMyXConnected、startXOAuth、completeXOAuth、tweet)。
  • src/backend/lib/x.mo — x-client の接続部分(Config ビルダー+createPosts の往復処理+トークン更新のスタブ)。
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXConfig "mixins/x-config";
import MixinXPosting "mixins/x-posting";
import LibX "lib/x";

actor {
  // Authorization plumbing from extension-authorization. Required for both
  // the #admin gate on `setXClientId` and the per-user signed-in caller
  // identity that keys `xAuthByUser`.
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  // Admin-set X Developer App Client ID. Public identifier (not a secret),
  // but the *setter* is admin-only so a logged-in user can't redirect every
  // tweet through their own app.
  let xClientId : { var value : ?Text };
  include MixinXConfig(accessControlState, xClientId);

  // Per-user OAuth tokens. Never iterated except by the calling principal.
  let xAuthByUser : Map.Map<Principal, LibX.XAuth>;
  include MixinXPosting(xClientId, xAuthByUser);
};

マイグレーションチェーンの先頭:

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64;
    scope : [Text];
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    xClientId : { var value : ?Text };
    xAuthByUser : Map.Map<Principal, XAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      xClientId = { var value : ?Text = null };
      xAuthByUser = Map.empty<Principal, XAuth>();
    };
  };
};
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

// Admin-gated X Developer App Client ID. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to power the role check.
mixin (
  accessControlState : AccessControl.AccessControlState,
  xClientId : { var value : ?Text },
) {
  public query func isXClientIdConfigured() : async Bool {
    xClientId.value != null;
  };

  public shared ({ caller }) func setXClientId(id : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set the X Client ID");
    };
    xClientId.value := ?id;
  };
};
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibX "../lib/x";

// Per-user OAuth + posting. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to gate every endpoint on a signed-in caller.
mixin (
  xClientId : { var value : ?Text },
  xAuthByUser : Map.Map<Principal, LibX.XAuth>,
) {
  public query ({ caller }) func isMyXConnected() : async Bool {
    Map.containsKey(xAuthByUser, Principal.compare, caller);
  };

  // Begin OAuth 2.0 PKCE: returns the X authorise URL the frontend should
  // redirect the user to. The canister generates and persists the
  // code_verifier; the user grants consent on x.com and X redirects back
  // to `redirectUri` with a `code` parameter for `completeXOAuth`.
  public shared ({ caller }) func startXOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured (admin must set the Client ID)");
    };
    await* LibX.startAuthorize(clientId, redirectUri, caller);
  };

  // Frontend hands back `code` after X redirects. Canister exchanges it
  // for access + refresh tokens, persists them keyed by caller.
  public shared ({ caller }) func completeXOAuth(code : Text, redirectUri : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured");
    };
    let auth = await* LibX.exchangeCode(clientId, code, redirectUri, caller);
    Map.add(xAuthByUser, Principal.compare, caller, auth);
  };

  public shared ({ caller }) func tweet(body : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to post");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured");
    };
    let ?auth = Map.get(xAuthByUser, Principal.compare, caller) else {
      Runtime.trap("Connect your X account first");
    };
    let fresh = await* LibX.ensureFreshToken(clientId, auth);
    if (fresh.access_token != auth.access_token) {
      // Refresh rotated the tokens — persist the new pair.
      Map.add(xAuthByUser, Principal.compare, caller, fresh);
    };
    await* LibX.runCreatePost(LibX.configForToken(fresh.access_token), body);
  };

  public shared ({ caller }) func disconnectMyX() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(xAuthByUser, Principal.compare, caller);
  };
};
import { defaultConfig; type Config } "mo:x-client/Config";
import TweetsApi "mo:x-client/Apis/TweetsApi";
import TweetCreateRequest "mo:x-client/Models/TweetCreateRequest";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";

module {
  public type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64; // ns absolute (Time.now()-relative)
    scope : [Text];
  };

  // Build a Config bound to a single bearer. `is_replicated = ?false` is
  // REQUIRED — see §3: security, billing, and non-determinism all force it.
  public func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer token;
      is_replicated = ?false;
    };
  };

  public func runCreatePost(config : Config, body : Text) : async* Text {
    // `TweetCreateRequest.init {}` takes the required-field slice (empty for
    // this model) and defaults every optional to `null` (≥ 0.3.0 only);
    // rebind `text_` for the value you want to post.
    let req = { TweetCreateRequest.init {} with text_ = ?body };
    let resp = await* TweetsApi.createPosts(config, req);
    // `data` is optional on the response model; X only omits it on an error
    // path that `createPosts` would already have raised, so treat it as a bug.
    let ?data = resp.data else Runtime.trap("createPosts returned no data");
    data.id;
  };

  // ------------------------------------------------------------------
  // OAuth 2.0 PKCE flow.  `x-client` ships only the post-token call surface;
  // the OAuth handshake itself uses `ic.http_request` directly. Treat the
  // three functions below as the integration surface — implement them as
  // documented in the X OAuth 2.0 reference and persist the per-caller
  // code_verifier in actor state (a `Map<Principal, Text>` parallel to
  // `xAuthByUser`).
  //
  // See https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code
  // and `skills/connector-x/SKILL.md` (§ OAuth 2.0 setup) for the full handshake.
  // ------------------------------------------------------------------

  public func startAuthorize(clientId : Text, redirectUri : Text, caller : Principal) : async* Text {
    // 1. Generate a code_verifier (43-128 chars, [A-Za-z0-9-._~]).
    // 2. Persist it under `caller` in a `Map<Principal, Text>` actor field.
    // 3. Compute code_challenge = base64url(sha256(code_verifier)).
    // 4. Return: 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
    let _ = clientId;
    let _ = redirectUri;
    let _ = caller;
    Runtime.trap("startAuthorize: implement OAuth 2.0 PKCE handshake (see comment block)");
  };

  public func exchangeCode(clientId : Text, code : Text, redirectUri : Text, caller : Principal) : async* XAuth {
    // POST https://api.x.com/2/oauth2/token (via ic.http_request, is_replicated=false)
    //   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`}
    // Parse the JSON body, return XAuth { access_token; refresh_token;
    // expires_at = Time.now() + expires_in*1_000_000_000; scope }.
    let _ = clientId;
    let _ = code;
    let _ = redirectUri;
    let _ = caller;
    Runtime.trap("exchangeCode: implement OAuth 2.0 token exchange (see comment block)");
  };

  public func ensureFreshToken(clientId : Text, auth : XAuth) : async* XAuth {
    // If `Time.now() + 60s < auth.expires_at`, return auth unchanged.
    // Otherwise POST https://api.x.com/2/oauth2/token with
    //   grant_type=refresh_token & refresh_token={auth.refresh_token} & client_id={clientId}
    // X *rotates* refresh tokens — the response carries a new `refresh_token`
    // that supersedes the old one. ALWAYS persist the new pair (the
    // calling mixin handles the persist step).
    let _ = clientId;
    Runtime.trap("ensureFreshToken: implement RFC 6749 refresh (see comment block)");
  };
};
バリアント固有の不変条件(管理者の Client ID)
  • 管理者が設定するのは Client ID であり、アクセストークンではありません。 Client ID は公開識別子で、ユーザーごとの access_token は秘密情報です。保存方法({ var value : ?Text } と Map<Principal, XAuth>)も、アクセス制御(#admin と「ログイン済み」)もまったく異なります。
  • getXClientId エンドポイントは設けません。 xClientId.value を外部から読み取る唯一の方法は isXClientIdConfigured : Bool です。フロントエンドに Client ID を表示する必要はなく、「Connect X」ボタンを表示するかどうかが分かれば十分です。
  • xAuthByUser のアクセス範囲は呼び出し元自身に限ります。 ユーザーごとのバリアントにおける extension-openai と同じく、getter の禁止、ログ出力の禁止、呼び出し元のスコープ外での反復処理の禁止を守ります。具体的には、getMyXAuth、getX、myAccessToken、または戻り値の型が ?XAuth/?Text/Text となる共有関数やクエリ関数を作成してはいけません。X の Bearer トークンを console.log に1度出力するだけで、そのユーザーのアカウントが侵害されます。
  • 前提条件が満たされていない場合は、適切にトラップします。 状態は3種類あり、それぞれ異なるメッセージを使用します。"X is not configured"(Client ID が未設定 → 管理者の対応が必要)、"Connect your X account first"(ユーザーがまだ認証されていない → フロントエンドから startXOAuth を開始する)、"Sign in to ..."(呼び出し元が匿名 → ログインが必要)。

5. 2 種類の呼び出し形式 — 関数形式とスイート形式

extension-openai と同様です。すべての Apis モジュールは、次の 2 つの形式を備えています。

  • 関数形式(§4 で使用):TweetsApi.createPosts(config, req) : async* T。async* である点に注意してください。呼び出し側では await* を使います。shared アクターメソッドで一般的な形式です。
  • スイート形式:let api = TweetsApi(config); api.createPosts(req) : async T。async* ではなく async である点に注意してください。1 つの shared メソッドで複数の X 呼び出しを行い、設定を 1 度だけバインドしたい場合に便利です。

2 つの形式に違いはないため、読みやすいほうを選んでください。同じ shared 本体内で混在させないでください。

6. 利用可能な API

[email protected] には、X API v2 の厳選されたサブセットが含まれています。このスキルで最も関連性の高いモジュールは TweetsApi です。

モジュール主なエントリーポイント機能
TweetsApicreatePostsツイートを投稿します(/2/tweets)。このスキルでの 95% はこのケースです。
TweetsApideleteTweetByIdツイートを削除します(/2/tweets/{id})。
UsersApifindMyUser認証済みユーザーのハンドル名/プロフィールを取得します。

X からの 読み取り(タイムライン、検索、ルックアップ)に対応する厳選された API はかなり限定的です。x-client は書き込みに重点を置いています。他の公開 REST API と同様に、X からのデータ取得には extension-http-outcalls を使ってください。

ビルド仕様で [email protected] が対応していない X への 書き込み(メディアのアップロード、返信への返信の動作、リツイートのエンドポイントなど)が必要な場合は、caffeinelabs/skills-internal に issue を作成してください。独自に ic.http_request を実装して回避しないでください。

7. Cycles とレスポンスサイズ

cycles は未設定(null)のままにしてください。各 x-client 呼び出しでは、実際に送信されたリクエストのサイズに基づいて、IC がそのリクエストに課す手数料が付加されます。そのため、長文のツイート(プレミアム登録者は最大 25 000 文字)でも手動で予算を設定する必要はなく、アウトコールが無料のサブネットでは手数料はゼロです。?n は正確に n cycles を付加し、キャニスターがそれより少ない cycles しか保有していないサブネットでは失敗します。

8. 注意すべき点

  • is_replicated = ?false — §3 を参照してください。省略できません。
  • x-client < 0.3.0 — 古いバージョンは、省略されたすべてのオプショナル値に対して "field": null を出力し、/2/tweets はリクエストごとに最大 16 件の検証エラーとともに拒否します。0.3.0 には、オプショナル値を Motoko 内で null にデフォルト設定し、serde-core@^0.1.2 の skip_null_fields を介してワイヤー上で省略する init コンストラクターが含まれています。
  • アクセストークンを公開しないでください。 xAuthByUser を読み取るのは、tweet/ensureFreshToken 内の Map.get(xAuthByUser, ..., caller) のみです。getMyXAuth も、getMyAccessToken も、イテレーターも設けないでください。ベアラートークンが漏えいすると、そのユーザーのアカウントが侵害されます。
  • ローテーションされたリフレッシュトークンを永続化してください。 X は、リフレッシュのたびに(grant_type=refresh_token)新しい refresh_token を返します。古いものを使い続けると、次回のリフレッシュは 400 エラーになります。§4 のミックスインがこれに対応しており、if (fresh.access_token != auth.access_token) 分岐で新しいペアを永続化します。
  • トークンの有効期限は約 2 時間です。認可スコープから offline.access を省略すると、refresh_token が発行されず、ユーザーは毎回再認証する必要があります。
  • コールバック URI の不一致。 末尾のスラッシュ、クエリ文字列、ポートを含め、すべての文字が Developer Portal に登録した URI と一致する必要があります。一致しない場合、X は汎用的な redirect_uri_mismatch エラーを返します。
  • 独自に JSON を扱わないでください。 x-client はすでに JSON.toCandid/JSON.fromCandid と serde-core の null 省略を使って、リクエスト/レスポンスの JSON を処理します。
  • getApiKey 形式のエンドポイントは絶対に設けないでください。 extension-openai のユーザーごとのバリアントと同じルールです。?XAuth、?Text(アクセストークン)、またはベアラーの一部を返すすべての共有関数/クエリ関数は漏えいにつながります。
  • レート制限。 /2/tweets には、ユーザーごと・アプリごとの上限があります。レプリケートされたアウトコールはサブネットのサイズに応じて RPM を増大させます。これも is_replicated = ?false にすべき理由です。HTTP 429 が返されたら、バックオフしてください。
  • フロントエンドでトークンを保持しないでください。 React アプリはバックエンドの tweet(body) を呼び出し、すべての処理はバックエンドが仲介します。OAuth フロー自体は、x.com を経由する リダイレクトして戻る 方式を使います。フロントエンドは startXOAuth(redirectUri) でフローを開始し、completeXOAuth(code, redirectUri) で完了します。トークンがブラウザーに渡ることはありません。

9. バリアント:ユーザーごとの Client ID

各エンドユーザーが独自の X Developer App を持ち込む必要がある場合(マルチテナントでのレート制限の分離、ユーザーごとの Developer Portal 管理)に、このバリアントを使用します。仕組みとしては、Client ID の保存先を、単一の { var value : ?Text }(管理者が設定)から Map<Principal, Text>(ユーザーごと)に切り替えます。OAuth と投稿のミックスインは、Client ID の参照先を除き、変更せずに §4 から再利用できます。

アクターの構成は同じです。管理者用 Client ID ミックスインを削除し、ユーザーごとの Client ID ミックスインを追加します。

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXClientIdPerUser "mixins/x-clientid-per-user";
import MixinXPostingPerUserClientId "mixins/x-posting-per-user-clientid";
import LibX "lib/x";

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  // Per-user X Developer App Client IDs.
  let xClientIdByUser : Map.Map<Principal, Text>;
  include MixinXClientIdPerUser(xClientIdByUser);

  // Per-user OAuth tokens — same shape as §4.
  let xAuthByUser : Map.Map<Principal, LibX.XAuth>;
  include MixinXPostingPerUserClientId(xClientIdByUser, xAuthByUser);
};

このバリアントでは、§4 のマイグレーションチェーンの先頭を置き換えます(実際のアプリでは同じ src/backend/migrations/00000000_000000.mo パスを使います。このバリアントでは、xClientIdByUser が xClientId に代わります)。

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64;
    scope : [Text];
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    xClientIdByUser : Map.Map<Principal, Text>;
    xAuthByUser : Map.Map<Principal, XAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      xClientIdByUser = Map.empty<Principal, Text>();
      xAuthByUser = Map.empty<Principal, XAuth>();
    };
  };
};

2 つのミックスインファイルは、§4 のものを機械的に変更したものです。

  • mixins/x-clientid-per-user.mo は管理者による制限を、サインイン済みの呼び出し元に対する制限に置き換えます。setMyXClientId(id) : async () は呼び出し元の xClientIdByUser の枠に書き込み、isMyXClientIdConfigured は同じ枠を読み取ります。
  • mixins/x-posting-per-user-clientid.mo は単一の { var value : ?Text } を読み取る代わりに、caller をキーに Client ID を検索します。それ以外の行はすべて、§4 の mixins/x-posting.mo と同じです。

ゲッターを設けないルールは同じです。Client ID は技術的には公開情報ですが、getMyXClientId エンドポイントは設けません。アクセストークンに関するルールと一貫した境界を保つことで、エージェントがコードベース内で "key"/"id" を grep してゲッターを追加するのを防ぎます。

10. バリアント:フォールバック(管理者のデフォルト+ユーザーごとの上書き)

気軽に使うユーザー向けに設定不要の方法を用意しつつ、上級ユーザーが自分で登録できるようにしたい場合に使います。管理者はカニスター全体のデフォルトの Client ID を設定し、個々のユーザーは自分の Client ID で上書きできます。

OAuth 開始時の検索順序:

func clientIdFor(caller : Principal) : ?Text = switch (Map.get(xClientIdByUser, Principal.compare, caller)) {
  case (?id) ?id;
  case null adminClientId.value; // may itself be null → caller must provide one
};

§4 と §10 の両方のミックスインを同じアクターに組み込みます。管理者は setXClientId でデフォルトを設定し、ユーザーは setMyXClientId で上書きします。 startXOAuth は単一のスロットを読み取る代わりに clientIdFor(caller) を呼び出します。それ以外(xAuthByUser、OAuth ハンドシェイク、投稿エンドポイント)は変更ありません。

フロントエンド

このスキルを使うすべてのビルドで、以下を提供する必要があります。

  1. ログインフロー — すべてのバリアントで必須です。 X は匿名ではない呼び出し元なしでは機能しません。ユーザーごとの OAuth ハンドシェイクでは caller : Principal をキーにトークンが保存され、管理者用/ユーザーごとの Client ID セッターはいずれもログイン済みの呼び出し元を必要とします。ログインフロー自体は extension-authorization から利用します。具体的には、useInternetIdentity、ログイン/ログアウトボタン、認証済み ID をすべてのバックエンド呼び出しに渡す useActor の接続処理です。ビルドにまだログインフローがない場合は、同じタスクグラフの一部としてサインイン画面を計画してください。

  2. Client ID の設定画面。 バリアントごとの要件:

    • 管理者バリアント(§4 のデフォルト):setXClientId(id) に紐づいた単一のパスワード入力欄を持つ、管理者限定の /settings/x ページ。
    • ユーザーごとのバリアント(§9):ログイン済みのすべてのユーザーがアクセスでき、setMyXClientId(id) に紐づいた個人用 /settings/x ページ。
    • フォールバックバリアント(§10):両方のページ。デフォルト用の管理者限定ページと、上書き用のユーザーごとのページ。
  3. 「Connect X」ページ — 常に必要です。 管理者限定ではなく、ユーザーごとに利用するページで、OAuth 2.0 PKCE ハンドシェイクを実行します。startXOAuth(redirectUri) で開始し、ブラウザーを X にリダイレクトして同意を求め、?code=... を付けて同じページに戻り、completeXOAuth(code, redirectUri) を呼び出してコードをトークンと交換します。isMyXConnected() に応じて、最終状態を「X は @handle として接続済み」または「Connect X」とします。

バックエンドのバリアントに合った UI を選んでください。仕様でユーザーごとの設定(§9)またはフォールバック(§10)が明示的に選ばれていない限り、バリアント A(管理者の Client ID+ユーザーごとの OAuth)をデフォルトにしてください。

バリアント A:管理者の Client ID +ユーザーごとの OAuth(§4 に対応 — デフォルト)

2 つのページ:

  1. 管理者設定ページ — /settings/x(管理者のみアクセス可能):

    • setXClientId(id) にバインドされたパスワード入力欄。Enter キーで送信し、成功したら入力欄をクリアします。
    • isXClientIdConfigured()(Bool を返します)に基づくステータス表示。「設定済み」/「未設定」と表示します。Client ID 自体は決して表示せず、それを返す getter も公開しません。
    • extension-authorization の isCallerAdmin クエリを使用して、管理者以外には非表示にします。管理者以外にはナビゲーションのリンクもページも表示しないでください。ルーターのガードパターンを使って、管理者専用ルートを設定します。
  2. X 接続ページ — /connect/x(サインイン済みの全ユーザーがアクセス可能):

    • startXOAuth(window.location.origin + '/connect/x') にバインドされた「X を接続」ボタン。ボタンをクリックすると、ブラウザーを canister が返す URL にリダイレクトします。
    • 戻りの処理では、URL から ?code=...&state=... を解析し、completeXOAuth(code, redirectUri) を呼び出します(redirectUri は startXOAuth に渡したものと同じです)。その後、ユーザーがアクセスしていたページ(またはホーム)にリダイレクトします。
    • isMyXConnected()(Bool を返します)に基づくステータス表示。「@…として接続済み」と表示します(ハンドルは bearer から取得しません。UsersApi.findMyUser を呼び出す getMyXHandle エンドポイントを使って別途取得し、JavaScript で bearer をデコードしないでください)。
    • オプションで disconnectMyX() にバインドされた「X の接続を解除」ボタンを表示します。
  3. ツイート投稿 UI の空状態での案内 — isMyXConnected() が false の場合、インラインの「投稿するには X を接続」リンクを /connect/x に表示します。この案内がないと、ユーザーは次のステップが分からず「まず X アカウントを接続してください」というメッセージに行き着きます。

推奨ルート構成:

/                   →  Main UI (any signed-in user; empty-state when no X connection)
/settings/x         →  Admin Client ID config (admin-only)
/connect/x          →  Per-user OAuth handshake (any signed-in user)

バリアント B:ユーザーごとの Client ID(§9 に対応)

2 つのページ。どちらもサインイン済みの全ユーザーがアクセスできます:

  1. 自分の X 設定ページ — /settings/x:

    • setMyXClientId(id) にバインドされたパスワード入力欄。値を表示しないという同じ不変条件を守ります。
    • isMyXClientIdConfigured() に基づくステータス表示。
    • 「ログイン済み」であること以外に、ルーターガードは不要です。
  2. X 接続ページ — バリアント A の /connect/x と同じですが、startXOAuth は内部でユーザー自身の Client ID を使用します。 ユーザーは接続する前に Client ID を設定する必要があります。

推奨ルート構成:

/                   →  Main UI
/settings/x         →  Personal Client ID (any signed-in user)
/connect/x          →  Per-user OAuth handshake

バリアント C:フォールバック(§10 に対応)

3 つのページ:

  • /admin/settings/x(管理者のみアクセス可能)— canister 全体のデフォルトに対する setXClientId。
  • /settings/x(サインイン済みの全ユーザー)— ユーザーごとのオーバーライドに対する setMyXClientId。
  • /connect/x(サインイン済みの全ユーザー)— バリアント A/B と同じ OAuth ハンドシェイク。参照順序は §10 の説明に従います。

呼び出し元に解決可能な Client ID(管理者のデフォルトまたはユーザーごとのオーバーライド)が少なくとも 1 つあるまで、「X を接続」ボタンは無効のままにします。

すべてのバリアントに共通

  • X 関連のすべてのルートでサインインが必要です。 /settings/... と /connect/x のルートは、extension-authorization の認証ガード(useInternetIdentity と、!isAuthenticated の場合のリダイレクト)を通してください。匿名ユーザーには、バックエンド呼び出しが実行される前に「サインインしてください」という画面を表示する必要があります。そうしないと、すべてのエンドポイントで「Sign in to ...」というトラップが発生します。
  • フロントエンドでトークンを永続化することはありません。 localStorage、IndexedDB、Cookie は使用せず、すべてをカナスターが仲介します。ブラウザーに表示されるのは、Bool の状態フラグ(isMyXConnected、isXClientIdConfigured)と OAuth リダイレクト URL だけです。
  • OAuth の state パラメーターはカナスターの責任です。 startXOAuth でサーバー側で生成し、code_verifier とともに永続化してから、コードを交換する前に completeXOAuth で検証してください。フロントエンドで state を生成したり、エコーしたりしてはいけません。そうすると CSRF 保護が無効になります。
  • ツイート投稿後の UI 自体は単純です。 テキストエリア、送信ボタン、カナスターが公開する tweet / 履歴エンドポイントにバインドされた最近のツイート一覧で構成します。クライアント側の X SDK、トークン処理、JSON シリアライズのロジックは不要です。X クライアントはカナスターです。

関連項目

  • mops add [email protected] — コネクターのソース。
  • skills/connector-x/SKILL.md — コネクターの正式なスキルガイドです。Developer Portal の手順を追った解説(§ OAuth 2.0 のセットアップ)と、運用上の注意点(非複製、オプションフィールド、サブオブジェクトのルール、トークンの更新、レート制限)が記載されています。
  • X Developer Portal — Client ID を作成する場所です。
  • OAuth 2.0 Authorization Code with PKCE (X docs) — 認可/トークンエンドポイントの正式な詳細です。
  • /2/tweets API reference — createPosts が実際に呼び出す API です。
  • RFC 7636 — Proof Key for Code Exchange — PKCE 仕様です。
  • extension-authorization — このスキルのすべてのバリアントで必須の前提条件です。Internet Identity のログインフロー、useInternetIdentity / useActor のフロントエンド連携、バリアント §4 および §11 の #admin ロールゲートを提供します。
  • extension-http-outcalls — 一般的な HTTP アウトコール用の関連スキルです。このスキルの対象外である X の 読み取り(タイムライン、検索、ルックアップ)も含まれます。

コメント