birdclaw 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,36 @@
1
1
  # CHANGELOG
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## Unreleased
4
+
5
+ ## 0.3.0 - 2026-05-05
6
+
7
+ ### Added
8
+
9
+ - Add research mode for turning bookmarked Twitter threads into Markdown briefs, with shared `xurl`/`bird` tweet lookup fallback for thread expansion. Thanks @anupamchugh.
10
+ - Add live home timeline and mention-thread sync commands so local triage can pull current `bird` context into the SQLite store.
11
+ - Add search snippets for tweet and DM results, including deterministic DM snippets when multiple messages in a conversation match. Thanks @mvanhorn.
12
+ - Add `--min-likes` and `--quality-reason` controls for tweet search quality filtering. Thanks @mvanhorn.
13
+ - Store Twitter following counts on profiles and include them in JSONL backups.
14
+
15
+ ### Changed
16
+
17
+ - Use the native TypeScript preview compiler for the `typecheck` script.
18
+ - Refresh TypeScript and related development dependencies.
19
+
20
+ ### Fixed
21
+
22
+ - Use the existing Twitter web cookie fallback as the final `auto` transport for block and unblock actions. Thanks @pejmanjohn.
23
+ - Resolve the `bird` transport from `PATH` before falling back to the local development checkout. Thanks @vyctorbrzezowski.
24
+ - Stabilize the presenter timestamp test across local time zones. Thanks @pejmanjohn.
25
+ - Clean up the DMs route render test so CI does not leave React work running after jsdom teardown.
26
+ - Allow Playwright e2e runs to use an alternate local port when `3000` is already occupied.
27
+ - Replace maintainer-local documentation links with repo-relative links and align the setup docs with the Node version file. Thanks @stainlu.
28
+
29
+ ## 0.2.1 - 2026-04-27
30
+
31
+ ### Changed
32
+
33
+ - Use Twitter wording in public descriptions, docs, CLI help, and release notes.
4
34
 
5
35
  ## 0.2.0 - 2026-04-27
6
36
 
@@ -13,7 +43,7 @@ All notable changes to this project will be documented in this file.
13
43
 
14
44
  ### Changed
15
45
 
16
- - Update the README tagline and package description for local X memory across archives, DMs, likes, bookmarks, and moderation.
46
+ - Update the README tagline and package description for local Twitter memory across archives, DMs, likes, bookmarks, and moderation.
17
47
  - Refresh dependencies, including `jsdom` 29.1.0.
18
48
  - Hide reply state and reply actions in saved likes/bookmarks web lanes.
19
49
  - Shard backup DMs by year and route unknown tweet dates to `data/tweets/unknown.jsonl` so Git backups stay compact and avoid bogus 1970 files.
@@ -21,7 +51,7 @@ All notable changes to this project will be documented in this file.
21
51
 
22
52
  ### Fixed
23
53
 
24
- - Fix live bookmark sync to use stored X user ids, force OAuth2 for `xurl` collection reads, and tolerate large/current `bird` bookmark payloads.
54
+ - Fix live bookmark sync to use stored Twitter user ids, force OAuth2 for `xurl` collection reads, and tolerate large/current `bird` bookmark payloads.
25
55
  - Fix fresh-machine backup sync so demo data is never exported into Git backups, and keep no-op syncs from creating metadata-only commits.
26
56
 
27
57
  ## 0.1.1 - 2026-04-27
@@ -38,7 +68,7 @@ All notable changes to this project will be documented in this file.
38
68
 
39
69
  ### Added
40
70
 
41
- - Add X web cookie fallback for block and unblock actions when the X API rejects OAuth2 block writes.
71
+ - Add Twitter web cookie fallback for block and unblock actions when the Twitter API rejects OAuth2 block writes.
42
72
  - Add `profiles replies` so moderation triage can inspect a user's recent reply pattern before blocking.
43
73
  - Add `blocks import <path>` for one-shot blocklist application from a file.
44
74
  - Add paged `mentions export --mode xurl --all --max-pages <n>` so moderation loops can scan the full retrievable mentions window.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
