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 +34 -4
- package/README.md +26 -15
- package/package.json +12 -11
- package/playwright.config.ts +5 -2
- package/src/cli.ts +118 -5
- package/src/components/AppNav.tsx +1 -1
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +9 -2
- package/src/lib/backup.ts +5 -3
- package/src/lib/bird-actions.ts +4 -0
- package/src/lib/bird.ts +143 -17
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +8 -0
- package/src/lib/mention-threads-live.ts +271 -0
- package/src/lib/mentions-live.ts +1 -1
- package/src/lib/moderation-target.ts +3 -1
- package/src/lib/openai.ts +1 -1
- package/src/lib/profile-hydration.ts +8 -0
- package/src/lib/profile-replies.ts +1 -1
- package/src/lib/queries.ts +135 -15
- package/src/lib/research.ts +596 -0
- package/src/lib/seed.ts +8 -2
- package/src/lib/timeline-collections-live.ts +16 -2
- package/src/lib/timeline-live.ts +175 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +29 -1
- package/src/lib/x-profile.ts +41 -12
- package/src/lib/xurl.ts +39 -5
- package/src/routes/blocks.tsx +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1
|
+
# birdclaw 🪶 — Local Twitter memory in SQLite: archives, DMs, likes, bookmarks
|
|
2
2
|
|
|
3
|
-
`birdclaw` is a local-first
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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](
|
|
501
|
+
Workflow: [ci.yml](.github/workflows/ci.yml)
|
|
491
502
|
|
|
492
503
|
## Docs
|
|
493
504
|
|
|
494
|
-
- [spec.md](
|
|
495
|
-
- [cli.md](
|
|
496
|
-
- [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.
|
|
4
|
-
"description": "Local
|
|
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.
|
|
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.
|
|
60
|
-
"@tanstack/router-plugin": "^1.167.
|
|
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.
|
|
65
|
-
"lucide-react": "^1.
|
|
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.
|
|
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.
|
|
85
|
-
"oxfmt": "^0.
|
|
86
|
-
"oxlint": "^1.
|
|
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
|
},
|
package/playwright.config.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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",
|
package/src/lib/bird-actions.ts
CHANGED
|
@@ -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
|
};
|