SLOPSHOPPER

x-timeline

X(Twitter)を Claude Code の中で: タイムライン・通知・検索・投稿・いいね・リポスト・ブックマークをチャットとペインで操作

newpanerowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · x-timeline
│ ┃ 𝕏 ✕ › fix the failing auth test and add an audit log call │ ┃ 𝕏 タイムライン │ ┃ ⏺ Read(src/auth.ts) │ ┃ はじめに ⎿ Read 6 lines │ ┃ 1. X Developer Console ⏺ Update(src/auth.ts) │ ┃ でネイティブアプリを作成 ⎿ Added 2 lines, removed 1 line │ ┃ 2. コールバックURIに ⏺ Bash(bun test) │ ┃ http://127.0.0.1:8976/callback を登録 ⎿ 3 pass, 1 fail │ ┃ 3. `/x-timeline client-id <Client ID>` → │ ┃ `/x-timeline login` ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /x-timeline │ ⎿ x-timeline: **1 𝕏 ホーム** · 2 🔔 通知 · 3 # タグ · 4 🔖 ブックマーク │ ⎿ x-timeline: <sub>`/x 1`〜`/x 4` タブ · `/x r` 更新 · `/x like 2` いいね │ ⎿ x-timeline: │ ⎿ x-timeline: 未ログインです。`/x-timeline login` でログインしてください │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 𝕏
𝕏 タイムライン はじめに 1. X Developer Console でネイティブアプリを作成 2. コールバックURIに http://127.0.0.1:8976/callback を登録 3. `/x-timeline client-id <Client ID>` → `/x-timeline login`
README

claude-x-timeline

X(Twitter)を Claude Code の中で。タイムライン・通知・検索・投稿・いいね・リポスト・ブックマークを、チャットとサイドペインでそのまま操作できる Claude Code の mod です。あわせて、自分の mod を作るためのテンプレートも入っています。

English below

  • x-timeline — X のクライアント mod(このリポジトリの本体)
  • hn-feed — mod の作り方を最小構成で示すテンプレート(Hacker News を表示)

インストール

Claude Code のターミナルで:

/plugin install x-timeline --marketplace dj-thank/claude-x-timeline

Add marketplace? に y、スコープは user を選びます。デスクトップアプリの Code タブでも、user スコープで入れた mod はそのまま動きます。

テンプレートを試すなら:

/plugin install hn-feed --marketplace dj-thank/claude-x-timeline

X API の準備(最初に一度だけ)

X の公式 API を自分のアプリで使います。トークンは自分の Mac / PC の安全な保管庫にだけ置かれます。

  1. X Developer Console で「アプリを作成」
  2. アプリの「設定」(ユーザー認証設定)で:
  3. アプリの種類: ネイティブアプリ(公開クライアント)
  4. コールバック URI: http://127.0.0.1:8976/callback(一字一句この通り)
  5. ウェブサイト URL: 自分の URL(例: 自分の X プロフィール)
  6. 「キーとトークン」の OAuth 2.0 クライアント ID をコピー
  7. 「クレジット」で API クレジットを購入(X API は従量課金です)
  8. Claude Code で:
/x client-id <コピーしたクライアントID>
/x login

ブラウザで「許可する」を押せば完了です。

使い方

/x を打つと、チャットにそのまま操作できるタイムラインが出ます(いちばん新しい出力がライブで動き、タブ・♡・🔁・💬・🔖 などが押せます)。ターミナルではサイドペインにも出ます。

コマンドできること
/x次のタブへ(ホーム → 通知 → タグ → ブックマーク)
/x 1 /x 2 /x 3 /x 4ホーム / 通知(メンション)/ 登録ハッシュタグ / ブックマーク
/x r更新
/x a 55 分ごとに自動更新し、新着をトーストで通知(/x a off で停止)
/x p 本文 → /x sendポスト(確認してから送信)。--image /path/to.png で画像(4 枚まで)
/x reply 3 本文 / /x quote 3 本文3 番のポストに返信 / 引用
/x like 3 / /x rt 3 / /x bookmark 3いいね / リポスト(確認あり)/ ブックマーク
/x delete 3自分のポストを削除(確認あり)
/x thread 3スレッド(直近 7 日の返信)
/x user @name / /x user 3プロフィールと最新ポスト(フォロー・ミュート・いいね一覧のボタン付き)
/x search 言葉検索(直近 7 日。X の検索演算子も使えます)
/x trends → /x trend 1あなた向けのトレンド → そのトレンドを検索
/x lists → /x list 1リスト一覧 → リストのタイムライン
/x likes [@name]いいね一覧
/x tag add #猫 / /x tagハッシュタグを登録 / 登録タグの最新
/x follow @name / unfollow / mute / unmuteフォロー・ミュート
/x qr自分のプロフィールの QR コード(/x qr open で画像)
/x backスレッドやプロフィールから戻る
/x help全コマンド

番号は、チャットに表示中のポストの番号です。日本語でも打てます(/x いいね 3、/x 検索 Claude など)。

ペインのキー操作(X 本家と同じ): j/k で選択、l いいね、t リポスト、r 返信、w 引用、b ブックマーク、o 開く、p プロフィール、s スレッド、u 更新、1〜4 タブ、h 戻る。

Claude に聞く: 「今のタイムラインを要約して」などと頼むと、Claude が読み取り専用のツール read_timeline で表示中のポストを読みます(X への追加リクエストはしません)。

チャットの 💬 🔁 ♡ の数字は X 公式の Web Intent へのリンクで、押すとブラウザで X の返信・リポスト・いいね画面が開きます(API 料金はかかりません)。

セキュリティ

  • 認証は X 公式の OAuth 2.0 Authorization Code + PKCE。クライアントシークレットは使いません。
  • アクセストークンとリフレッシュトークンは macOS のキーチェーン(Linux は secret-tool)にだけ保存します。設定ファイル・ログ・画面には出しません。保存先がない環境ではログインを拒否します。
  • キーチェーンへはコマンドライン引数ではなく標準入力で渡します。画像アップロードの curl も Authorization ヘッダーを標準入力から読みます。
  • ログイン時のコールバックは 127.0.0.1 だけで待ち受け、state を照合します。
  • 投稿・いいね・リポスト・削除・フォローは、本人の操作(入力・クリック)からだけ実行します。予約タスクや他のセッションからの指示では実行しません。リポストと削除は確認が必要です。
  • DM の権限は求めません。
  • /x logout で X 側のトークンを失効させ、保管庫からも削除します。

料金の目安

X API は従量課金です(取得したポスト 1 件ごと、投稿 1 件ごとなど)。正確な単価は Developer Console で確認してください。

  • 更新は新しいポストだけを取りに行きます。
  • 自動更新は初期設定で OFF です。
  • リポストや引用の元ポスト、画像の情報も 1 回の取得にまとめています。

動作環境

  • macOS: すべての機能
  • Linux: secret-tool(libsecret)と xdg-open、python3 があれば動きます
  • Windows: 未対応(トークンを安全に保存する方法を用意していないため)
  • 画像をペインに直接描くのは kitty / Ghostty。ほかの端末とデスクトップアプリでは画像へのリンクになります。チャットでは画像として表示されます。

この mod を土台に自分の mod を作る

templates/hn-feed が最小のテンプレートです。x-timeline と同じ作りを 1 ファイルにまとめています。

やりたいこと使うものx-timeline での例
スラッシュコマンドsession.start で $.command.register、command.run で { text } を返す/x
チャットの出力を操作できる UI にするui.render の CommandOutput に自分のツリーを返すライブのタイムライン
サイドペイン$.ui.open と ui.render の Paneペイン表示
画面が読む状態atom / read / update($.state)。書くと再描画されるstatus・posts
Claude が呼べるツール$.tool.register と tool.callread_timeline
外部 API$.http.fetchX API v2
秘密情報$.process.run で OS の保管庫へ(標準入力で渡す)キーチェーン
テストclaude plugin test(claude-code/testing)tests/

作業の流れ:

claude plugin validate templates/hn-feed
claude plugin test templates/hn-feed
claude --plugin-dir templates/hn-feed

型定義(.claude-plugin/types/)は Claude Code が mod を読み込んだときに自動で生成します。生成後は tsc -p <mod のフォルダ> で型チェックできます。

開発

claude plugin test plugins/x-timeline
claude plugin validate .

ライセンス

MIT


English

claude-x-timeline brings X (Twitter) into Claude Code as a mod: read your home timeline, mentions, hashtags, bookmarks, lists, trends, threads and profiles, and post, reply, quote (with images), like, repost, bookmark, follow and mute — from a live, clickable timeline in the chat and a side pane. It also ships hn-feed, a one-file template for building your own Claude Code mod.

Install

/plugin install x-timeline --marketplace dj-thank/claude-x-timeline

Set up the X API (once)

  1. Create an app at the X Developer Console.
  2. In its user authentication settings choose Native App (public client) and set the callback URI to exactly http://127.0.0.1:8976/callback.
  3. Copy the OAuth 2.0 Client ID, buy API credits (X bills per use), then run /x client-id <ID> and /x login.

Use

/x shows a live timeline in the chat; /x 1–/x 4 switch tabs; /x help lists every command (search, trends, lists, likes, threads, profiles, posting with --image, quoting, deleting, following, QR code). In a terminal pane, X's own keys work: j/k, l, t, r, w, b, o, p, s. Ask Claude to summarize your timeline: it reads what is loaded through the read-only read_timeline tool, with no extra X request.

Security

OAuth 2.0 with PKCE (no client secret); tokens live only in the macOS Keychain (or libsecret on Linux), passed over stdin, never in files, logs or argv; the callback listens on 127.0.0.1 and checks state; writes run only from the person's own input; no DM scopes; /x logout revokes and deletes the tokens.

Build your own mod

Start from templates/hn-feed: a slash command, a live clickable chat row, a pane, state, a tool Claude can call, and a test, in one file. claude plugin validate, claude plugin test and claude --plugin-dir are the loop.

License

MIT