- # birdclaw 🪶 — Local X memory in SQLite: archives, DMs, likes, bookmarks
1
+ # birdclaw 🪶 — Local Twitter memory in SQLite: archives, DMs, likes, bookmarks
2
2
 
3
- `birdclaw` is a local-first X workspace: archive import, cached live reads, focused triage, and reply flows in one local web app + CLI. Built by [@steipete](https://github.com/steipete/).
3
+ `birdclaw` is a local-first Twitter workspace: archive import, cached live reads, focused triage, and reply flows in one local web app + CLI. Built by [@steipete](https://github.com/steipete/).
4
4
 
5
5
  Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and rough edges while the core settles.
6
6
 
7
7
  ## What It Does
8
8
 
9
- - keeps your X data in local SQLite
9
+ - keeps your Twitter data in local SQLite
10
10
  - stores media and avatar cache under `~/.birdclaw`
11
11
  - imports archives when you have them
12
12
  - still works when you do not
@@ -24,7 +24,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
24
24
  - archive import for bookmark exports when present
25
25
  - live likes and bookmarks sync through `xurl` or `bird`
26
26
  - Git-friendly text backups with yearly tweet shards and per-conversation DM shards
27
- - profile hydration from live X metadata
27
+ - profile hydration from live Twitter metadata
28
28
  - local avatar cache
29
29
  - local media cache root under `~/.birdclaw`
30
30
 
@@ -61,7 +61,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
61
61
  - import batch blocklists in one call
62
62
  - add / remove local mutes
63
63
  - sync remote blocks through `xurl` when available
64
- - fall back to the X web cookie session when OAuth2 block writes are rejected
64
+ - fall back to the Twitter web cookie session when OAuth2 block writes are rejected
65
65
 
66
66
  ### Safety
67
67
 
@@ -82,7 +82,7 @@ If you need polished product-grade sync parity today, this is not there yet.
82
82
 
83
83
  ## Screens
84
84
 
85
- - `Home`: read and reply without fighting the main X timeline
85
+ - `Home`: read and reply without fighting the main Twitter timeline
86
86
  - `Mentions`: work the reply queue with clean filters
87
87
  - `Likes` / `Bookmarks`: revisit saved posts from archive or live sync
88
88
  - `DMs`: triage by sender context, follower count, and influence
@@ -203,7 +203,7 @@ pnpm cli search tweets --liked --limit 20 --json
203
203
  pnpm cli search tweets --bookmarked --limit 20 --json
204
204
  ```
205
205
 
206
- ### Sync likes and bookmarks
206
+ ### Sync likes, bookmarks, and home timeline
207
207
 
208
208
  `auto` tries `xurl` first, then falls back to `bird`. Use `bird` directly when the API path is unavailable for the account/token you have locally.
209
209
 
@@ -211,6 +211,8 @@ pnpm cli search tweets --bookmarked --limit 20 --json
211
211
  pnpm cli sync likes --mode auto --limit 100 --refresh --json
212
212
  pnpm cli sync bookmarks --mode auto --limit 100 --refresh --json
213
213
  pnpm cli sync bookmarks --mode bird --all --max-pages 5 --limit 100 --refresh --json
214
+ pnpm cli sync timeline --limit 100 --refresh --json
215
+ pnpm cli sync mention-threads --limit 30 --delay-ms 1500 --timeout-ms 15000 --json
214
216
  ```
215
217
 
216
218
  ### Export mentions for agents
@@ -257,7 +259,16 @@ Notes:
257
259
  - `actions.transport` accepts `auto`, `bird`, or `xurl`
258
260
  - `bird` mode uses your local `bird` CLI and caches its mentions output into birdclaw's canonical store
259
261
  - filters still work in `xurl` mode; filtered payloads are rebuilt from the local canonical store after sync
260
- - `sync likes` and `sync bookmarks` store live results in the same local timeline table, so `search tweets --liked` and `search tweets --bookmarked` work across archive and live data
262
+ - `sync likes`, `sync bookmarks`, `sync timeline`, and `sync mention-threads` store live results in the same local timeline table, so `search tweets`, `search tweets --liked`, and `search tweets --bookmarked` work across archive and live data
263
+
264
+ ### Research bookmarks and threads
265
+
266
+ `birdclaw research` turns bookmarked tweets into a markdown brief with local thread expansion, live ancestor lookup when needed, and extracted links/handles:
267
+
268
+ ```bash
269
+ birdclaw research "codex" --limit 20 --thread-depth 10 --json
270
+ birdclaw research --account acct_primary --out ~/research/codex.md
271
+ ```
261
272
 
262
273
  ### Search and triage DMs
263
274
 
@@ -293,9 +304,9 @@ pnpm cli unban @amelia --account acct_primary --transport bird --json
293
304
  Notes:
294
305
 
295
306
  - `ban` / `unban` accept `--transport auto|bird|xurl`
296
- - `auto` tries `bird` first, then falls back to `xurl` when bird fails
307
+ - `auto` tries `bird` first, then falls back to `xurl`, then `x-web` cookie-backed block/unblock when both fail
297
308
  - forced `xurl` writes still verify through `bird status` before sqlite changes
298
- - X still rejects pure OAuth2 block writes, so `auto` is the safe default for block/unblock
309
+ - Twitter still rejects pure OAuth2 block writes, so `auto` is the safe default for block/unblock
299
310
  - `blocks import` accepts newline-delimited blocklists with comments and markdown bullets
300
311
  - `blocks sync` is for slow/manual remote reconciliation; not for a hot cron loop
301
312
  - `blocks record` stores a known-good remote block locally without issuing another live write
@@ -432,7 +443,7 @@ tail -n 1 ~/.birdclaw/audit/bookmarks-sync.jsonl | jq .
432
443
  ## Typical Workflow
433
444
 
434
445
  1. import your archive if you have one
435
- 2. hydrate imported profiles from live X metadata
446
+ 2. hydrate imported profiles from live Twitter metadata
436
447
  3. use `Home` for reading
437
448
  4. use `Mentions` for reply triage
438
449
  5. when one account feels borderline, inspect `profiles replies`
@@ -487,10 +498,10 @@ GitHub Actions runs:
487
498
  - `pnpm build`
488
499
  - `pnpm e2e`
489
500
 
490
- Workflow: [ci.yml](/Users/steipete/Projects/birdclaw/.github/workflows/ci.yml)
501
+ Workflow: [ci.yml](.github/workflows/ci.yml)
491
502
 
492
503
  ## Docs
493
504
 
494
- - [spec.md](/Users/steipete/Projects/birdclaw/docs/spec.md)
495
- - [cli.md](/Users/steipete/Projects/birdclaw/docs/cli.md)
496
- - [data-architecture.md](/Users/steipete/Projects/birdclaw/docs/data-architecture.md)
505
+ - [spec.md](docs/spec.md)
506
+ - [cli.md](docs/cli.md)
507
+ - [data-architecture.md](docs/data-architecture.md)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.2.0",
4
- "description": "Local X memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
3
+ "version": "0.3.0",
4
+ "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -53,22 +53,22 @@
53
53
  "@tailwindcss/vite": "^4.2.4",
54
54
  "@tanstack/devtools-vite": "0.6.0",
55
55
  "@tanstack/react-devtools": "0.10.2",
56
- "@tanstack/react-router": "1.168.24",
56
+ "@tanstack/react-router": "1.169.1",
57
57
  "@tanstack/react-router-devtools": "1.166.13",
58
58
  "@tanstack/react-router-ssr-query": "1.166.12",
59
- "@tanstack/react-start": "1.167.49",
60
- "@tanstack/router-plugin": "^1.167.27",
59
+ "@tanstack/react-start": "1.167.61",
60
+ "@tanstack/router-plugin": "^1.167.32",
61
61
  "@vitejs/plugin-react": "^6.0.1",
62
62
  "better-sqlite3": "^12.9.0",
63
63
  "commander": "^14.0.3",
64
- "kysely": "^0.28.16",
65
- "lucide-react": "^1.11.0",
64
+ "kysely": "^0.28.17",
65
+ "lucide-react": "^1.14.0",
66
66
  "react": "^19.2.5",
67
67
  "react-dom": "^19.2.5",
68
68
  "tailwindcss": "^4.2.4",
69
69
  "tsx": "^4.21.0",
70
70
  "vite": "^8.0.10",
71
- "zod": "^4.3.6"
71
+ "zod": "^4.4.2"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@playwright/test": "^1.59.1",
@@ -80,10 +80,11 @@
80
80
  "@types/node": "^25.6.0",
81
81
  "@types/react": "^19.2.14",
82
82
  "@types/react-dom": "^19.2.3",
83
+ "@typescript/native-preview": "7.0.0-dev.20260505.1",
83
84
  "@vitest/coverage-v8": "^4.1.5",
84
- "jsdom": "^29.1.0",
85
- "oxfmt": "^0.46.0",
86
- "oxlint": "^1.61.0",
85
+ "jsdom": "^29.1.1",
86
+ "oxfmt": "^0.47.0",
87
+ "oxlint": "^1.62.0",
87
88
  "typescript": "^6.0.3",
88
89
  "vitest": "^4.1.5"
89
90
  },
@@ -2,6 +2,8 @@ import path from "node:path";
2
2
  import { defineConfig, devices } from "@playwright/test";
3
3
 
4
4
  const testHome = path.join(process.cwd(), ".playwright-home");
5
+ const port = process.env.BIRDCLAW_PLAYWRIGHT_PORT ?? "3000";
6
+ const baseURL = `http://127.0.0.1:${port}`;
5
7
 
6
8
  export default defineConfig({
7
9
  testDir: "./playwright",
@@ -9,7 +11,7 @@ export default defineConfig({
9
11
  retries: 0,
10
12
  workers: 1,
11
13
  use: {
12
- baseURL: "http://127.0.0.1:3000",
14
+ baseURL,
13
15
  trace: "on-first-retry",
14
16
  },
15
17
  projects: [
@@ -20,10 +22,11 @@ export default defineConfig({
20
22
  ],
21
23
  webServer: {
22
24
  command: "node ./scripts/start-test-server.mjs",
23
- url: "http://127.0.0.1:3000",
25
+ url: baseURL,
24
26
  reuseExistingServer: false,
25
27
  timeout: 120000,
26
28
  env: {
29
+ BIRDCLAW_PLAYWRIGHT_PORT: port,
27
30
  BIRDCLAW_HOME: testHome,
28
31
  BIRDCLAW_DISABLE_LIVE_WRITES: "1",
29
32
  },
package/src/cli.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from "#/lib/config";
30
30
  import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
31
31
  import { listInboxItems, scoreInbox } from "#/lib/inbox";
32
+ import { syncMentionThreads } from "#/lib/mention-threads-live";
32
33
  import { exportMentionItems } from "#/lib/mentions-export";
33
34
  import {
34
35
  exportMentionsViaCachedBird,
@@ -36,6 +37,7 @@ import {
36
37
  } from "#/lib/mentions-live";
37
38
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
38
39
  import { inspectProfileReplies } from "#/lib/profile-replies";
40
+ import { runResearchMode } from "#/lib/research";
39
41
  import {
40
42
  createDmReply,
41
43
  createPost,
@@ -48,6 +50,7 @@ import {
48
50
  syncTimelineCollection,
49
51
  type TimelineCollectionMode,
50
52
  } from "#/lib/timeline-collections-live";
53
+ import { syncHomeTimeline } from "#/lib/timeline-live";
51
54
 
52
55
  const program = new Command();
53
56
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -63,6 +66,35 @@ function print(data: unknown, asJson: boolean) {
63
66
  console.log(data);
64
67
  }
65
68
 
69
+ function printError(error: string) {
70
+ console.error(JSON.stringify({ error }));
71
+ }
72
+
73
+ function parseNonNegativeIntegerOption(
74
+ value: string | undefined,
75
+ option: string,
76
+ ) {
77
+ if (value === undefined) {
78
+ return undefined;
79
+ }
80
+
81
+ const trimmed = value.trim();
82
+ if (!/^\d+$/.test(trimmed)) {
83
+ printError(`${option} must be a non-negative integer`);
84
+ process.exitCode = 1;
85
+ return undefined;
86
+ }
87
+
88
+ const parsed = Number.parseInt(trimmed, 10);
89
+ if (!Number.isSafeInteger(parsed)) {
90
+ printError(`${option} must be a non-negative integer`);
91
+ process.exitCode = 1;
92
+ return undefined;
93
+ }
94
+
95
+ return parsed;
96
+ }
97
+
66
98
  function resolveActionOptions(options: { transport?: string }) {
67
99
  return {
68
100
  transport: options.transport as ActionsTransport | undefined,
@@ -85,7 +117,7 @@ async function autoSyncAfterWrite() {
85
117
 
86
118
  program
87
119
  .name("birdclaw")
88
- .description("Local-first X workspace")
120
+ .description("Local-first Twitter workspace")
89
121
  .version(packageVersion.version ?? "0.0.0")
90
122
  .option("--json", "Emit JSON output");
91
123
 
@@ -118,7 +150,7 @@ program
118
150
 
119
151
  program
120
152
  .command("archive find")
121
- .description("Find likely X/Twitter archives on disk")
153
+ .description("Find likely Twitter archives on disk")
122
154
  .action(async () => {
123
155
  const items = await findArchives();
124
156
  print(items, program.opts().json ?? false);
@@ -130,7 +162,7 @@ const importCommand = program
130
162
 
131
163
  importCommand
132
164
  .command("archive [archivePath]")
133
- .description("Import an X/Twitter archive into the local SQLite store")
165
+ .description("Import a Twitter archive into the local SQLite store")
134
166
  .action(async (archivePath) => {
135
167
  let resolvedArchivePath = archivePath;
136
168
  if (!resolvedArchivePath) {
@@ -151,7 +183,7 @@ importCommand
151
183
 
152
184
  importCommand
153
185
  .command("hydrate-profiles")
154
- .description("Backfill archive-imported profiles from live X metadata")
186
+ .description("Backfill archive-imported profiles from live Twitter metadata")
155
187
  .action(async () => {
156
188
  const result = await hydrateProfilesFromX();
157
189
  await autoSyncAfterWrite();
@@ -171,10 +203,23 @@ searchCommand
171
203
  .option("--until <date>", "Include tweets created before this date")
172
204
  .option("--originals-only", "Exclude authored replies that start with @")
173
205
  .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
206
+ .option(
207
+ "--min-likes <n>",
208
+ "Override the low-quality like threshold (default 50)",
209
+ )
210
+ .option("--quality-reason", "Include qualityReason on each row")
174
211
  .option("--liked", "Only liked tweets")
175
212
  .option("--bookmarked", "Only bookmarked tweets")
176
213
  .option("--limit <n>", "Limit results", "20")
177
214
  .action(async (query, options) => {
215
+ const minLikes = parseNonNegativeIntegerOption(
216
+ options.minLikes,
217
+ "--min-likes",
218
+ );
219
+ if (options.minLikes !== undefined && minLikes === undefined) {
220
+ return;
221
+ }
222
+
178
223
  await autoUpdateBeforeRead();
179
224
  const replyFilter = options.replied
180
225
  ? "replied"
@@ -189,6 +234,8 @@ searchCommand
189
234
  until: options.until,
190
235
  includeReplies: !options.originalsOnly,
191
236
  qualityFilter: options.hideLowQuality ? "summary" : "all",
237
+ lowQualityThreshold: minLikes,
238
+ includeQualityReason: Boolean(options.qualityReason),
192
239
  likedOnly: Boolean(options.liked),
193
240
  bookmarkedOnly: Boolean(options.bookmarked),
194
241
  limit: Number(options.limit),
@@ -236,6 +283,28 @@ searchCommand
236
283
  print(items, program.opts().json ?? false);
237
284
  });
238
285
 
286
+ program
287
+ .command("research [query]")
288
+ .description("Build a markdown research brief from bookmarked threads")
289
+ .option("--account <accountId>", "Account id")
290
+ .option("--limit <n>", "Seed bookmark limit", "20")
291
+ .option("--thread-depth <n>", "Maximum ancestor walk depth", "10")
292
+ .option("--out <path>", "Write the markdown brief to a file")
293
+ .action(async (query, options) => {
294
+ await autoUpdateBeforeRead();
295
+ const report = await runResearchMode({
296
+ account: options.account,
297
+ query,
298
+ limit: Number(options.limit),
299
+ maxThreadDepth: Number(options.threadDepth),
300
+ outPath: options.out,
301
+ });
302
+ print(
303
+ program.opts().json ? report : report.markdown,
304
+ program.opts().json ?? false,
305
+ );
306
+ });
307
+
239
308
  const mentionsCommand = program
240
309
  .command("mentions")
241
310
  .description("Export local mention tweets for scripts and agents");
@@ -323,7 +392,51 @@ const dmsCommand = program.command("dms").description("Direct messages");
323
392
 
324
393
  const syncCommand = program
325
394
  .command("sync")
326
- .description("Refresh live X collections into the local store");
395
+ .description("Refresh live Twitter collections into the local store");
396
+
397
+ syncCommand
398
+ .command("timeline")
399
+ .description("Refresh live home timeline through bird")
400
+ .option("--account <accountId>", "Account id")
401
+ .option("--limit <n>", "Result limit", "100")
402
+ .option("--for-you", 'Fetch "For You" instead of chronological Following')
403
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
404
+ .option("--refresh", "Bypass live-cache freshness window")
405
+ .action(async (options) => {
406
+ const result = await syncHomeTimeline({
407
+ account: options.account,
408
+ limit: Number(options.limit),
409
+ following: !options.forYou,
410
+ refresh: Boolean(options.refresh),
411
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
412
+ });
413
+ await autoSyncAfterWrite();
414
+ print(result, true);
415
+ });
416
+
417
+ syncCommand
418
+ .command("mention-threads")
419
+ .description(
420
+ "Fetch tweet conversation context for recent mentions through bird",
421
+ )
422
+ .option("--account <accountId>", "Account id")
423
+ .option("--limit <n>", "Recent mentions to inspect", "30")
424
+ .option("--delay-ms <n>", "Delay between thread fetches", "1500")
425
+ .option("--timeout-ms <n>", "Per-thread timeout", "15000")
426
+ .option("--all", "Fetch all retrievable thread pages")
427
+ .option("--max-pages <n>", "Stop after N pages")
428
+ .action(async (options) => {
429
+ const result = await syncMentionThreads({
430
+ account: options.account,
431
+ limit: Number(options.limit),
432
+ delayMs: Number(options.delayMs),
433
+ timeoutMs: Number(options.timeoutMs),
434
+ all: Boolean(options.all),
435
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
436
+ });
437
+ await autoSyncAfterWrite();
438
+ print(result, true);
439
+ });
327
440
 
328
441
  for (const kind of ["likes", "bookmarks"] as const) {
329
442
  syncCommand
@@ -29,7 +29,7 @@ export function AppNav() {
29
29
  <nav className={navClass}>
30
30
  <div>
31
31
  <p className={eyebrowClass}>birdclaw</p>
32
- <h1 className={brandMarkClass}>Quiet signal for X.</h1>
32
+ <h1 className={brandMarkClass}>Quiet signal for Twitter.</h1>
33
33
  </div>
34
34
  <div className={navLinksClass}>
35
35
  {links.map((link) => {
@@ -9,7 +9,9 @@ import { type ActionsTransport, resolveActionsTransport } from "./config";
9
9
  import type {
10
10
  ModerationAction,
11
11
  ModerationActionTransportResult,
12
+ ModerationTransportKind,
12
13
  } from "./types";
14
+ import { blockUserViaXWeb, unblockUserViaXWeb } from "./x-web";
13
15
  import {
14
16
  blockUserViaXurl,
15
17
  lookupAuthenticatedUser,
@@ -27,7 +29,7 @@ interface RunActionParams {
27
29
  transport?: string;
28
30
  }
29
31
 
30
- function normalizeFailure(transport: "bird" | "xurl", output: string) {
32
+ function normalizeFailure(transport: ModerationTransportKind, output: string) {
31
33
  return `${transport}: ${output}`;
32
34
  }
33
35
 
@@ -133,6 +135,37 @@ async function runXurlAction(
133
135
  };
134
136
  }
135
137
 
138
+ async function runXWebAction(
139
+ action: ModerationAction,
140
+ targetUserId?: string,
141
+ ): Promise<ActionTransportResult> {
142
+ if (action !== "block" && action !== "unblock") {
143
+ return {
144
+ ok: false,
145
+ output: `x-web does not support ${action}`,
146
+ transport: "x-web",
147
+ };
148
+ }
149
+
150
+ if (!targetUserId) {
151
+ return {
152
+ ok: false,
153
+ output: "missing target user id for x-web transport",
154
+ transport: "x-web",
155
+ };
156
+ }
157
+
158
+ const result =
159
+ action === "block"
160
+ ? await blockUserViaXWeb(targetUserId)
161
+ : await unblockUserViaXWeb(targetUserId);
162
+
163
+ return {
164
+ ...result,
165
+ transport: "x-web",
166
+ };
167
+ }
168
+
136
169
  export async function runModerationAction({
137
170
  action,
138
171
  query,
@@ -160,6 +193,30 @@ export async function runModerationAction({
160
193
  };
161
194
  }
162
195
 
196
+ if (action === "block" || action === "unblock") {
197
+ const xWebResult = await runXWebAction(action, targetUserId);
198
+ if (xWebResult.ok) {
199
+ return {
200
+ ...xWebResult,
201
+ output: [
202
+ xWebResult.output,
203
+ `falling back after ${normalizeFailure("bird", birdResult.output)}`,
204
+ `falling back after ${normalizeFailure("xurl", xurlResult.output)}`,
205
+ ].join("\n"),
206
+ };
207
+ }
208
+
209
+ return {
210
+ ok: false,
211
+ output: [
212
+ normalizeFailure("bird", birdResult.output),
213
+ normalizeFailure("xurl", xurlResult.output),
214
+ normalizeFailure("x-web", xWebResult.output),
215
+ ].join("\n"),
216
+ transport: xWebResult.transport,
217
+ };
218
+ }
219
+
163
220
  return {
164
221
  ok: false,
165
222
  output: [
@@ -476,6 +476,7 @@ export async function importArchive(
476
476
  displayName: string;
477
477
  bio: string;
478
478
  followersCount: number;
479
+ followingCount: number;
479
480
  avatarHue: number;
480
481
  createdAt: string;
481
482
  }
@@ -500,6 +501,7 @@ export async function importArchive(
500
501
  displayName: accountPayload.displayName,
501
502
  bio: accountPayload.bio,
502
503
  followersCount: 0,
504
+ followingCount: 0,
503
505
  avatarHue: 18,
504
506
  createdAt: accountPayload.createdAt,
505
507
  };
@@ -583,6 +585,7 @@ export async function importArchive(
583
585
  conversationName || `Group DM ${externalParticipantIds.length}`,
584
586
  bio: `Group DM with ${externalParticipantIds.length} participants`,
585
587
  followersCount: 0,
588
+ followingCount: 0,
586
589
  avatarHue: 220,
587
590
  createdAt: accountPayload.createdAt,
588
591
  });
@@ -598,6 +601,7 @@ export async function importArchive(
598
601
  displayName: inferred.displayName,
599
602
  bio: `Imported from archive user ${otherUserId}`,
600
603
  followersCount: 0,
604
+ followingCount: 0,
601
605
  avatarHue: 210,
602
606
  createdAt: accountPayload.createdAt,
603
607
  });
@@ -626,6 +630,7 @@ export async function importArchive(
626
630
  displayName: inferred.displayName,
627
631
  bio: `Imported from archive user ${senderId}`,
628
632
  followersCount: 0,
633
+ followingCount: 0,
629
634
  avatarHue: 240,
630
635
  createdAt: accountPayload.createdAt,
631
636
  });
@@ -745,6 +750,7 @@ export async function importArchive(
745
750
  displayName: "Unknown",
746
751
  bio: "Imported from archive collection metadata",
747
752
  followersCount: 0,
753
+ followingCount: 0,
748
754
  avatarHue: 210,
749
755
  createdAt: accountPayload.createdAt,
750
756
  });
@@ -758,8 +764,8 @@ export async function importArchive(
758
764
  values (?, ?, ?, ?, ?, 1, ?)
759
765
  `);
760
766
  const insertProfile = db.prepare(`
761
- insert into profiles (id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at)
762
- values (?, ?, ?, ?, ?, ?, ?, ?)
767
+ insert into profiles (id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at)
768
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
763
769
  `);
764
770
  const insertTweet = db.prepare(`
765
771
  insert into tweets (
@@ -811,6 +817,7 @@ export async function importArchive(
811
817
  profile.displayName,
812
818
  profile.bio,
813
819
  profile.followersCount,
820
+ profile.followingCount,
814
821
  profile.avatarHue,
815
822
  null,
816
823
  profile.createdAt,
package/src/lib/backup.ts CHANGED
@@ -188,7 +188,7 @@ function getExportRowSets(db: Database.Database) {
188
188
  rows: rowsForQuery(
189
189
  db,
190
190
  `
191
- select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
191
+ select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
192
192
  from profiles
193
193
  order by id
194
194
  `,
@@ -1002,13 +1002,14 @@ export async function importBackup({
1002
1002
  db,
1003
1003
  `
1004
1004
  insert into profiles (
1005
- id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
1006
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
1005
+ id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
1006
+ ) values (?, ?, ?, ?, ?, coalesce(?, 0), ?, ?, ?)
1007
1007
  on conflict(id) do update set
1008
1008
  handle = coalesce(nullif(excluded.handle, ''), profiles.handle),
1009
1009
  display_name = coalesce(nullif(excluded.display_name, ''), profiles.display_name),
1010
1010
  bio = coalesce(nullif(excluded.bio, ''), profiles.bio),
1011
1011
  followers_count = max(profiles.followers_count, excluded.followers_count),
1012
+ following_count = max(profiles.following_count, excluded.following_count),
1012
1013
  avatar_hue = case when profiles.avatar_hue = 0 then excluded.avatar_hue else profiles.avatar_hue end,
1013
1014
  avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
1014
1015
  created_at = min(profiles.created_at, excluded.created_at)
@@ -1020,6 +1021,7 @@ export async function importBackup({
1020
1021
  "display_name",
1021
1022
  "bio",
1022
1023
  "followers_count",
1024
+ "following_count",
1023
1025
  "avatar_hue",
1024
1026
  "avatar_url",
1025
1027
  "created_at",
@@ -80,6 +80,7 @@ function toBirdLookupUser(payload: Record<string, unknown>): XurlMentionUser {
80
80
  }
81
81
 
82
82
  const followersCount = Number(user.followersCount ?? 0);
83
+ const followingCount = Number(user.followingCount);
83
84
  return {
84
85
  id,
85
86
  name:
@@ -95,6 +96,9 @@ function toBirdLookupUser(payload: Record<string, unknown>): XurlMentionUser {
95
96
  : undefined,
96
97
  public_metrics: {
97
98
  followers_count: Number.isFinite(followersCount) ? followersCount : 0,
99
+ ...(Number.isFinite(followingCount)
100
+ ? { following_count: followingCount }
101
+ : {}),
98
102
  },
99
103
  created_at: typeof user.createdAt === "string" ? user.createdAt : undefined,
100
104
  };