Source 4 files
hooks/register.tsx 2104 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { Draft, Mode, PerMode, Post, Status, View } from '../types'
5import { encodeQr, qrRows, qrSvg } from './qr'
6import {
7  API,
8  CALLBACK_PORT,
9  CALLBACK_SERVER,
10  POST_LIMIT,
11  REDIRECT_URI,
12  EXPANSIONS,
13  MEDIA_FIELDS,
14  intentUrl,
15  isMediaUrl,
16  mediaVariant,
17  TWEET_FIELDS,
18  USER_FIELDS,
19  authorizeUrl,
20  canLike,
21  canPost,
22  clampResults,
23  compactCount,
24  createPkce,
25  decodeSecret,
26  describeApiError,
27  encodeSecret,
28  formEncode,
29  IMAGE_LIMIT,
30  IMAGE_TYPES,
31  hasScope,
32  imagePathProblem,
33  isClientId,
34  parseLists,
35  parsePostArgs,
36  parseTrends,
37  mergePosts,
38  normalizeTag,
39  parseClientIdArgs,
40  parseQuery,
41  parseProfile,
42  parseSingle,
43  parseTimeline,
44  profileText,
45  joinedText,
46  PROFILE_FIELDS,
47  clip,
48  postBadges,
49  postText,
50  postUrl,
51  refreshIntervalMs,
52  relativeTime,
53  segments,
54  tagQuery,
55  targetOf,
56  tokensFrom,
57  weightedLength,
58} from './x-api'
59import type { HttpLike, Tokens } from './x-api'
60
61const PANE = 'x-timeline'
62const TITLE = '𝕏'
63const KEPT = 300
64const SHOWN_AS_TEXT = 15
65// Tools of the host, found at session start: macOS ships `security`,
66// `open` and a python3; Linux has `secret-tool` (libsecret) and `xdg-open`.
67type Platform = { secrets: 'keychain' | 'libsecret' | null; opener: string; python: string; cacheRoot: string | null }
68let platform: Platform = { secrets: 'keychain', opener: '/usr/bin/open', python: '/usr/bin/python3', cacheRoot: null }
69const KEYCHAIN_SERVICE = 'claude-x-timeline'
70const KEYCHAIN_ACCOUNT = 'oauth-token'
71const AUTO_STEPS = [0, 5, 15, 30]
72// X's own colors: the blue of links and the active tab, the pink of a
73// like, the green of a repost.
74const ACCENT = '#1d9bf0'
75const PINK = '#f91880'
76const GREEN = '#00ba7c'
77const AVATAR_COLORS = ['#1d9bf0', '#f91880', '#00ba7c', '#7856ff', '#ff7a00', '#ffd400', '#e0245e', '#17bf63']
78// Where a command that posts may come from: a person's own message, never
79// automation (scheduled triggers, peers, channels, notifications).
80const PERSON_ORIGINS = ['composer', 'bridge', 'sdk']
81
82const INITIAL: Status = {
83  auth: 'unknown',
84  mode: 'home',
85  isLoading: false,
86  error: null,
87  notice: null,
88  fetchedAt: { home: null, mentions: null, tags: null, bookmarks: null, view: null },
89  account: null,
90  authorizeUrl: null,
91  autoMinutes: 0,
92  canPost: false,
93  canLike: false,
94  newIds: [],
95  unread: { home: 0, mentions: 0, tags: 0, bookmarks: 0, view: 0 },
96  liked: [],
97  reposted: [],
98  bookmarked: [],
99  followed: [],
100  muted: [],
101  deleteAsk: null,
102  repostAsk: null,
103  shown: { home: [], mentions: [], tags: [], bookmarks: [], view: [] },
104  view: null,
105  backTo: 'home',
106  cursor: 0,
107  imagesReady: [],
108  liveTail: null,
109  chatLimit: 10,
110  hasMore: { home: true, mentions: true, tags: true, bookmarks: false, view: false },
111  tags: [],
112  activeTag: null,
113  qrFor: null,
114}
115const EMPTY_DRAFT: Draft = { text: '', replyTo: null, quoteOf: null, images: [], isConfirming: false, isPosting: false }
116
117const posts = atom({ plugin: 'x-timeline', key: 'posts' } as const, [])
118const mentions = atom({ plugin: 'x-timeline', key: 'mentions' } as const, [])
119const tagPosts = atom({ plugin: 'x-timeline', key: 'tagPosts' } as const, [])
120const viewPosts = atom({ plugin: 'x-timeline', key: 'viewPosts' } as const, [])
121const bookmarkPosts = atom({ plugin: 'x-timeline', key: 'bookmarkPosts' } as const, [])
122const status = atom({ plugin: 'x-timeline', key: 'status' } as const, INITIAL)
123const draft = atom({ plugin: 'x-timeline', key: 'draft' } as const, EMPTY_DRAFT)
124
125type Engine = EngineInterface
126
127// The tokens live in the macOS Keychain and in this module's memory only:
128// never in $.state, $.store, logs or the pane.
129let tokens: Tokens | null = null
130let refreshing: Promise<Tokens | null> | null = null
131let isFetching = false
132let isLoggingIn = false
133let pendingLogin: { verifier: string; state: string; clientId: string } | null = null
134let maxResults = 20
135let defaultAutoMinutes = 0
136let autoTimer: { cancel: () => void } | null = null
137// Where post pictures are kept as PNG files a terminal reads itself.
138let mediaDir: string | null = null
139const mediaInFlight = new Set<string>()
140
141// Values kept from an older version of this mod read as the current shape.
142function perMode<T>(base: PerMode<T>, kept: Partial<PerMode<T>> | undefined): PerMode<T> {
143  return { ...base, ...kept }
144}
145
146async function readStatus($: Engine): Promise<Status> {
147  const kept = await read($, status)
148
149  return {
150    ...INITIAL,
151    ...kept,
152    fetchedAt: perMode(INITIAL.fetchedAt, kept.fetchedAt),
153    unread: perMode(INITIAL.unread, kept.unread),
154    hasMore: perMode(INITIAL.hasMore, kept.hasMore),
155    shown: perMode(INITIAL.shown, kept.shown),
156  }
157}
158
159async function readDraft($: Engine): Promise<Draft> {
160  return { ...EMPTY_DRAFT, ...(await read($, draft)) }
161}
162
163function setStatus($: Engine, patch: Partial<Status>) {
164  return update($, status, s => ({ ...INITIAL, ...s, ...patch }))
165}
166
167function readList($: Engine, mode: Mode): Promise<Post[]> {
168  if (mode === 'home') return read($, posts)
169  if (mode === 'view') return read($, viewPosts)
170  if (mode === 'bookmarks') return read($, bookmarkPosts)
171
172  return mode === 'mentions' ? read($, mentions) : read($, tagPosts)
173}
174
175function updateList($: Engine, mode: Mode, fn: (list: Post[]) => Post[]) {
176  if (mode === 'home') return update($, posts, fn)
177  if (mode === 'view') return update($, viewPosts, fn)
178  if (mode === 'bookmarks') return update($, bookmarkPosts, fn)
179
180  return mode === 'mentions' ? update($, mentions, fn) : update($, tagPosts, fn)
181}
182
183function errorText(error: unknown): string {
184  return error instanceof Error ? error.message : String(error)
185}
186
187async function detectPlatform($: Engine): Promise<void> {
188  const has = (path: string) => $.fs.exists(path).catch(() => false)
189  const home = (await $.env.get('HOME')) ?? null
190  if (await has('/usr/bin/security')) {
191    platform = { secrets: 'keychain', opener: '/usr/bin/open', python: '/usr/bin/python3', cacheRoot: home ? `${home}/Library/Caches` : null }
192
193    return
194  }
195  const secretTool = (await has('/usr/bin/secret-tool')) ? 'libsecret' : null
196  platform = { secrets: secretTool, opener: 'xdg-open', python: 'python3', cacheRoot: home ? `${home}/.cache` : null }
197}
198
199async function openUrl($: Engine, url: string): Promise<void> {
200  await $.process.run([platform.opener, url]).catch(() => undefined)
201}
202
203async function loadTokens($: Engine): Promise<Tokens | null> {
204  const found =
205    platform.secrets === 'keychain'
206      ? await $.process.run(['/usr/bin/security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', KEYCHAIN_ACCOUNT, '-w'])
207      : platform.secrets === 'libsecret'
208        ? await $.process.run(['/usr/bin/secret-tool', 'lookup', 'service', KEYCHAIN_SERVICE, 'account', KEYCHAIN_ACCOUNT])
209        : null
210
211  return found?.exitCode === 0 ? decodeSecret(found.stdout) : null
212}
213
214async function saveTokens($: Engine, next: Tokens): Promise<void> {
215  // The secret goes over stdin to `security -i`, never on a command line.
216  // base64url keeps it free of quotes and spaces.
217  const secret = encodeSecret(next)
218  // No secret store, no saving: a token is never written to a plain file.
219  if (!platform.secrets) throw new Error('トークンを安全に保存できる場所がありません(macOS のキーチェーンか Linux の secret-tool が必要です)')
220  if (platform.secrets === 'keychain') {
221    await $.process.run(['/usr/bin/security', '-i'], {
222      stdin: `add-generic-password -U -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -l "Claude X timeline" -w "${secret}"\n`,
223    })
224  } else {
225    await $.process.run(['/usr/bin/secret-tool', 'store', '--label=Claude X timeline', 'service', KEYCHAIN_SERVICE, 'account', KEYCHAIN_ACCOUNT], { stdin: secret })
226  }
227  const stored = await loadTokens($)
228  if (stored?.accessToken !== next.accessToken) throw new Error('キーチェーンにトークンを保存できませんでした')
229  tokens = next
230}
231
232async function forgetTokens($: Engine): Promise<void> {
233  tokens = null
234  if (platform.secrets === 'keychain') await $.process.run(['/usr/bin/security', 'delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', KEYCHAIN_ACCOUNT])
235  if (platform.secrets === 'libsecret') await $.process.run(['/usr/bin/secret-tool', 'clear', 'service', KEYCHAIN_SERVICE, 'account', KEYCHAIN_ACCOUNT])
236  await $.store.set('account', null)
237}
238
239async function clientId($: Engine): Promise<string | null> {
240  const value = await $.store.get('clientId')
241  if (typeof value === 'string' && isClientId(value)) return value
242  // Another session may have logged in: its Keychain record carries the id.
243  tokens ??= await loadTokens($)
244
245  return tokens?.clientId ?? null
246}
247
248async function tokenRequest($: Engine, id: string, params: Record<string, string>): Promise<Tokens | HttpLike> {
249  const res = await $.http.fetch(`${API}/2/oauth2/token`, {
250    method: 'POST',
251    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
252    body: formEncode({ ...params, client_id: id }),
253  })
254  if (!res.ok) return res
255
256  return tokensFrom(JSON.parse(res.text), await $.clock.now(), id)
257}
258
259function refreshTokens($: Engine): Promise<Tokens | null> {
260  refreshing ??= (async () => {
261    // Several sessions share the Keychain record and X rotates the refresh
262    // token: take a newer one another session stored before spending ours.
263    const stored = await loadTokens($)
264    const now = await $.clock.now()
265    if (stored && stored.accessToken !== tokens?.accessToken && now < stored.expiresAt - 60_000) {
266      tokens = stored
267
268      return stored
269    }
270    const current = stored ?? tokens
271    const id = (await clientId($)) ?? current?.clientId
272    if (!id || !current?.refreshToken) return null
273    const result = await tokenRequest($, id, { grant_type: 'refresh_token', refresh_token: current.refreshToken })
274    if ('status' in result) {
275      if (result.status === 400 || result.status === 401) {
276        const latest = await loadTokens($)
277        if (latest && latest.refreshToken !== current.refreshToken) {
278          tokens = latest
279
280          return latest
281        }
282        await forgetTokens($)
283        await setStatus($, { auth: 'signed-out', canPost: false, canLike: false, error: 'ログインの有効期限が切れました。もう一度ログインしてください' })
284
285        return null
286      }
287      throw new Error(describeApiError(result))
288    }
289    await saveTokens($, { ...result, scope: result.scope || current.scope })
290
291    return tokens
292  })().finally(() => {
293    refreshing = null
294  })
295
296  return refreshing
297}
298
299async function accessToken($: Engine): Promise<string | null> {
300  tokens ??= await loadTokens($)
301  if (!tokens) return null
302  if ((await $.clock.now()) < tokens.expiresAt - 60_000) return tokens.accessToken
303
304  return (await refreshTokens($))?.accessToken ?? null
305}
306
307async function apiCall(
308  $: Engine,
309  method: 'GET' | 'POST' | 'DELETE',
310  path: string,
311  query: Record<string, string>,
312  body?: unknown,
313): Promise<HttpLike | null> {
314  const qs = formEncode(query)
315  const url = `${API}${path}${qs ? `?${qs}` : ''}`
316  const send = (token: string) =>
317    $.http.fetch(url, {
318      method,
319      headers: body === undefined ? { Authorization: `Bearer ${token}` } : { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
320      body: body === undefined ? undefined : JSON.stringify(body),
321    })
322  const token = await accessToken($)
323  if (!token) return null
324  const res = await send(token)
325  if (res.status !== 401) return res
326  const renewed = await refreshTokens($)
327
328  return renewed ? send(renewed.accessToken) : null
329}
330
331async function account($: Engine): Promise<{ id: string; username: string } | null> {
332  const kept = (await $.store.get('account')) as { id?: unknown; username?: unknown } | null
333  if (typeof kept?.id === 'string' && typeof kept.username === 'string') return { id: kept.id, username: kept.username }
334  const res = await apiCall($, 'GET', '/2/users/me', {})
335  if (!res) return null
336  if (res.status !== 200) throw new Error(describeApiError(res))
337  const me = JSON.parse(res.text)?.data
338  if (typeof me?.id !== 'string') throw new Error('アカウント情報を取得できませんでした')
339  const found = { id: me.id as string, username: String(me.username) }
340  await $.store.set('account', found)
341
342  return found
343}
344
345// Fetches one mode's newer posts (or, with `older`, the page past the
346// oldest shown); answers the posts that are new to a list already shown.
347async function refreshTimeline($: Engine, mode?: Mode, older = false): Promise<Post[]> {
348  if ((mode ?? (await readStatus($)).mode) === 'view') {
349    if (!older) await reloadView($)
350
351    return []
352  }
353  if (isFetching) return []
354  isFetching = true
355  const before = await readStatus($)
356  const which = mode ?? before.mode
357  await setStatus($, { isLoading: true, error: null })
358  try {
359    const me = await account($)
360    if (!me) {
361      await setStatus($, { auth: 'signed-out' })
362
363      return []
364    }
365    const current = await readList($, which)
366    const watched = before.activeTag ? [before.activeTag] : before.tags
367    if (which === 'tags' && watched.length === 0) throw new Error('ハッシュタグが登録されていません。`/x tag add #タグ` で登録してください')
368    const query: Record<string, string> = {
369      // Recent search takes 10 at the least.
370      max_results: String(which === 'tags' ? Math.max(10, maxResults) : maxResults),
371      'tweet.fields': TWEET_FIELDS,
372      expansions: EXPANSIONS,
373      'user.fields': USER_FIELDS,
374      'media.fields': MEDIA_FIELDS,
375    }
376    // Bookmarks come in the order they were saved and take no since_id: the
377    // first page is read again whole.
378    const edge = which === 'bookmarks' ? undefined : older ? current.at(-1) : current[0]
379    if (edge) query[older ? 'until_id' : 'since_id'] = edge.id
380    if (which === 'tags') query.query = tagQuery(watched)
381    const path =
382      which === 'home'
383        ? `/2/users/${me.id}/timelines/reverse_chronological`
384        : which === 'mentions'
385          ? `/2/users/${me.id}/mentions`
386          : which === 'bookmarks'
387            ? `/2/users/${me.id}/bookmarks`
388            : '/2/tweets/search/recent'
389    const res = await apiCall($, 'GET', path, query)
390    if (!res) {
391      await setStatus($, { auth: 'signed-out' })
392
393      return []
394    }
395    if (res.status !== 200) throw new Error(describeApiError(res))
396    const fresh = parseTimeline(JSON.parse(res.text))
397    await updateList($, which, list => (which === 'bookmarks' ? fresh : older ? mergePosts(list, fresh, KEPT) : mergePosts(fresh, list, KEPT)))
398    const s = await readStatus($)
399    const isNews = !older && which !== 'bookmarks' && current.length > 0 && fresh.length > 0
400    const isShown = s.mode === which
401    await setStatus($, {
402      auth: 'signed-in',
403      account: me.username,
404      canPost: canPost(tokens),
405      canLike: canLike(tokens),
406      fetchedAt: { ...s.fetchedAt, [which]: await $.clock.now() },
407      newIds: isShown && !older ? fresh.map(post => post.id) : s.newIds,
408      unread: { ...s.unread, [which]: isNews && !isShown ? s.unread[which] + fresh.length : isShown ? 0 : s.unread[which] },
409      hasMore: older ? { ...s.hasMore, [which]: fresh.length > 0 } : s.hasMore,
410    })
411
412    return isNews ? fresh : []
413  } catch (error) {
414    await setStatus($, { error: errorText(error) })
415
416    return []
417  } finally {
418    isFetching = false
419    await setStatus($, { isLoading: false })
420  }
421}
422
423function preview(post: Post): string {
424  return `@${post.username}: ${post.text.replace(/\s+/g, ' ').slice(0, 60)}`
425}
426
427async function autoTick($: Engine): Promise<void> {
428  const freshHome = await refreshTimeline($, 'home')
429  const freshMentions = await refreshTimeline($, 'mentions')
430  const s = await readStatus($)
431  const freshTags = s.mode === 'tags' && s.tags.length > 0 ? await refreshTimeline($, 'tags') : []
432  const mention = freshMentions[0]
433  if (mention) {
434    $.ui.toast(`🔔 X 通知 ${freshMentions.length}件 · ${preview(mention)}`)
435  } else if (freshTags[0]) {
436    $.ui.toast(`# タグ新着 ${freshTags.length}件 · ${preview(freshTags[0])}`)
437  } else if (freshHome[0]) {
438    $.ui.toast(`𝕏 新着 ${freshHome.length}件 · ${preview(freshHome[0])}`)
439  }
440}
441
442async function setAuto($: Engine, minutes: number): Promise<void> {
443  autoTimer?.cancel()
444  autoTimer = null
445  await $.store.set('autoMinutes', minutes)
446  await setStatus($, { autoMinutes: minutes })
447  const ms = refreshIntervalMs(minutes)
448  if (ms > 0 && tokens) autoTimer = $.clock.every(ms, () => void autoTick($))
449}
450
451async function switchMode($: Engine, mode: Mode): Promise<void> {
452  const s = await readStatus($)
453  await setStatus($, { mode, cursor: 0, error: null, notice: null, newIds: [], unread: { ...s.unread, [mode]: 0 } })
454  tokens ??= await loadTokens($)
455  if ((await readList($, mode)).length === 0 && tokens) await refreshTimeline($, mode)
456}
457
458async function loadTags($: Engine): Promise<string[]> {
459  const kept = await $.store.get('tags')
460
461  return Array.isArray(kept) ? kept.filter((tag): tag is string => typeof tag === 'string') : []
462}
463
464async function saveTags($: Engine, tags: string[]): Promise<void> {
465  await $.store.set('tags', tags)
466  await setStatus($, { tags })
467}
468
469async function showTag($: Engine, tag: string | null): Promise<void> {
470  const s = await readStatus($)
471  if (s.activeTag !== tag || s.mode !== 'tags') await update($, tagPosts, () => [])
472  await setStatus($, { activeTag: tag, hasMore: { ...s.hasMore, tags: true } })
473  await switchMode($, 'tags')
474}
475
476async function tagCommand($: Engine, rest: string): Promise<string> {
477  const [sub = '', ...words] = rest.split(/\s+/).filter(Boolean)
478  const tags = await loadTags($)
479  const listed = () => (tags.length ? tags.map(tag => `#${tag}`).join(' ') : '(なし)')
480
481  if (sub === 'add' || sub === '追加') {
482    const added = words.map(normalizeTag).filter((tag): tag is string => tag !== null)
483    if (added.length === 0) return '使い方: `/x tag add #タグ`(複数可)'
484    const next = [...tags, ...added.filter(tag => !tags.includes(tag))].slice(0, 20)
485    await saveTags($, next)
486    await showTag($, null)
487
488    return `登録しました: ${next.map(tag => `#${tag}`).join(' ')}`
489  }
490  if (sub === 'rm' || sub === 'remove' || sub === '削除') {
491    const gone = new Set(words.map(normalizeTag))
492    const next = tags.filter(tag => !gone.has(tag))
493    await saveTags($, next)
494    const s = await readStatus($)
495    if (s.activeTag && gone.has(s.activeTag)) await showTag($, null)
496
497    return `登録中: ${next.length ? next.map(tag => `#${tag}`).join(' ') : '(なし)'}`
498  }
499  if (sub === 'list' || sub === '一覧') return `登録中のハッシュタグ: ${listed()}`
500
501  const single = sub && sub !== 'all' && sub !== 'すべて' ? normalizeTag(sub) : null
502  if (sub && sub !== 'all' && sub !== 'すべて' && !single) return '使い方: `/x tag` · `/x tag #タグ` · `/x tag add #タグ` · `/x tag rm #タグ` · `/x tag list`'
503  if (!single && tags.length === 0) {
504    await setStatus($, { mode: 'tags', activeTag: null, error: null })
505    await openPane($)
506
507    return `${tabBar(await readStatus($))}\n\nハッシュタグが登録されていません。\`/x tag add #タグ\` で登録してください`
508  }
509  await setStatus($, { tags })
510  await showTag($, single)
511  await openPane($)
512
513  return live($, `${tabBar(await readStatus($))}\n\n${await asText($, 'tags')}`)
514}
515
516async function qrCommand($: Engine, rest: string): Promise<string> {
517  const [first = '', second = ''] = rest.split(/\s+/)
518  const wantsFile = first === 'open' || second === 'open'
519  const named = [first, second].find(word => word && word !== 'open')
520  let username = named?.replace(/^@/, '') ?? null
521  if (username !== null && !/^[A-Za-z0-9_]{1,15}$/.test(username)) return 'ユーザー名の形式が正しくありません(例: `/x qr @example_user`)'
522  if (!username) {
523    try {
524      username = (await account($))?.username ?? null
525    } catch (error) {
526      return errorText(error)
527    }
528  }
529  if (!username) return '未ログインです。`/x login` でログインするか、`/x qr @ユーザー名` を指定してください'
530  const url = `https://x.com/${username}`
531  const matrix = encodeQr(url)
532
533  if (wantsFile) {
534    const home = await $.env.get('HOME')
535    if (!home) return 'ホームフォルダが分かりませんでした'
536    const dir = `${platform.cacheRoot ?? (platform.secrets === 'keychain' ? `${home}/Library/Caches` : `${home}/.cache`)}/claude-x-timeline`
537    const file = `${dir}/qr-${username}.svg`
538    await $.process.run(['/bin/mkdir', '-p', dir])
539    await $.fs.write(file, qrSvg(matrix))
540    await openUrl($, file)
541
542    return `QRコードの画像を開きました(${url})`
543  }
544
545  await setStatus($, { qrFor: username })
546  await openPane($)
547
548  // A surface that draws the row itself shows the code as a picture; this
549  // text is what the transcript keeps and what a plain one shows.
550  return [`@${username} のQRコード(${url})`, '```', ...qrRows(matrix, false), '```', '画像で開く: `/x qr open`'].join('\n')
551}
552
553async function login($: Engine): Promise<string> {
554  const id = await clientId($)
555  if (!id) return '先に `/x-timeline client-id <OAuth 2.0 Client ID>` を実行してください'
556  // A retry while the listener still waits replaces the PKCE pair and state:
557  // only the newest authorization request can complete.
558  const pkce = await createPkce()
559  pendingLogin = { ...pkce, clientId: id }
560  const url = authorizeUrl(id, pkce.challenge, pkce.state)
561  await setStatus($, { auth: 'waiting', error: null, authorizeUrl: url })
562  if (isLoggingIn) {
563    await openUrl($, url)
564
565    return 'ブラウザでXの認可画面を開き直しました'
566  }
567  isLoggingIn = true
568
569  void (async () => {
570    try {
571      const listener = $.process.run([platform.python, '-c', CALLBACK_SERVER, String(CALLBACK_PORT)], { timeoutMs: 320_000 })
572      await openUrl($, url)
573      const done = await listener
574      const request = pendingLogin
575      if (!request) throw new Error('ログインが取り消されました')
576      if (done.exitCode !== 0) {
577        throw new Error(
578          done.exitCode === 2
579            ? '5分以内に認可されませんでした。もう一度ログインしてください'
580            : `認可の受け取りに失敗しました(ポート${CALLBACK_PORT}が使用中かもしれません)`,
581        )
582      }
583      const answer = parseQuery(done.stdout.trim())
584      if (answer.state !== request.state) throw new Error('認可応答のstateが一致しません。もう一度ログインしてください')
585      if (!answer.code) throw new Error(answer.error === 'access_denied' ? '認可がキャンセルされました' : `認可に失敗しました(${answer.error ?? '不明'})`)
586      const result = await tokenRequest($, request.clientId, {
587        grant_type: 'authorization_code',
588        code: answer.code,
589        redirect_uri: REDIRECT_URI,
590        code_verifier: request.verifier,
591      })
592      if ('status' in result) throw new Error(describeApiError(result))
593      await $.store.set('account', null)
594      await saveTokens($, result)
595      await update($, posts, () => [])
596      await update($, mentions, () => [])
597      await setStatus($, { auth: 'signed-in', authorizeUrl: null, canPost: canPost(result), canLike: canLike(result), newIds: [] })
598      $.ui.toast('𝕏 と連携しました')
599      await refreshTimeline($)
600      await setAuto($, (await readStatus($)).autoMinutes)
601    } catch (error) {
602      await setStatus($, { auth: tokens ? 'signed-in' : 'signed-out', authorizeUrl: null, error: errorText(error) })
603    } finally {
604      isLoggingIn = false
605      pendingLogin = null
606    }
607  })()
608
609  return 'ブラウザでXの認可画面を開きました。許可するとタイムラインを取得します'
610}
611
612async function logout($: Engine): Promise<string> {
613  const id = await clientId($)
614  const current = tokens ?? (await loadTokens($))
615  if (id && current) {
616    for (const [token, hint] of [
617      [current.refreshToken, 'refresh_token'],
618      [current.accessToken, 'access_token'],
619    ] as const) {
620      if (!token) continue
621      await $.http
622        .fetch(`${API}/2/oauth2/revoke`, {
623          method: 'POST',
624          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
625          body: formEncode({ token, token_type_hint: hint, client_id: id }),
626        })
627        .catch(() => undefined)
628    }
629  }
630  autoTimer?.cancel()
631  autoTimer = null
632  await forgetTokens($)
633  await update($, posts, () => [])
634  await update($, mentions, () => [])
635  await update($, draft, () => EMPTY_DRAFT)
636  await setStatus($, { ...INITIAL, auth: 'signed-out', autoMinutes: (await readStatus($)).autoMinutes })
637
638  return 'Xとの連携を解除し、キーチェーンからトークンを削除しました'
639}
640
641// `/x p 本文 --image /path.png`: each picture checked before the confirm.
642async function checkImages($: Engine, paths: string[]): Promise<string[]> {
643  const home = (await $.env.get('HOME')) ?? ''
644  const out: string[] = []
645  for (const raw of paths.slice(0, 4)) {
646    const path = raw.startsWith('~/') ? `${home}${raw.slice(1)}` : raw
647    const problem = imagePathProblem(path)
648    if (problem) throw new Error(`${raw}: ${problem}`)
649    const stat = await $.fs.stat(path).catch(() => null)
650    if (stat?.kind !== 'file') throw new Error(`${raw}: ファイルが見つかりません`)
651    if (stat.size > IMAGE_LIMIT) throw new Error(`${raw}: 5MB までの画像にしてください`)
652    out.push(path)
653  }
654
655  return out
656}
657
658async function stageDraft($: Engine, text: string, imagePaths: string[] = []): Promise<string> {
659  const body = text.trim()
660  if (!body && imagePaths.length === 0) return '投稿する文章が空です'
661  const before = await readDraft($)
662  let images = before.images
663  try {
664    if (imagePaths.length > 0) images = await checkImages($, imagePaths)
665  } catch (error) {
666    await setStatus($, { error: errorText(error) })
667
668    return `⚠ ${errorText(error)}`
669  }
670  const { replyTo, quoteOf } = before
671  await update($, draft, () => ({ ...EMPTY_DRAFT, text: body, replyTo, quoteOf, images, isConfirming: true }))
672  await setStatus($, { error: null, notice: null })
673  const target = replyTo ? `@${replyTo.username} への返信を` : quoteOf ? `@${quoteOf.username} の引用を` : ''
674  const attached = images.length ? `\n🖼 画像 ${images.length}枚: ${images.map(path => path.split('/').pop()).join(', ')}` : ''
675
676  return `${target}この内容で投稿しますか?(${weightedLength(body)}/${POST_LIMIT})\n\n${body}${attached}\n\n確定は「ポストする」か \`/x send\`、取り消しは \`/x cancel\``
677}
678
679async function sendDraft($: Engine): Promise<string> {
680  const pending = await readDraft($)
681  if (!pending.isConfirming || !pending.text) return '確認待ちの投稿はありません'
682  if (pending.isPosting) return '投稿中です'
683  tokens ??= await loadTokens($)
684  if (!canPost(tokens)) {
685    const message = '投稿の権限がありません。`/x-timeline login` でログインし直してください'
686    await setStatus($, { error: message })
687
688    return message
689  }
690  if (pending.images.length > 0 && !hasScope(tokens, 'media.write')) {
691    const message = '画像の投稿には `/x login` でログインし直してください(画像の許可が増えました)'
692    await setStatus($, { error: message })
693
694    return message
695  }
696  await update($, draft, d => ({ ...EMPTY_DRAFT, ...d, isPosting: true }))
697  try {
698    const mediaIds: string[] = []
699    for (const path of pending.images) mediaIds.push(await uploadImage($, path))
700    const body: Record<string, unknown> = { text: pending.text }
701    if (pending.replyTo) body.reply = { in_reply_to_tweet_id: pending.replyTo.id }
702    if (pending.quoteOf) body.quote_tweet_id = pending.quoteOf.id
703    if (mediaIds.length) body.media = { media_ids: mediaIds }
704    const res = await apiCall($, 'POST', '/2/tweets', {}, body)
705    if (!res) throw new Error('ログインが必要です')
706    if (res.status !== 201 && res.status !== 200) throw new Error(describeApiError(res))
707    const created = JSON.parse(res.text)?.data as { id?: string } | undefined
708    const me = await account($)
709    let url = ''
710    if (created?.id && me) {
711      const own: Post = {
712        id: created.id,
713        text: pending.text,
714        createdAt: new Date(await $.clock.now()).toISOString(),
715        username: me.username,
716        name: me.username,
717        likes: 0,
718        reposts: 0,
719        replies: 0,
720        isReply: pending.replyTo !== null,
721      }
722      url = postUrl(own)
723      await update($, posts, list => mergePosts([own], list, KEPT))
724    }
725    await update($, draft, () => EMPTY_DRAFT)
726    await setStatus($, { notice: `投稿しました ${url}`.trim(), error: null })
727    $.ui.toast('𝕏 に投稿しました')
728
729    return `投稿しました ${url}`.trim()
730  } catch (error) {
731    await update($, draft, d => ({ ...EMPTY_DRAFT, ...d, isPosting: false }))
732    await setStatus($, { error: errorText(error) })
733
734    return `投稿できませんでした: ${errorText(error)}`
735  }
736}
737
738// X's media upload, one picture: curl sends the file as a form part and
739// reads the Authorization header from stdin, so the token is in no argv.
740async function uploadImage($: Engine, path: string): Promise<string> {
741  const token = await accessToken($)
742  if (!token) throw new Error('ログインが必要です')
743  const ext = path.split('.').pop()?.toLowerCase() ?? ''
744  const done = await $.process.run(
745    ['/usr/bin/curl', '-sS', '--max-time', '60', '--proto', '=https', '-X', 'POST', `${API}/2/media/upload`, '-H', '@-', '-F', `media=@${path};type=${IMAGE_TYPES[ext] ?? 'application/octet-stream'}`, '-F', 'media_category=tweet_image'],
746    { stdin: `Authorization: Bearer ${token}\n`, timeoutMs: 70_000 },
747  )
748  let id: string | undefined
749  try {
750    const body = JSON.parse(done.stdout)
751    id = body?.data?.id ?? body?.media_id_string
752  } catch {
753    // not JSON
754  }
755  if (done.exitCode !== 0 || !id) throw new Error(`画像をアップロードできませんでした(${path.split('/').pop()})${describeApiError({ status: 400, headers: {}, text: done.stdout }).replace(/^X APIエラー 400/, '')}`)
756
757  return id
758}
759
760async function startQuote($: Engine, post: Post): Promise<void> {
761  await update($, draft, () => ({ ...EMPTY_DRAFT, quoteOf: { id: targetOf(post), username: post.username, name: post.name, text: post.text } }))
762  await setStatus($, { notice: null, error: null, repostAsk: null })
763}
764
765// A switch on a post the person owns a side of: like, bookmark. The scope it
766// needs, its endpoint pair, and the state list that remembers it.
767async function togglePostFlag($: Engine, post: Post, flag: 'bookmark'): Promise<void> {
768  tokens ??= await loadTokens($)
769  if (!hasScope(tokens, 'bookmark.write')) {
770    await setStatus($, { error: 'ブックマークには `/x login` でログインし直してください(ブックマークの許可が増えました)' })
771
772    return
773  }
774  const s = await readStatus($)
775  const target = targetOf(post)
776  const isOn = s.bookmarked.includes(target) || s.mode === 'bookmarks'
777  try {
778    const me = await account($)
779    if (!me) throw new Error('ログインが必要です')
780    const res = isOn
781      ? await apiCall($, 'DELETE', `/2/users/${me.id}/bookmarks/${target}`, {})
782      : await apiCall($, 'POST', `/2/users/${me.id}/bookmarks`, {}, { tweet_id: target })
783    if (!res) throw new Error('ログインが必要です')
784    if (res.status !== 200) throw new Error(describeApiError(res))
785    await setStatus($, { bookmarked: isOn ? s.bookmarked.filter(id => id !== target) : [...s.bookmarked, target], error: null, notice: isOn ? 'ブックマークを外しました' : '🔖 ブックマークしました' })
786    if (isOn) await update($, bookmarkPosts, list => list.filter(one => targetOf(one) !== target))
787    void flag
788  } catch (error) {
789    await setStatus($, { error: errorText(error) })
790  }
791}
792
793async function userIdOf($: Engine, username: string): Promise<{ id: string; username: string }> {
794  const s = await readStatus($)
795  if (s.view?.kind === 'user' && s.view.profile.username.toLowerCase() === username.toLowerCase()) return { id: s.view.profile.id, username: s.view.profile.username }
796  const profile = parseProfile(await apiJson($, `/2/users/by/username/${encodeURIComponent(username)}`, {}))
797  if (!profile) throw new Error(`@${username} が見つかりませんでした`)
798
799  return { id: profile.id, username: profile.username }
800}
801
802// Follow, unfollow, mute, unmute: one person, by their handle.
803async function relate($: Engine, act: 'follow' | 'unfollow' | 'mute' | 'unmute', username: string): Promise<string> {
804  tokens ??= await loadTokens($)
805  const scope = act === 'follow' || act === 'unfollow' ? 'follows.write' : 'mute.write'
806  if (!hasScope(tokens, scope)) return '`/x login` でログインし直してください(フォロー・ミュートの許可が増えました)'
807  try {
808    const me = await account($)
809    if (!me) throw new Error('ログインが必要です')
810    const them = await userIdOf($, username.replace(/^@/, ''))
811    const res =
812      act === 'follow'
813        ? await apiCall($, 'POST', `/2/users/${me.id}/following`, {}, { target_user_id: them.id })
814        : act === 'unfollow'
815          ? await apiCall($, 'DELETE', `/2/users/${me.id}/following/${them.id}`, {})
816          : act === 'mute'
817            ? await apiCall($, 'POST', `/2/users/${me.id}/muting`, {}, { target_user_id: them.id })
818            : await apiCall($, 'DELETE', `/2/users/${me.id}/muting/${them.id}`, {})
819    if (!res) throw new Error('ログインが必要です')
820    if (res.status !== 200) throw new Error(describeApiError(res))
821    const s = await readStatus($)
822    const without = (list: string[]) => list.filter(id => id !== them.id)
823    await setStatus($, {
824      followed: act === 'follow' ? [...without(s.followed), them.id] : act === 'unfollow' ? without(s.followed) : s.followed,
825      muted: act === 'mute' ? [...without(s.muted), them.id] : act === 'unmute' ? without(s.muted) : s.muted,
826      error: null,
827    })
828    const words = { follow: 'フォローしました', unfollow: 'フォローを解除しました', mute: 'ミュートしました', unmute: 'ミュートを解除しました' }
829
830    return `@${them.username} を${words[act]}`
831  } catch (error) {
832    await setStatus($, { error: errorText(error) })
833
834    return `⚠ ${errorText(error)}`
835  }
836}
837
838async function deletePost($: Engine, post: Post): Promise<string> {
839  const s = await readStatus($)
840  if (post.repostedBy || post.username.toLowerCase() !== (s.account ?? '').toLowerCase()) return '削除できるのは自分のポストだけです'
841  try {
842    const res = await apiCall($, 'DELETE', `/2/tweets/${post.id}`, {})
843    if (!res) throw new Error('ログインが必要です')
844    if (res.status !== 200) throw new Error(describeApiError(res))
845    const gone = (list: Post[]) => list.filter(one => one.id !== post.id)
846    await update($, posts, gone)
847    await update($, mentions, gone)
848    await update($, tagPosts, gone)
849    await update($, viewPosts, gone)
850    await setStatus($, { deleteAsk: null, notice: 'ポストを削除しました', error: null })
851
852    return 'ポストを削除しました'
853  } catch (error) {
854    await setStatus($, { deleteAsk: null, error: errorText(error) })
855
856    return `⚠ ${errorText(error)}`
857  }
858}
859
860async function cancelDraft($: Engine): Promise<string> {
861  await update($, draft, () => EMPTY_DRAFT)
862
863  return '投稿を取り消しました'
864}
865
866async function startReply($: Engine, post: Post): Promise<void> {
867  await update($, draft, () => ({ ...EMPTY_DRAFT, replyTo: { id: post.id, username: post.username } }))
868  await setStatus($, { notice: null, error: null })
869}
870
871async function toggleLike($: Engine, post: Post): Promise<void> {
872  tokens ??= await loadTokens($)
873  if (!canLike(tokens)) {
874    await setStatus($, { error: 'いいねの権限がありません。`/x-timeline login` でログインし直してください' })
875
876    return
877  }
878  const s = await readStatus($)
879  const target = targetOf(post)
880  const isLiked = s.liked.includes(target)
881  try {
882    const me = await account($)
883    if (!me) throw new Error('ログインが必要です')
884    const res = isLiked
885      ? await apiCall($, 'DELETE', `/2/users/${me.id}/likes/${target}`, {})
886      : await apiCall($, 'POST', `/2/users/${me.id}/likes`, {}, { tweet_id: target })
887    if (!res) throw new Error('ログインが必要です')
888    if (res.status !== 200) throw new Error(describeApiError(res))
889    const liked = isLiked ? s.liked.filter(id => id !== target) : [...s.liked, target]
890    const delta = isLiked ? -1 : 1
891    await setStatus($, { liked, error: null })
892    const bump = (list: Post[]) => list.map(one => (targetOf(one) === target ? { ...one, likes: Math.max(0, (one.likes ?? 0) + delta) } : one))
893    await update($, posts, bump)
894    await update($, mentions, bump)
895    await update($, tagPosts, bump)
896  } catch (error) {
897    await setStatus($, { error: errorText(error) })
898  }
899}
900
901// A repost shares the post with the person's followers, so the first press
902// asks and the second confirms, as X's own menu does.
903async function toggleRepost($: Engine, post: Post, isConfirmed: boolean): Promise<void> {
904  const s = await readStatus($)
905  const target = targetOf(post)
906  const isReposted = s.reposted.includes(target)
907  if (!isReposted && !isConfirmed) {
908    await setStatus($, { repostAsk: s.repostAsk === post.id ? null : post.id })
909
910    return
911  }
912  tokens ??= await loadTokens($)
913  if (!canPost(tokens)) {
914    await setStatus($, { repostAsk: null, error: 'リポストの権限がありません。`/x login` でログインし直してください' })
915
916    return
917  }
918  try {
919    const me = await account($)
920    if (!me) throw new Error('ログインが必要です')
921    const res = isReposted
922      ? await apiCall($, 'DELETE', `/2/users/${me.id}/retweets/${target}`, {})
923      : await apiCall($, 'POST', `/2/users/${me.id}/retweets`, {}, { tweet_id: target })
924    if (!res) throw new Error('ログインが必要です')
925    if (res.status !== 200) throw new Error(describeApiError(res))
926    const reposted = isReposted ? s.reposted.filter(id => id !== target) : [...s.reposted, target]
927    const delta = isReposted ? -1 : 1
928    await setStatus($, { reposted, repostAsk: null, error: null })
929    const bump = (list: Post[]) => list.map(one => (targetOf(one) === target ? { ...one, reposts: Math.max(0, (one.reposts ?? 0) + delta) } : one))
930    await update($, posts, bump)
931    await update($, mentions, bump)
932    await update($, tagPosts, bump)
933  } catch (error) {
934    await setStatus($, { repostAsk: null, error: errorText(error) })
935  }
936}
937
938const POST_QUERY = { 'tweet.fields': TWEET_FIELDS, expansions: EXPANSIONS, 'user.fields': USER_FIELDS, 'media.fields': MEDIA_FIELDS }
939
940async function apiJson($: Engine, path: string, query: Record<string, string>): Promise<unknown> {
941  const res = await apiCall($, 'GET', path, query)
942  if (!res) throw new Error('ログインが必要です。`/x login` でログインしてください')
943  if (res.status !== 200) throw new Error(describeApiError(res))
944
945  return JSON.parse(res.text)
946}
947
948// A thread: the conversation's first post, then its replies oldest first.
949// Recent search reaches the last 7 days, as X's own API allows.
950async function loadThread($: Engine, from: Post | string): Promise<{ view: View; list: Post[] }> {
951  // A listed post already knows its conversation; an id alone is looked up.
952  const postId = typeof from === 'string' ? from : targetOf(from)
953  const [post] = typeof from !== 'string' && from.conversationId ? [from] : parseSingle(await apiJson($, `/2/tweets/${postId}`, POST_QUERY))
954  if (!post) throw new Error('ポストが見つかりませんでした')
955  const rootId = post.conversationId ?? postId
956  const [root] = rootId === postId ? [post] : parseSingle(await apiJson($, `/2/tweets/${rootId}`, POST_QUERY).catch(() => ({})))
957  const found = await apiJson($, '/2/tweets/search/recent', {
958    ...POST_QUERY,
959    query: `conversation_id:${rootId}`,
960    max_results: String(Math.max(10, maxResults)),
961  })
962  const replies = parseTimeline(found).reverse()
963
964  return { view: { kind: 'thread', rootId, username: (root ?? post).username }, list: mergePosts([...(root ? [root] : []), ...(root ? [] : [post]), ...replies], [], KEPT) }
965}
966
967async function loadUser($: Engine, username: string): Promise<{ view: View; list: Post[] }> {
968  const profile = parseProfile(await apiJson($, `/2/users/by/username/${encodeURIComponent(username)}`, { 'user.fields': PROFILE_FIELDS }))
969  if (!profile) throw new Error(`@${username} が見つかりませんでした`)
970  const list = parseTimeline(await apiJson($, `/2/users/${profile.id}/tweets`, { ...POST_QUERY, max_results: String(Math.max(5, maxResults)) }))
971
972  return { view: { kind: 'user', profile }, list }
973}
974
975async function loadSearch($: Engine, query: string): Promise<{ view: View; list: Post[] }> {
976  const list = parseTimeline(await apiJson($, '/2/tweets/search/recent', { ...POST_QUERY, query, max_results: String(Math.max(10, maxResults)) }))
977
978  return { view: { kind: 'search', query }, list }
979}
980
981async function loadLikes($: Engine, username: string | null): Promise<{ view: View; list: Post[] }> {
982  tokens ??= await loadTokens($)
983  if (!hasScope(tokens, 'like.read')) throw new Error('いいね一覧には `/x login` でログインし直してください(いいね一覧の許可が増えました)')
984  const me = await account($)
985  const who = username ? await userIdOf($, username) : me
986  if (!who) throw new Error('ログインが必要です')
987  const list = parseTimeline(await apiJson($, `/2/users/${who.id}/liked_tweets`, { ...POST_QUERY, max_results: String(Math.max(10, maxResults)) }))
988
989  return { view: { kind: 'likes', username: who.username, userId: who.id }, list }
990}
991
992async function loadList($: Engine, listId: string, name: string): Promise<{ view: View; list: Post[] }> {
993  const list = parseTimeline(await apiJson($, `/2/lists/${listId}/tweets`, { ...POST_QUERY, max_results: String(Math.max(5, maxResults)) }))
994
995  return { view: { kind: 'list', listId, name }, list }
996}
997
998async function loadLists($: Engine): Promise<{ view: View; list: Post[] }> {
999  tokens ??= await loadTokens($)
1000  if (!hasScope(tokens, 'list.read')) throw new Error('リストには `/x login` でログインし直してください(リストの許可が増えました)')
1001  const me = await account($)
1002  if (!me) throw new Error('ログインが必要です')
1003  const fields = { 'list.fields': 'member_count,private,description', max_results: '100' }
1004  const owned = parseLists(await apiJson($, `/2/users/${me.id}/owned_lists`, fields))
1005  const followed = parseLists(await apiJson($, `/2/users/${me.id}/followed_lists`, fields).catch(() => ({})))
1006  const lists = [...owned, ...followed.filter(one => !owned.some(mine => mine.id === one.id))]
1007
1008  return { view: { kind: 'lists', lists }, list: [] }
1009}
1010
1011async function loadTrends($: Engine): Promise<{ view: View; list: Post[] }> {
1012  const trends = parseTrends(await apiJson($, '/2/users/personalized_trends', { 'personalized_trend.fields': 'category,post_count,trend_name,trending_since' }))
1013
1014  return { view: { kind: 'trends', trends }, list: [] }
1015}
1016
1017function loaderFor($: Engine, view: View): Promise<{ view: View; list: Post[] }> {
1018  if (view.kind === 'thread') return loadThread($, view.rootId)
1019  if (view.kind === 'user') return loadUser($, view.profile.username)
1020  if (view.kind === 'search') return loadSearch($, view.query)
1021  if (view.kind === 'likes') return loadLikes($, view.username)
1022  if (view.kind === 'list') return loadList($, view.listId, view.name)
1023
1024  return view.kind === 'lists' ? loadLists($) : loadTrends($)
1025}
1026
1027let isLoadingView = false
1028
1029async function showView($: Engine, load: Promise<{ view: View; list: Post[] }>): Promise<string> {
1030  const before = await readStatus($)
1031  await setStatus($, { isLoading: true, error: null, notice: null, backTo: before.mode === 'view' ? before.backTo : before.mode })
1032  try {
1033    const { view, list } = await load
1034    await update($, viewPosts, () => list)
1035    await setStatus($, { mode: 'view', view, cursor: 0, newIds: [], fetchedAt: { ...before.fetchedAt, view: await $.clock.now() } })
1036  } catch (error) {
1037    await setStatus($, { error: errorText(error) })
1038  } finally {
1039    await setStatus($, { isLoading: false })
1040  }
1041  await openPane($)
1042  const after = await readStatus($)
1043  if (after.mode !== 'view') return `⚠ ${after.error ?? '表示できませんでした'}`
1044
1045  return live($, `${tabBar(after)}\n\n${await asText($, 'view')}`)
1046}
1047
1048async function reloadView($: Engine): Promise<void> {
1049  const view = (await readStatus($)).view
1050  if (!view || isLoadingView) return
1051  isLoadingView = true
1052  try {
1053    await showView($, loaderFor($, view))
1054  } finally {
1055    isLoadingView = false
1056  }
1057}
1058
1059async function goBack($: Engine): Promise<string> {
1060  const s = await readStatus($)
1061
1062  return showMode($, s.mode === 'view' ? s.backTo : s.mode)
1063}
1064
1065function viewTitle(view: View | null): string {
1066  if (!view) return ''
1067  if (view.kind === 'thread') return `💭 @${view.username} のスレッド`
1068  if (view.kind === 'user') return `👤 @${view.profile.username}`
1069  if (view.kind === 'search') return `🔍 ${view.query}`
1070  if (view.kind === 'likes') return `♡ @${view.username} のいいね`
1071  if (view.kind === 'list') return `📋 ${view.name}`
1072
1073  return view.kind === 'lists' ? '📋 リスト' : '📈 トレンド'
1074}
1075
1076// The view's own lines in the chat: what is not a post (lists, trends).
1077function viewIndexText(view: View): string | null {
1078  if (view.kind === 'lists') {
1079    return view.lists.length
1080      ? view.lists.map((list, i) => `\`${i + 1}\` **${list.name}**${list.isPrivate ? ' 🔒' : ''}${list.members !== null ? ` · ${list.members}人` : ''}${list.description ? `  \n<sub>${list.description}</sub>` : ''}`).join('\n\n') + '\n\n<sub>開く: `/x list 番号`</sub>'
1081      : 'リストがありません'
1082  }
1083  if (view.kind === 'trends') {
1084    return view.trends.length
1085      ? view.trends.map((trend, i) => `\`${i + 1}\` **${trend.name}**${trend.posts ? ` · ${compactCount(trend.posts)}件のポスト` : ''}${trend.category ? ` · ${trend.category}` : ''}`).join('  \n') + '\n\n<sub>検索: `/x trend 番号`</sub>'
1086      : 'トレンドを取得できませんでした'
1087  }
1088
1089  return null
1090}
1091
1092async function asText($: Engine, mode: Mode): Promise<string> {
1093  let list = await readList($, mode)
1094  // A view is loaded by its own command; an empty one (no results) stays so.
1095  if (list.length === 0 && mode !== 'view') {
1096    await refreshTimeline($, mode)
1097    list = await readList($, mode)
1098  }
1099  const s = await readStatus($)
1100  if (s.error) return `⚠ ${s.error}`
1101  const index = mode === 'view' && s.view ? viewIndexText(s.view) : null
1102  if (index !== null) return `_${viewTitle(s.view)}_\n\n${index}`
1103  if (list.length === 0 && mode === 'view') return `_${viewTitle(s.view)}_\n\n見つかりませんでした`
1104  if (list.length === 0) return s.auth === 'signed-in' ? '表示できるポストがありません' : '未ログインです。`/x-timeline login` でログインしてください'
1105  const now = await $.clock.now()
1106  const tagLabel = s.activeTag ? `#${s.activeTag}` : s.tags.map(tag => `#${tag}`).join(' ')
1107  const fetchedAt = s.fetchedAt[mode]
1108  const when = fetchedAt ? ` · ${relativeTime(new Date(fetchedAt).toISOString(), now)}前に更新` : ''
1109  const view = mode === 'view' ? s.view : null
1110  const heading = view
1111    ? view.kind === 'user'
1112      ? `${profileText(view.profile)}\n\n_@${view.profile.username} の最新ポスト${when}_`
1113      : view.kind === 'thread'
1114        ? `_💭 @${view.username} のポストのスレッド(返信は古い順・直近7日)${when}_`
1115        : `_${viewTitle(view)}${when}_`
1116    : mode === 'bookmarks'
1117      ? `_🔖 @${s.account ?? ''} のブックマーク${when}_`
1118      : mode === 'home'
1119      ? `_@${s.account ?? ''} のホーム${when}_`
1120      : mode === 'mentions'
1121        ? `_@${s.account ?? ''} へのメンション${when}_`
1122        : `_${tagLabel} の最新ポスト${when}_`
1123
1124  const page = list.slice(0, SHOWN_AS_TEXT)
1125  // The numbers the chat's actions (`/x like 3`) refer to.
1126  await setStatus($, { shown: { ...s.shown, [mode]: page.map(post => post.id) } })
1127
1128  return [heading, ...page.map((post, i) => postText(post, now, i + 1))].join('\n\n---\n\n')
1129}
1130
1131function displayWidth(text: string): number {
1132  let width = 0
1133  for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e80 ? 2 : 1
1134
1135  return width
1136}
1137
1138type TextElement = (props: Record<string, unknown>, ...children: unknown[]) => unknown
1139
1140// A round avatar stand-in: the name's first letter on a color the handle picks.
1141function avatar(Text: unknown, name: string, username: string) {
1142  const T = Text as TextElement
1143  let hash = 0
1144  for (const char of username) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
1145  const color = AVATAR_COLORS[hash % AVATAR_COLORS.length] ?? ACCENT
1146  const initial = [...(name.trim() || username || '?')][0] ?? '?'
1147  const cell = displayWidth(initial) > 1 ? ` ${initial}` : ` ${initial.toUpperCase()} `
1148
1149  return h(T as never, { color: '#ffffff', backgroundColor: color, bold: true }, cell)
1150}
1151
1152// Post text with #tags, @mentions and links in X's blue.
1153function richText(Text: unknown, text: string, links?: Post['links']) {
1154  const T = Text as TextElement
1155
1156  return segments(text, links).map(part => (part.kind === 'text' ? part.text : h(T as never, { color: ACCENT }, part.text)))
1157}
1158
1159async function ensureMediaDir($: Engine): Promise<string | null> {
1160  if (mediaDir) return mediaDir
1161  const home = await $.env.get('HOME')
1162  if (!home) return null
1163  const dir = `${platform.cacheRoot ?? (platform.secrets === 'keychain' ? `${home}/Library/Caches` : `${home}/.cache`)}/claude-x-timeline/media`
1164  await $.process.run(['/bin/mkdir', '-p', dir])
1165  mediaDir = dir
1166
1167  return dir
1168}
1169
1170// Fetches the small PNG of each picture the pane shows, from X's image host
1171// only; the terminal draws the file, so no pixel crosses `$`.
1172async function prefetchImages($: Engine, list: Post[]): Promise<void> {
1173  const dir = await ensureMediaDir($)
1174  if (!dir) return
1175  const ready = new Set((await readStatus($)).imagesReady)
1176  const wanted = list
1177    .slice(0, 12)
1178    .flatMap(post => (post.images ?? []).slice(0, 2))
1179    .filter(image => /^[A-Za-z0-9_]+$/.test(image.key) && isMediaUrl(image.url) && !ready.has(image.key) && !mediaInFlight.has(image.key))
1180  for (const image of wanted) {
1181    mediaInFlight.add(image.key)
1182    const done = await $.process
1183      .run(['/usr/bin/curl', '-sSfL', '--max-time', '15', '--proto', '=https', '-o', `${dir}/${image.key}.png`, mediaVariant(image.url, 'png', 'small')], { timeoutMs: 20_000 })
1184      .catch(() => null)
1185    mediaInFlight.delete(image.key)
1186    if (done?.exitCode === 0) await update($, status, s => ({ ...INITIAL, ...s, imagesReady: [...(s.imagesReady ?? []), image.key].slice(-300) }))
1187  }
1188}
1189
1190async function selected($: Engine): Promise<Post | undefined> {
1191  const s = await readStatus($)
1192  const list = await readList($, s.mode)
1193
1194  return list[Math.min(s.cursor, list.length - 1)]
1195}
1196
1197async function moveCursor($: Engine, delta: number): Promise<void> {
1198  const s = await readStatus($)
1199  const length = (await readList($, s.mode)).length
1200  await setStatus($, { cursor: Math.max(0, Math.min(length - 1, s.cursor + delta)), repostAsk: null })
hooks/qr.ts 312 lines
1// A small QR Code encoder: byte mode, error correction level M, versions 1-6
2// (up to 106 bytes, plenty for a profile URL). Follows ISO/IEC 18004 the way
3// Project Nayuki's reference encoder lays it out. No engine calls here.
4
5const ECC_PER_BLOCK_M = [-1, 10, 16, 26, 18, 24, 16]
6const BLOCKS_M = [-1, 1, 1, 1, 2, 2, 4]
7const MAX_VERSION = 6
8
9export type QrMatrix = boolean[][]
10
11function rawDataModules(version: number): number {
12  let result = (16 * version + 128) * version + 64
13  if (version >= 2) {
14    const align = Math.floor(version / 7) + 2
15    result -= (25 * align - 10) * align - 55
16  }
17
18  return result
19}
20
21function dataCodewords(version: number): number {
22  return Math.floor(rawDataModules(version) / 8) - (ECC_PER_BLOCK_M[version] ?? 0) * (BLOCKS_M[version] ?? 0)
23}
24
25function gfMultiply(x: number, y: number): number {
26  let z = 0
27  for (let i = 7; i >= 0; i--) {
28    z = (z << 1) ^ ((z >>> 7) * 0x11d)
29    z ^= ((y >>> i) & 1) * x
30  }
31
32  return z & 0xff
33}
34
35function rsDivisor(degree: number): number[] {
36  const result = new Array<number>(degree).fill(0)
37  result[degree - 1] = 1
38  let root = 1
39  for (let i = 0; i < degree; i++) {
40    for (let j = 0; j < degree; j++) {
41      result[j] = gfMultiply(result[j] ?? 0, root)
42      if (j + 1 < degree) result[j] = (result[j] ?? 0) ^ (result[j + 1] ?? 0)
43    }
44    root = gfMultiply(root, 0x02)
45  }
46
47  return result
48}
49
50function rsRemainder(data: number[], divisor: number[]): number[] {
51  const result = new Array<number>(divisor.length).fill(0)
52  for (const byte of data) {
53    const factor = byte ^ (result.shift() ?? 0)
54    result.push(0)
55    divisor.forEach((coef, i) => {
56      result[i] = (result[i] ?? 0) ^ gfMultiply(coef, factor)
57    })
58  }
59
60  return result
61}
62
63function withEcc(data: number[], version: number): number[] {
64  const blocks = BLOCKS_M[version] ?? 1
65  const eccLen = ECC_PER_BLOCK_M[version] ?? 0
66  const raw = Math.floor(rawDataModules(version) / 8)
67  const shortBlocks = blocks - (raw % blocks)
68  const shortLen = Math.floor(raw / blocks)
69  const divisor = rsDivisor(eccLen)
70  const all: number[][] = []
71  let k = 0
72  for (let i = 0; i < blocks; i++) {
73    const dat = data.slice(k, k + shortLen - eccLen + (i < shortBlocks ? 0 : 1))
74    k += dat.length
75    const ecc = rsRemainder(dat, divisor)
76    if (i < shortBlocks) dat.push(0)
77    all.push(dat.concat(ecc))
78  }
79  const result: number[] = []
80  const width = all[0]?.length ?? 0
81  for (let i = 0; i < width; i++) {
82    all.forEach((block, j) => {
83      if (i !== shortLen - eccLen || j >= shortBlocks) result.push(block[i] ?? 0)
84    })
85  }
86
87  return result
88}
89
90function encodeData(bytes: Uint8Array): { version: number; codewords: number[] } {
91  for (let version = 1; version <= MAX_VERSION; version++) {
92    const capacityBits = dataCodewords(version) * 8
93    const bits: number[] = []
94    const push = (value: number, length: number) => {
95      for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1)
96    }
97    push(0b0100, 4)
98    push(bytes.length, 8)
99    bytes.forEach(byte => push(byte, 8))
100    if (bits.length > capacityBits) continue
101    push(0, Math.min(4, capacityBits - bits.length))
102    push(0, (8 - (bits.length % 8)) % 8)
103    for (let pad = 0xec; bits.length < capacityBits; pad ^= 0xec ^ 0x11) push(pad, 8)
104    const codewords: number[] = []
105    for (let i = 0; i < bits.length; i += 8) {
106      let byte = 0
107      for (let j = 0; j < 8; j++) byte = (byte << 1) | (bits[i + j] ?? 0)
108      codewords.push(byte)
109    }
110
111    return { version, codewords: withEcc(codewords, version) }
112  }
113  throw new Error('QRコードに入りきらない長さです')
114}
115
116function alignmentPositions(version: number, size: number): number[] {
117  if (version === 1) return []
118  const count = Math.floor(version / 7) + 2
119  const step = Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2
120  const result = [6]
121  for (let pos = size - 7; result.length < count; pos -= step) result.splice(1, 0, pos)
122
123  return result
124}
125
126const MASKS: Array<(x: number, y: number) => boolean> = [
127  (x, y) => (x + y) % 2 === 0,
128  (_x, y) => y % 2 === 0,
129  x => x % 3 === 0,
130  (x, y) => (x + y) % 3 === 0,
131  (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
132  (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
133  (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
134  (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
135]
136
137function penalty(m: QrMatrix): number {
138  const size = m.length
139  let score = 0
140  const runs = (line: boolean[]) => {
141    let run = 1
142    for (let i = 1; i <= line.length; i++) {
143      if (i < line.length && line[i] === line[i - 1]) {
144        run++
145        continue
146      }
147      if (run >= 5) score += run - 2
148      run = 1
149    }
150  }
151  for (let y = 0; y < size; y++) runs(m[y] ?? [])
152  for (let x = 0; x < size; x++) runs(m.map(row => row[x] ?? false))
153  for (let y = 0; y + 1 < size; y++) {
154    for (let x = 0; x + 1 < size; x++) {
155      const c = m[y]?.[x]
156      if (c === m[y]?.[x + 1] && c === m[y + 1]?.[x] && c === m[y + 1]?.[x + 1]) score += 3
157    }
158  }
159  const finderLike = [true, false, true, true, true, false, true]
160  const scan = (line: boolean[]) => {
161    for (let i = 0; i + 7 <= line.length; i++) {
162      if (!finderLike.every((v, j) => line[i + j] === v)) continue
163      const before = line.slice(Math.max(0, i - 4), i)
164      const after = line.slice(i + 7, i + 11)
165      if ((i < 4 || before.every(v => !v)) || (i + 11 > line.length || after.every(v => !v))) score += 40
166    }
167  }
168  for (let y = 0; y < size; y++) scan(m[y] ?? [])
169  for (let x = 0; x < size; x++) scan(m.map(row => row[x] ?? false))
170  const dark = m.reduce((n, row) => n + row.filter(Boolean).length, 0)
171  const total = size * size
172  score += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10
173
174  return score
175}
176
177export function encodeQr(text: string): QrMatrix {
178  const { version, codewords } = encodeData(new TextEncoder().encode(text))
179  const size = version * 4 + 17
180  const grid: QrMatrix = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
181  const fixed: QrMatrix = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
182  const set = (x: number, y: number, dark: boolean) => {
183    const row = grid[y]
184    const fixedRow = fixed[y]
185    if (!row || !fixedRow || x < 0 || x >= size) return
186    row[x] = dark
187    fixedRow[x] = true
188  }
189
190  for (let i = 0; i < size; i++) {
191    set(6, i, i % 2 === 0)
192    set(i, 6, i % 2 === 0)
193  }
194  for (const [cx, cy] of [
195    [3, 3],
196    [size - 4, 3],
197    [3, size - 4],
198  ] as const) {
199    for (let dy = -4; dy <= 4; dy++) {
200      for (let dx = -4; dx <= 4; dx++) {
201        const dist = Math.max(Math.abs(dx), Math.abs(dy))
202        const x = cx + dx
203        const y = cy + dy
204        if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, dist !== 2 && dist !== 4)
205      }
206    }
207  }
208  const aligns = alignmentPositions(version, size)
209  aligns.forEach((ay, i) =>
210    aligns.forEach((ax, j) => {
211      const isCorner = (i === 0 && j === 0) || (i === 0 && j === aligns.length - 1) || (i === aligns.length - 1 && j === 0)
212      if (isCorner) return
213      for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
214    }),
215  )
216  const drawFormat = (mask: number) => {
217    const data = (0 << 3) | mask // level M is 00
218    let rem = data
219    for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
220    const bits = ((data << 10) | rem) ^ 0x5412
221    const bit = (i: number) => ((bits >>> i) & 1) !== 0
222    for (let i = 0; i <= 5; i++) set(8, i, bit(i))
223    set(8, 7, bit(6))
224    set(8, 8, bit(7))
225    set(7, 8, bit(8))
226    for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i))
227    for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i))
228    for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i))
229    set(8, size - 8, true)
230  }
231  drawFormat(0)
232
233  let i = 0
234  for (let right = size - 1; right >= 1; right -= 2) {
235    if (right === 6) right = 5
236    for (let vert = 0; vert < size; vert++) {
237      for (let j = 0; j < 2; j++) {
238        const x = right - j
239        const upward = ((right + 1) & 2) === 0
240        const y = upward ? size - 1 - vert : vert
241        const row = grid[y]
242        if (row && !fixed[y]?.[x] && i < codewords.length * 8) {
243          row[x] = (((codewords[i >>> 3] ?? 0) >>> (7 - (i & 7))) & 1) !== 0
244          i++
245        }
246      }
247    }
248  }
249
250  const masked = (mask: number): QrMatrix => {
251    const test = MASKS[mask] ?? (() => false)
252    const out = grid.map((row, y) => row.map((dark, x) => (fixed[y]?.[x] ? dark : dark !== test(x, y))))
253    const keep = grid.map(row => [...row])
254    grid.splice(0, size, ...out)
255    drawFormat(mask)
256    const result = grid.map(row => [...row])
257    grid.splice(0, size, ...keep)
258
259    return result
260  }
261  let best = masked(0)
262  let bestScore = penalty(best)
263  for (let mask = 1; mask < 8; mask++) {
264    const candidate = masked(mask)
265    const score = penalty(candidate)
266    if (score < bestScore) {
267      best = candidate
268      bestScore = score
269    }
270  }
271
272  return best
273}
274
275function withQuietZone(m: QrMatrix, quiet: number): QrMatrix {
276  const size = m.length + quiet * 2
277  return Array.from({ length: size }, (_, y) =>
278    Array.from({ length: size }, (_, x) => m[y - quiet]?.[x - quiet] ?? false),
279  )
280}
281
282// Two module rows per text row with half blocks. `darkGlyph` true draws the
283// dark modules as glyphs (for black text on a white background); false
284// draws the light ones (for light text on a dark background).
285export function qrRows(m: QrMatrix, darkGlyph: boolean, quiet = 2): string[] {
286  const grid = withQuietZone(m, quiet)
287  const rows: string[] = []
288  for (let y = 0; y < grid.length; y += 2) {
289    let line = ''
290    for (let x = 0; x < grid.length; x++) {
291      const top = (grid[y]?.[x] ?? false) === darkGlyph
292      const bottom = y + 1 < grid.length ? (grid[y + 1]?.[x] ?? false) === darkGlyph : !darkGlyph ? true : false
293      line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
294    }
295    rows.push(line)
296  }
297
298  return rows
299}
300
301export function qrSvg(m: QrMatrix, quiet = 4, scale = 10): string {
302  const size = m.length + quiet * 2
303  const rects: string[] = []
304  m.forEach((row, y) =>
305    row.forEach((dark, x) => {
306      if (dark) rects.push(`M${x + quiet} ${y + quiet}h1v1h-1z`)
307    }),
308  )
309
310  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size * scale}" height="${size * scale}" shape-rendering="crispEdges"><rect width="${size}" height="${size}" fill="#fff"/><path d="${rects.join('')}" fill="#000"/></svg>\n`
311}
312
hooks/x-api.ts 733 lines
1import type { ListInfo, Post, Profile, TrendInfo } from '../types'
2
3// Pure helpers for X API v2 and OAuth 2.0 (PKCE). No engine calls here.
4
5export const API = 'https://api.x.com'
6export const AUTHORIZE = 'https://x.com/i/oauth2/authorize'
7// Every scope a feature of the mod uses; never DMs. A token without one
8// asks the person to log in again for that feature only.
9export const SCOPES = [
10  'tweet.read',
11  'tweet.write',
12  'users.read',
13  'like.read',
14  'like.write',
15  'bookmark.read',
16  'bookmark.write',
17  'follows.read',
18  'follows.write',
19  'mute.write',
20  'list.read',
21  'media.write',
22  'offline.access',
23].join(' ')
24export const CALLBACK_PORT = 8976
25export const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`
26
27export type Tokens = {
28  accessToken: string
29  refreshToken: string | null
30  expiresAt: number
31  clientId: string | null
32  scope: string
33}
34
35export type HttpLike = {
36  status: number
37  headers: Record<string, string>
38  text: string
39}
40
41const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
42
43export function base64url(bytes: Uint8Array): string {
44  let out = ''
45  for (let i = 0; i < bytes.length; i += 3) {
46    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
47    out += ALPHABET.charAt((n >> 18) & 63) + ALPHABET.charAt((n >> 12) & 63)
48    if (i + 1 < bytes.length) out += ALPHABET.charAt((n >> 6) & 63)
49    if (i + 2 < bytes.length) out += ALPHABET.charAt(n & 63)
50  }
51
52  return out
53}
54
55export function fromBase64url(text: string): Uint8Array {
56  const out: number[] = []
57  let buffer = 0
58  let bits = 0
59  for (const char of text) {
60    const value = ALPHABET.indexOf(char)
61    if (value < 0) throw new Error('invalid base64url')
62    buffer = (buffer << 6) | value
63    bits += 6
64    if (bits >= 8) {
65      bits -= 8
66      out.push((buffer >> bits) & 255)
67    }
68  }
69
70  return new Uint8Array(out)
71}
72
73export function encodeSecret(tokens: Tokens): string {
74  return base64url(new TextEncoder().encode(JSON.stringify(tokens)))
75}
76
77export function decodeSecret(text: string): Tokens | null {
78  try {
79    const value = JSON.parse(new TextDecoder().decode(fromBase64url(text.trim())))
80    if (typeof value?.accessToken !== 'string' || typeof value?.expiresAt !== 'number') return null
81
82    return {
83      accessToken: value.accessToken,
84      refreshToken: typeof value.refreshToken === 'string' ? value.refreshToken : null,
85      expiresAt: value.expiresAt,
86      clientId: typeof value.clientId === 'string' && isClientId(value.clientId) ? value.clientId : null,
87      scope: typeof value.scope === 'string' ? value.scope : '',
88    }
89  } catch {
90    return null
91  }
92}
93
94function randomBytes(length: number): Uint8Array {
95  // A CSPRNG only: PKCE and state must not fall back to Math.random.
96  return crypto.getRandomValues(new Uint8Array(length))
97}
98
99export async function createPkce(): Promise<{ verifier: string; challenge: string; state: string }> {
100  const verifier = base64url(randomBytes(32))
101  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
102
103  return { verifier, challenge: base64url(new Uint8Array(digest)), state: base64url(randomBytes(16)) }
104}
105
106export function formEncode(params: Record<string, string>): string {
107  return Object.entries(params)
108    .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
109    .join('&')
110}
111
112export function parseQuery(query: string): Record<string, string> {
113  const out: Record<string, string> = {}
114  for (const part of query.replace(/^\?/, '').split('&')) {
115    if (!part) continue
116    const [key = '', value = ''] = part.split('=')
117    out[decodeURIComponent(key.replace(/\+/g, ' '))] = decodeURIComponent(value.replace(/\+/g, ' '))
118  }
119
120  return out
121}
122
123export function authorizeUrl(clientId: string, challenge: string, state: string): string {
124  return `${AUTHORIZE}?${formEncode({
125    response_type: 'code',
126    client_id: clientId,
127    redirect_uri: REDIRECT_URI,
128    scope: SCOPES,
129    state,
130    code_challenge: challenge,
131    code_challenge_method: 'S256',
132  })}`
133}
134
135export function isClientId(value: string): boolean {
136  return /^[A-Za-z0-9_-]{10,100}$/.test(value)
137}
138
139// Reads `client-id <ID>`'s argument as people paste it: wrapped in <> or
140// quotes, or with a following `/x-timeline login` line pasted alongside.
141export function parseClientIdArgs(args: string): { id: string | null; alsoLogin: boolean } {
142  const [first = '', ...more] = args.split(/\r?\n/)
143  const token = (first.trim().split(/\s+/)[0] ?? '').replace(/^[<"'「]+|[>"'」]+$/g, '')
144
145  return {
146    id: isClientId(token) ? token : null,
147    alsoLogin: more.some(line => /^\/?x-timeline\s+login\s*$/.test(line.trim())),
148  }
149}
150
151export function tokensFrom(body: unknown, now: number, clientId: string): Tokens {
152  const value = body as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; scope?: unknown }
153  if (typeof value?.access_token !== 'string') throw new Error('トークン応答の形式が不正です')
154  const seconds = typeof value.expires_in === 'number' ? value.expires_in : 7200
155
156  return {
157    accessToken: value.access_token,
158    refreshToken: typeof value.refresh_token === 'string' ? value.refresh_token : null,
159    expiresAt: now + seconds * 1000,
160    clientId,
161    scope: typeof value.scope === 'string' ? value.scope : '',
162  }
163}
164
165export function hasScope(tokens: Tokens | null, scope: string): boolean {
166  return tokens !== null && tokens.scope.split(/\s+/).includes(scope)
167}
168
169export function canPost(tokens: Tokens | null): boolean {
170  return hasScope(tokens, 'tweet.write')
171}
172
173export function canLike(tokens: Tokens | null): boolean {
174  return hasScope(tokens, 'like.write')
175}
176
177export const TWEET_FIELDS = 'created_at,note_tweet,public_metrics,referenced_tweets,in_reply_to_user_id,entities,attachments,conversation_id'
178
179// X's weighted length: most Latin text counts 1, CJK and emoji 2, a URL 23.
180export const POST_LIMIT = 280
181
182export function weightedLength(text: string): number {
183  let total = 0
184  const withoutUrls = text.replace(/https?:\/\/\S+/g, () => {
185    total += 23
186
187    return ''
188  })
189  for (const char of withoutUrls) {
190    const code = char.codePointAt(0) ?? 0
191    const isLight =
192      code <= 4351 || (code >= 8192 && code <= 8205) || (code >= 8208 && code <= 8223) || (code >= 8242 && code <= 8247)
193    total += isLight ? 1 : 2
194  }
195
196  return total
197}
198
199export function postUrl(post: Post): string {
200  return `https://x.com/${post.username}/status/${post.targetId ?? post.id}`
201}
202
203export const USER_FIELDS = 'name,username,verified,verified_type'
204
205// A post's text in pieces: plain words, and the links X draws in blue.
206export type Segment = { kind: 'text' | 'url' | 'mention' | 'hashtag'; text: string; href?: string }
207
208export function segments(text: string, links?: Post['links']): Segment[] {
209  const out: Segment[] = []
210  const pattern = /(https?:\/\/\S+)|(@[A-Za-z0-9_]{1,15})|([##][\p{L}\p{N}_]+)/gu
211  let last = 0
212  for (const match of text.matchAll(pattern)) {
213    const at = match.index ?? 0
214    if (at > last) out.push({ kind: 'text', text: text.slice(last, at) })
215    const [whole, url, mention] = match
216    // A t.co link reads as X draws it: the target's short form, linked whole.
217    if (url) out.push({ kind: 'url', text: links?.[whole]?.display ?? whole, href: links?.[whole]?.href ?? whole })
218    else if (mention) out.push({ kind: 'mention', text: whole, href: `https://x.com/${whole.slice(1)}` })
219    else out.push({ kind: 'hashtag', text: whole, href: `https://x.com/hashtag/${encodeURIComponent(whole.slice(1))}` })
220    last = at + whole.length
221  }
222  if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
223
224  return out
225}
226
227function escapeMarkdown(text: string): string {
228  return text.replace(/([\\`*_[\]<>|~])/g, '\\$1').replace(/^(\s*)([#>+-]|\d+\.)(\s)/gm, '$1\\$2$3')
229}
230
231export function markdownBody(text: string, links?: Post['links']): string {
232  return segments(text, links)
233    .map(part =>
234      part.kind === 'text'
235        ? escapeMarkdown(part.text)
236        : part.kind === 'url' && part.text === part.href
237          ? part.text
238          : `[${escapeMarkdown(part.text)}](${part.href})`,
239    )
240    .join('')
241    .split('\n')
242    .join('  \n')
243}
244
245// X's Web Intents: one click opens X's own reply, repost or like screen
246// for the post, signed in as the person, with no API call of ours.
247export function intentUrl(kind: 'reply' | 'repost' | 'like', post: Post): string {
248  const id = post.targetId ?? post.id
249  if (kind === 'reply') return `https://x.com/intent/post?in_reply_to=${id}`
250
251  return `https://x.com/intent/${kind === 'repost' ? 'retweet' : 'like'}?tweet_id=${id}`
252}
253
254export function metricsLine(post: Post): string {
255  // A repost's own counts are the wrapper's, not the original's: none shown.
256  const n = (value: number | undefined) => (post.hasNoCounts ? '' : ` ${compactCount(value ?? 0)}`)
257  const parts = [
258    `[💬${n(post.replies)}](${intentUrl('reply', post)})`,
259    `[🔁${n(post.reposts)}](${intentUrl('repost', post)})`,
260    `[♡${n(post.likes)}](${intentUrl('like', post)})`,
261  ]
262  if (post.views !== undefined && !post.hasNoCounts) parts.push(`📊 ${compactCount(post.views)}`)
263
264  return parts.join('  ')
265}
266
267export function imagesMarkdown(post: Post): string {
268  return (post.images ?? [])
269    .slice(0, 4)
270    .map(image => `[![${escapeMarkdown(image.alt || (image.kind === 'video' ? '動画' : '画像'))}](${mediaVariant(image.url, 'jpg', 'small')})](${postUrl(post)})`)
271    .join(' ')
272}
273
274export function clip(text: string, length: number): string {
275  const chars = [...text]
276
277  return chars.length > length ? `${chars.slice(0, length).join('')}…` : text
278}
279
280export function postBadges(post: Post): string {
281  const images = post.images ?? []
282  const videos = images.filter(image => image.kind === 'video').length
283  // Media drawn as pictures needs no badge but a video's; media X sent no
284  // picture for keeps the count.
285  const media = images.length > 0 ? (videos > 0 ? `▶ 動画${videos > 1 ? ` ${videos}本` : ''}` : '') : post.media ? `🖼 ${post.media === 1 ? '画像' : `画像 ${post.media}枚`}` : ''
286
287  return [media, post.isQuote && !post.quote ? '❝ 引用ポスト' : '']
288    .filter(Boolean)
289    .join(' ')
290}
291
292export function postText(post: Post, now: number, index?: number): string {
293  const number = index === undefined ? '' : `\`${index}\` `
294  const replying = post.replyingTo?.length ? [`<sub>返信先: ${post.replyingTo.map(name => `[@${escapeMarkdown(name)}](https://x.com/${name})`).join(' ')}</sub>`] : []
295  const badges = postBadges(post)
296  const head = [
297    ...(post.repostedBy ? [`🔁 _${escapeMarkdown(post.repostedBy)}さんがリポストしました_`] : []),
298    `${number}**${escapeMarkdown(post.name)}**${post.isVerified ? ' ☑︎' : ''} @${escapeMarkdown(post.username)} · ${relativeTime(post.createdAt, now)}`,
299    ...replying,
300    markdownBody(post.text, post.links),
301    ...(post.images?.length ? [imagesMarkdown(post)] : []),
302    ...(badges ? [`[${badges}](${postUrl(post)})`] : []),
303  ]
304  const quote = post.quote
305    ? `\n\n> **${escapeMarkdown(post.quote.name)}**${post.quote.isVerified ? ' ☑︎' : ''} @${escapeMarkdown(post.quote.username)}  \n> ${markdownBody(clip(post.quote.text, 160), post.quote.links).split('  \n').join('  \n> ')}`
306    : ''
307
308  return `${head.join('  \n')}${quote}\n\n${metricsLine(post)}  [↗ 開く](${postUrl(post)})`
309}
310
311function decodeEntities(text: string): string {
312  return text
313    .replace(/&lt;/g, '<')
314    .replace(/&gt;/g, '>')
315    .replace(/&quot;/g, '"')
316    .replace(/&#39;/g, "'")
317    .replace(/&amp;/g, '&')
318}
319
320type TimelineItem = {
321  id: string
322  text: string
323  created_at?: string
324  author_id?: string
325  in_reply_to_user_id?: string
326  conversation_id?: string
327  note_tweet?: { text?: string; entities?: { urls?: UrlEntity[] } }
328  public_metrics?: { like_count?: number; retweet_count?: number; reply_count?: number; impression_count?: number }
329  referenced_tweets?: Array<{ type: string; id: string }>
330  attachments?: { media_keys?: string[] }
331  entities?: { urls?: UrlEntity[] }
332}
333
334type UrlEntity = { url?: string; expanded_url?: string; display_url?: string }
335
336const LEADING_MENTIONS = /^((?:@[A-Za-z0-9_]{1,15}\s+)+)/
337
338// Linked t.co URLs to what X shows for them; media links are dropped, as X
339// draws the media itself rather than its link.
340function linksOf(item: TimelineItem): { links: Post['links']; mediaUrls: Set<string> } {
341  const links: NonNullable<Post['links']> = {}
342  const mediaUrls = new Set<string>()
343  // A long post's links are on its note, the rest on the post itself.
344  for (const entity of [...(item.entities?.urls ?? []), ...(item.note_tweet?.entities?.urls ?? [])]) {
345    if (!entity.url) continue
346    const href = entity.expanded_url ?? entity.url
347    if (/^https:\/\/(x|twitter)\.com\/[^/]+\/status\/\d+\/(photo|video)\//.test(href)) mediaUrls.add(entity.url)
348    else links[entity.url] = { display: entity.display_url ?? href, href }
349  }
350
351  return { links, mediaUrls }
352}
353
354type TimelineUser = { id: string; username: string; name: string; verified?: boolean; verified_type?: string }
355
356type TimelineMedia = { media_key: string; type: string; url?: string; preview_image_url?: string; alt_text?: string; width?: number; height?: number }
357
358type TimelineBody = {
359  data?: TimelineItem[]
360  includes?: { users?: TimelineUser[]; tweets?: TimelineItem[]; media?: TimelineMedia[] }
361}
362
363// Only X's own image host is fetched or shown.
364export function isMediaUrl(url: string): boolean {
365  return /^https:\/\/pbs\.twimg\.com\/[A-Za-z0-9_./-]+$/.test(url.split('?')[0] ?? '')
366}
367
368function imagesOf(item: TimelineItem, media: Map<string, TimelineMedia>): NonNullable<Post['images']> {
369  return (item.attachments?.media_keys ?? []).flatMap(key => {
370    const found = media.get(key)
371    const url = found?.url ?? found?.preview_image_url
372    if (!found || !url || !isMediaUrl(url)) return []
373
374    return [{ key, url, alt: found.alt_text ?? '', kind: found.type === 'photo' ? ('photo' as const) : ('video' as const), width: found.width ?? 0, height: found.height ?? 0 }]
375  })
376}
377
378// A smaller rendition from X's image host, as a PNG for terminals.
379export function mediaVariant(url: string, format: 'png' | 'jpg', size: 'small' | 'medium'): string {
380  const base = url.split('?')[0] ?? url
381  const stem = base.replace(/\.(jpg|jpeg|png|webp)$/i, '')
382
383  return `${stem}?format=${format}&name=${size}`
384}
385
386const REPOST_PREFIX = /^RT @([A-Za-z0-9_]{1,15}): /
387export const EXPANSIONS = 'author_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
388export const MEDIA_FIELDS = 'type,url,preview_image_url,alt_text,width,height'
389
390function isVerified(user: TimelineUser | undefined): boolean {
391  return Boolean(user?.verified || (user?.verified_type && user.verified_type !== 'none'))
392}
393
394// One post as X draws it, without what wraps it (a repost) or what it
395// quotes; `quotedId` drops the link to the quoted post from the text.
396function toPost(item: TimelineItem, users: Map<string, TimelineUser>, quotedId?: string, media: Map<string, TimelineMedia> = new Map()): Post {
397  const user = item.author_id ? users.get(item.author_id) : undefined
398  const { links, mediaUrls } = linksOf(item)
399  let text = decodeEntities(item.note_tweet?.text ?? item.text)
400  // A media link goes with the space before it, so no gap is left.
401  const drop = (url: string) => {
402    text = text.includes(` ${url}`) ? text.replace(` ${url}`, '') : text.replace(url, '')
403  }
404  for (const url of mediaUrls) drop(url)
405  if (quotedId) {
406    for (const [url, link] of Object.entries(links ?? {})) {
407      if (link.href.includes(`/status/${quotedId}`)) drop(url)
408    }
409  }
410  text = text.trim()
411  // A reply's leading mentions are drawn as "返信先" rather than as text.
412  const leading = item.in_reply_to_user_id ? LEADING_MENTIONS.exec(text) : null
413  const replyingTo = leading?.[1] ? leading[1].trim().split(/\s+/).map(name => name.slice(1)) : undefined
414  if (leading?.[1] && text.length > leading[1].length) text = text.slice(leading[1].length)
415  const metrics = item.public_metrics ?? {}
416
417  return {
418    id: item.id,
419    text,
420    createdAt: item.created_at ?? '',
421    username: user?.username ?? 'i',
422    name: user?.name ?? '(不明)',
423    likes: metrics.like_count ?? 0,
424    reposts: metrics.retweet_count ?? 0,
425    replies: metrics.reply_count ?? 0,
426    views: metrics.impression_count,
427    isVerified: isVerified(user),
428    isReply: Boolean(item.in_reply_to_user_id),
429    replyingTo,
430    links,
431    media: item.attachments?.media_keys?.length ?? 0,
432    isQuote: item.referenced_tweets?.some(ref => ref.type === 'quoted') ?? false,
433    conversationId: item.conversation_id,
434    images: imagesOf(item, media),
435  }
436}
437
438function withQuote(item: TimelineItem, users: Map<string, TimelineUser>, tweets: Map<string, TimelineItem>, media: Map<string, TimelineMedia>): Post {
439  const quotedId = item.referenced_tweets?.find(ref => ref.type === 'quoted')?.id
440  const quoted = quotedId ? tweets.get(quotedId) : undefined
441  const post = toPost(item, users, quoted ? quotedId : undefined, media)
442  if (!quoted) return post
443  const inner = toPost(quoted, users, undefined, media)
444
445  return { ...post, quote: { id: inner.id, name: inner.name, username: inner.username, text: inner.text, isVerified: inner.isVerified, links: inner.links } }
446}
447
448export function parseTimeline(body: unknown): Post[] {
449  const value = body as TimelineBody
450  const users = new Map((value?.includes?.users ?? []).map(user => [user.id, user]))
451  const tweets = new Map((value?.includes?.tweets ?? []).map(tweet => [tweet.id, tweet]))
452  const media = new Map((value?.includes?.media ?? []).map(one => [one.media_key, one]))
453
454  return (value?.data ?? []).map(item => {
455    const wrapper = toPost(item, users, undefined, media)
456    const repostOf = item.referenced_tweets?.find(ref => ref.type === 'retweeted')?.id
457    const original = repostOf ? tweets.get(repostOf) : undefined
458    // A repost reads as its original, the reposter in the header; it keeps
459    // its own id (the timeline's order) and acts on the original's.
460    if (original) return { ...withQuote(original, users, tweets, media), id: item.id, targetId: original.id, repostedBy: wrapper.name }
461    const repost = repostOf ? REPOST_PREFIX.exec(wrapper.text) : null
462    if (repost?.[1]) {
463      return {
464        ...wrapper,
465        username: repost[1],
466        name: repost[1],
467        text: wrapper.text.slice(repost[0].length),
468        targetId: repostOf,
469        repostedBy: wrapper.name,
470        isReply: false,
471        isVerified: false,
472        views: undefined,
473        hasNoCounts: true,
474      }
475    }
476
477    return withQuote(item, users, tweets, media)
478  })
479}
480
481export function targetOf(post: Post): string {
482  return post.targetId ?? post.id
483}
484
485export function mergePosts(fresh: Post[], current: Post[], limit: number): Post[] {
486  // One row per post: a second repost of it, or the post and a repost of
487  // it, keep the newest only.
488  const seen = new Set<string>()
489  const out: Post[] = []
490  for (const post of [...fresh, ...current]) {
491    if (seen.has(post.id) || seen.has(targetOf(post))) continue
492    seen.add(post.id)
493    seen.add(targetOf(post))
494    out.push(post)
495  }
496
497  return out.slice(0, limit)
498}
499
500export function clampResults(value: unknown): number {
501  const n = Math.round(Number(value))
502
503  return Number.isFinite(n) ? Math.min(100, Math.max(5, n)) : 20
504}
505
506export function refreshIntervalMs(value: unknown): number {
507  const minutes = Number(value)
508  if (!Number.isFinite(minutes) || minutes <= 0) return 0
509
510  return Math.max(5, minutes) * 60_000
511}
512
513function clock(ms: number): string {
514  const date = new Date(ms)
515
516  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
517}
518
519export function relativeTime(iso: string, now: number): string {
520  const then = Date.parse(iso)
521  if (!Number.isFinite(then)) return ''
522  const seconds = Math.max(0, Math.round((now - then) / 1000))
523  if (seconds < 60) return `${seconds}秒`
524  if (seconds < 3600) return `${Math.floor(seconds / 60)}分`
525  if (seconds < 86_400) return `${Math.floor(seconds / 3600)}時間`
526  if (seconds < 7 * 86_400) return `${Math.floor(seconds / 86_400)}日`
527  const date = new Date(then)
528
529  return `${date.getMonth() + 1}月${date.getDate()}日`
530}
531
532export function compactCount(n: number): string {
533  if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1).replace(/\.0$/, '')}億`
534  if (n >= 10_000) return `${(n / 10_000).toFixed(1).replace(/\.0$/, '')}万`
535
536  return n.toLocaleString('en-US')
537}
538
539export function describeApiError(res: HttpLike): string {
540  let detail = ''
541  try {
542    const body = JSON.parse(res.text)
543    detail = String(body?.detail ?? body?.error_description ?? body?.errors?.[0]?.message ?? body?.title ?? '')
544  } catch {
545    // not JSON
546  }
547  detail = detail.slice(0, 300)
548  const suffix = detail ? `(${detail})` : ''
549
550  if (res.status === 429) {
551    const reset = Number(res.headers['x-rate-limit-reset'])
552
553    return Number.isFinite(reset) && reset > 0
554      ? `レート制限中です。${clock(reset * 1000)}以降に再試行してください`
555      : 'レート制限中です。しばらく待って再試行してください'
556  }
557  if (res.status === 402) return `X APIのクレジットが不足しています。Developer Consoleを確認してください${suffix}`
558  if (res.status === 403) return `X APIへのアクセスが拒否されました。アプリの権限とプランを確認してください${suffix}`
559
560  return `X APIエラー ${res.status}${suffix}`
561}
562
563// One-shot loopback listener for the OAuth redirect: binds 127.0.0.1 only,
564// answers the first /callback request, prints its query string and exits.
565export const CALLBACK_SERVER = [
566  'import sys, time, http.server, urllib.parse',
567  'port = int(sys.argv[1])',
568  'result = []',
569  'class H(http.server.BaseHTTPRequestHandler):',
570  '    def do_GET(self):',
571  '        u = urllib.parse.urlparse(self.path)',
572  "        if u.path != '/callback':",
573  '            self.send_response(404); self.end_headers(); return',
574  '        result.append(u.query)',
575  "        body = '<!doctype html><meta charset=\"utf-8\"><title>X</title><p>Claudeに戻ってください。このタブは閉じて構いません。</p>'.encode('utf-8')",
576  '        self.send_response(200)',
577  "        self.send_header('Content-Type', 'text/html; charset=utf-8')",
578  "        self.send_header('Content-Length', str(len(body)))",
579  '        self.end_headers()',
580  '        self.wfile.write(body)',
581  '    def log_message(self, *args):',
582  '        pass',
583  "server = http.server.HTTPServer(('127.0.0.1', port), H)",
584  'server.timeout = 5',
585  'deadline = time.time() + 300',
586  'while not result and time.time() < deadline:',
587  '    server.handle_request()',
588  'server.server_close()',
589  'if not result:',
590  '    sys.exit(2)',
591  'sys.stdout.write(result[0])',
592].join('\n')
593
594// A hashtag as people type it (`#猫`, `#cat`, `cat`), without its mark.
595export function normalizeTag(raw: string): string | null {
596  const tag = raw.trim().replace(/^[##]+/, '')
597
598  return /^[\p{L}\p{N}_]{1,100}$/u.test(tag) ? tag : null
599}
600
601export function tagQuery(tags: readonly string[]): string {
602  const terms = tags.map(tag => `#${tag}`)
603
604  return `${terms.length > 1 ? `(${terms.join(' OR ')})` : (terms[0] ?? '')} -is:retweet`
605}
606
607export const PROFILE_FIELDS = 'created_at,description,location,public_metrics,url,verified,verified_type,entities'
608
609type ProfileBody = {
610  data?: {
611    id: string
612    name: string
613    username: string
614    description?: string
615    location?: string
616    url?: string
617    created_at?: string
618    verified?: boolean
619    verified_type?: string
620    public_metrics?: { followers_count?: number; following_count?: number; tweet_count?: number }
621    entities?: { url?: { urls?: UrlEntity[] }; description?: { urls?: UrlEntity[] } }
622  }
623}
624
625export function parseProfile(body: unknown): Profile | null {
626  const user = (body as ProfileBody)?.data
627  if (!user?.id) return null
628  const expand = (text: string, urls: UrlEntity[] | undefined) =>
629    (urls ?? []).reduce((out, entity) => (entity.url ? out.replace(entity.url, entity.display_url ?? entity.expanded_url ?? entity.url) : out), text)
630  const metrics = user.public_metrics ?? {}
631
632  return {
633    id: user.id,
634    name: user.name,
635    username: user.username,
636    description: decodeEntities(expand(user.description ?? '', user.entities?.description?.urls)),
637    location: user.location || null,
638    url: user.url ? (user.entities?.url?.urls?.[0]?.expanded_url ?? user.url) : null,
639    followers: metrics.followers_count ?? 0,
640    following: metrics.following_count ?? 0,
641    posts: metrics.tweet_count ?? 0,
642    joined: user.created_at ?? null,
643    isVerified: Boolean(user.verified || (user.verified_type && user.verified_type !== 'none')),
644  }
645}
646
647export function joinedText(iso: string | null): string | null {
648  const date = iso ? new Date(Date.parse(iso)) : null
649
650  return date && Number.isFinite(date.getTime()) ? `${date.getFullYear()}年${date.getMonth() + 1}月から利用` : null
651}
652
653export function profileText(profile: Profile): string {
654  const facts = [profile.location ? `📍 ${escapeMarkdown(profile.location)}` : '', profile.url ? `🔗 ${profile.url}` : '', joinedText(profile.joined) ? `📅 ${joinedText(profile.joined)}` : '']
655    .filter(Boolean)
656    .join(' ')
657
658  return [
659    `**${escapeMarkdown(profile.name)}**${profile.isVerified ? ' ☑︎' : ''} [@${escapeMarkdown(profile.username)}](https://x.com/${profile.username})`,
660    ...(profile.description ? [markdownBody(profile.description)] : []),
661    ...(facts ? [facts] : []),
662    `**${compactCount(profile.following)}** フォロー中 **${compactCount(profile.followers)}** フォロワー **${compactCount(profile.posts)}** ポスト`,
663  ].join('  \n')
664}
665
666// A single post's body (`/2/tweets/:id`) read as a one-post timeline.
667export function parseSingle(body: unknown): Post[] {
668  const value = body as { data?: TimelineItem; includes?: TimelineBody['includes'] }
669
670  return value?.data ? parseTimeline({ data: [value.data], includes: value.includes }) : []
671}
672
673type ListBody = { data?: Array<{ id: string; name: string; member_count?: number; private?: boolean; description?: string }> }
674
675export function parseLists(body: unknown): ListInfo[] {
676  return ((body as ListBody)?.data ?? []).map(list => ({
677    id: list.id,
678    name: list.name,
679    members: list.member_count ?? null,
680    isPrivate: Boolean(list.private),
681    description: list.description ?? '',
682  }))
683}
684
685// A count as X writes it for people: `12,345`, `1.2万`, `12.3K`, `1.5M posts`.
686export function parseCount(text: string): number | null {
687  const match = /([\d.,]+)\s*(万|億|K|M|B)?/i.exec(text)
688  if (!match?.[1]) return null
689  const base = Number(match[1].replace(/,/g, ''))
690  if (!Number.isFinite(base)) return null
691  const unit = (match[2] ?? '').toUpperCase()
692  const scale = unit === '万' ? 1e4 : unit === '億' ? 1e8 : unit === 'K' ? 1e3 : unit === 'M' ? 1e6 : unit === 'B' ? 1e9 : 1
693
694  return Math.round(base * scale)
695}
696
697type TrendBody = { data?: Array<{ trend_name?: string; post_count?: number | string; category?: string; trending_since?: string }> }
698
699export function parseTrends(body: unknown): TrendInfo[] {
700  return ((body as TrendBody)?.data ?? []).flatMap(trend => {
701    if (!trend.trend_name) return []
702    const count = typeof trend.post_count === 'number' ? trend.post_count : parseCount(String(trend.post_count ?? ''))
703
704    return [{ name: trend.trend_name, posts: count, category: trend.category ?? null }]
705  })
706}
707
708// `/x p 本文 --image ~/a.png --image b.jpg`: the text, and the pictures to attach.
709export function parsePostArgs(raw: string): { text: string; images: string[] } {
710  const images: string[] = []
711  const text = raw
712    .replace(/(?:^|\s)--image\s+("([^"]+)"|(\S+))/g, (_all, _whole, quoted: string | undefined, bare: string | undefined) => {
713      images.push(quoted ?? bare ?? '')
714
715      return ''
716    })
717    .trim()
718
719  return { text, images: images.filter(Boolean) }
720}
721
722export const IMAGE_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
723export const IMAGE_LIMIT = 5 * 1024 * 1024
724
725// A picture path curl's form syntax reads whole: no `;` `,` `"` or newline.
726export function imagePathProblem(path: string): string | null {
727  if (!path.startsWith('/')) return 'フルパスで指定してください'
728  if (/[;,"\n\r]/.test(path)) return 'パスに ; , " は使えません'
729  const ext = path.split('.').pop()?.toLowerCase() ?? ''
730
731  return IMAGE_TYPES[ext] ? null : '対応する画像は png / jpg / gif / webp です'
732}
733
types/index.d.ts 107 lines
1export type Post = {
2  id: string
3  text: string
4  createdAt: string
5  username: string
6  name: string
7  likes?: number
8  reposts?: number
9  replies?: number
10  isReply?: boolean
11  repostedBy?: string
12  replyingTo?: string[]
13  links?: Record<string, { display: string; href: string }>
14  media?: number
15  isQuote?: boolean
16  targetId?: string
17  conversationId?: string
18  images?: Array<{ key: string; url: string; alt: string; kind: 'photo' | 'video'; width: number; height: number }>
19  hasNoCounts?: boolean
20  quote?: { id: string; name: string; username: string; text: string; isVerified?: boolean; links?: Record<string, { display: string; href: string }> }
21  views?: number
22  isVerified?: boolean
23}
24
25export type AuthStatus = 'unknown' | 'signed-out' | 'waiting' | 'signed-in'
26
27export type Mode = 'home' | 'mentions' | 'tags' | 'bookmarks' | 'view'
28
29export type PerMode<T> = { home: T; mentions: T; tags: T; bookmarks: T; view: T }
30
31export type ListInfo = { id: string; name: string; members: number | null; isPrivate: boolean; description: string }
32
33export type TrendInfo = { name: string; posts: number | null; category: string | null }
34
35export type Profile = {
36  id: string
37  name: string
38  username: string
39  description: string
40  location: string | null
41  url: string | null
42  followers: number
43  following: number
44  posts: number
45  joined: string | null
46  isVerified: boolean
47}
48
49// What the fourth list holds: one post's thread, or one person's posts.
50export type View =
51  | { kind: 'thread'; rootId: string; username: string }
52  | { kind: 'user'; profile: Profile }
53  | { kind: 'search'; query: string }
54  | { kind: 'list'; listId: string; name: string }
55  | { kind: 'likes'; username: string; userId: string }
56  | { kind: 'lists'; lists: ListInfo[] }
57  | { kind: 'trends'; trends: TrendInfo[] }
58
59export type Status = {
60  auth: AuthStatus
61  mode: Mode
62  isLoading: boolean
63  error: string | null
64  notice: string | null
65  fetchedAt: PerMode<number | null>
66  account: string | null
67  authorizeUrl: string | null
68  autoMinutes: number
69  canPost: boolean
70  canLike: boolean
71  newIds: string[]
72  unread: PerMode<number>
73  liked: string[]
74  reposted: string[]
75  bookmarked: string[]
76  followed: string[]
77  muted: string[]
78  deleteAsk: string | null
79  repostAsk: string | null
80  shown: PerMode<string[]>
81  view: View | null
82  cursor: number
83  liveTail: string | null
84  chatLimit: number
85  imagesReady: string[]
86  backTo: Mode
87  hasMore: PerMode<boolean>
88  tags: string[]
89  activeTag: string | null
90  qrFor: string | null
91}
92
93export type Draft = {
94  text: string
95  replyTo: { id: string; username: string } | null
96  quoteOf: { id: string; username: string; name: string; text: string } | null
97  images: string[]
98  isConfirming: boolean
99  isPosting: boolean
100}
101
102declare module 'claude-code' {
103  interface PluginState {
104    'x-timeline': { posts: Post[]; mentions: Post[]; tagPosts: Post[]; bookmarkPosts: Post[]; viewPosts: Post[]; status: Status; draft: Draft }
105  }
106}
107