birdclaw 0.1.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/bin/birdclaw.mjs +28 -0
- package/package.json +100 -0
- package/playwright.config.ts +31 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/src/cli-moderation.ts +210 -0
- package/src/cli.ts +450 -0
- package/src/components/AppNav.tsx +49 -0
- package/src/components/AvatarChip.tsx +47 -0
- package/src/components/DmWorkspace.tsx +246 -0
- package/src/components/EmbeddedTweetCard.tsx +44 -0
- package/src/components/InboxCard.tsx +136 -0
- package/src/components/ProfilePreview.tsx +55 -0
- package/src/components/ThemeSlider.tsx +124 -0
- package/src/components/TimelineCard.tsx +118 -0
- package/src/components/TweetMediaGrid.tsx +39 -0
- package/src/components/TweetRichText.tsx +85 -0
- package/src/lib/actions-transport.ts +173 -0
- package/src/lib/archive-finder.ts +128 -0
- package/src/lib/archive-import.ts +736 -0
- package/src/lib/avatar-cache.ts +184 -0
- package/src/lib/bird-actions.ts +200 -0
- package/src/lib/bird.ts +183 -0
- package/src/lib/blocklist.ts +119 -0
- package/src/lib/blocks-write.ts +118 -0
- package/src/lib/blocks.ts +326 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/db.ts +326 -0
- package/src/lib/dms-live.ts +279 -0
- package/src/lib/inbox.ts +210 -0
- package/src/lib/mentions-export.ts +147 -0
- package/src/lib/mentions-live.ts +475 -0
- package/src/lib/moderation-target.ts +171 -0
- package/src/lib/moderation-write.ts +72 -0
- package/src/lib/mutes-write.ts +118 -0
- package/src/lib/mutes.ts +77 -0
- package/src/lib/openai.ts +86 -0
- package/src/lib/present.ts +20 -0
- package/src/lib/profile-hydration.ts +144 -0
- package/src/lib/profile-replies.ts +60 -0
- package/src/lib/queries.ts +725 -0
- package/src/lib/seed.ts +486 -0
- package/src/lib/sync-cache.ts +65 -0
- package/src/lib/theme-transition.ts +117 -0
- package/src/lib/theme.tsx +157 -0
- package/src/lib/tweet-render.ts +115 -0
- package/src/lib/types.ts +316 -0
- package/src/lib/ui.ts +270 -0
- package/src/lib/x-profile.ts +203 -0
- package/src/lib/x-web.ts +168 -0
- package/src/lib/xurl.ts +492 -0
- package/src/routeTree.gen.ts +282 -0
- package/src/router.tsx +20 -0
- package/src/routes/__root.tsx +64 -0
- package/src/routes/api/action.tsx +88 -0
- package/src/routes/api/avatar.tsx +41 -0
- package/src/routes/api/blocks.tsx +32 -0
- package/src/routes/api/inbox.tsx +35 -0
- package/src/routes/api/query.tsx +72 -0
- package/src/routes/api/status.tsx +15 -0
- package/src/routes/blocks.tsx +352 -0
- package/src/routes/dms.tsx +262 -0
- package/src/routes/inbox.tsx +201 -0
- package/src/routes/index.tsx +125 -0
- package/src/routes/mentions.tsx +125 -0
- package/src/styles.css +109 -0
- package/tsconfig.json +30 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
addBlock,
|
|
4
|
+
listBlocks,
|
|
5
|
+
recordBlock,
|
|
6
|
+
removeBlock,
|
|
7
|
+
syncBlocks,
|
|
8
|
+
} from "#/lib/blocks";
|
|
9
|
+
import { addMute, listMutes, recordMute, removeMute } from "#/lib/mutes";
|
|
10
|
+
|
|
11
|
+
interface RegisterModerationCommandsParams {
|
|
12
|
+
program: Command;
|
|
13
|
+
print: (data: unknown, asJson: boolean) => void;
|
|
14
|
+
asJson: () => boolean;
|
|
15
|
+
importBlocklist: (accountId: string, filePath: string) => Promise<unknown>;
|
|
16
|
+
resolveActionOptions: (options: { transport?: string }) => {
|
|
17
|
+
transport: "auto" | "bird" | "xurl" | undefined;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerModerationCommands({
|
|
22
|
+
program,
|
|
23
|
+
print,
|
|
24
|
+
asJson,
|
|
25
|
+
importBlocklist,
|
|
26
|
+
resolveActionOptions,
|
|
27
|
+
}: RegisterModerationCommandsParams) {
|
|
28
|
+
const blocksCommand = program
|
|
29
|
+
.command("blocks")
|
|
30
|
+
.description("Maintain the local blocklist");
|
|
31
|
+
|
|
32
|
+
blocksCommand
|
|
33
|
+
.command("list")
|
|
34
|
+
.option("--account <accountId>", "Account id")
|
|
35
|
+
.option("--search <query>", "Filter blocked profiles")
|
|
36
|
+
.option("--limit <n>", "Limit results", "50")
|
|
37
|
+
.action((options) => {
|
|
38
|
+
const items = listBlocks({
|
|
39
|
+
account: options.account,
|
|
40
|
+
search: options.search,
|
|
41
|
+
limit: Number(options.limit),
|
|
42
|
+
});
|
|
43
|
+
print(items, asJson());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
blocksCommand
|
|
47
|
+
.command("add <query>")
|
|
48
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
49
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
50
|
+
.action(async (query, options) => {
|
|
51
|
+
const result = await addBlock(
|
|
52
|
+
options.account,
|
|
53
|
+
query,
|
|
54
|
+
resolveActionOptions(options),
|
|
55
|
+
);
|
|
56
|
+
print(result, asJson());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
blocksCommand
|
|
60
|
+
.command("remove <query>")
|
|
61
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
62
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
63
|
+
.action(async (query, options) => {
|
|
64
|
+
const result = await removeBlock(
|
|
65
|
+
options.account,
|
|
66
|
+
query,
|
|
67
|
+
resolveActionOptions(options),
|
|
68
|
+
);
|
|
69
|
+
print(result, asJson());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
blocksCommand
|
|
73
|
+
.command("sync")
|
|
74
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
75
|
+
.action(async (options) => {
|
|
76
|
+
const result = await syncBlocks(options.account);
|
|
77
|
+
print(result, asJson());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
blocksCommand
|
|
81
|
+
.command("import <path>")
|
|
82
|
+
.description("Import a newline-delimited blocklist file")
|
|
83
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
84
|
+
.action(async (filePath, options) => {
|
|
85
|
+
const result = await importBlocklist(options.account, filePath);
|
|
86
|
+
print(result, asJson());
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
blocksCommand
|
|
90
|
+
.command("record <query>")
|
|
91
|
+
.description(
|
|
92
|
+
"Record a known-good remote block locally without another live write",
|
|
93
|
+
)
|
|
94
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
95
|
+
.action(async (query, options) => {
|
|
96
|
+
const result = await recordBlock(options.account, query);
|
|
97
|
+
print(result, asJson());
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const mutesCommand = program
|
|
101
|
+
.command("mutes")
|
|
102
|
+
.description("Maintain the local mute list");
|
|
103
|
+
|
|
104
|
+
mutesCommand
|
|
105
|
+
.command("list")
|
|
106
|
+
.option("--account <accountId>", "Account id")
|
|
107
|
+
.option("--search <query>", "Filter muted profiles")
|
|
108
|
+
.option("--limit <n>", "Limit results", "50")
|
|
109
|
+
.action((options) => {
|
|
110
|
+
const items = listMutes({
|
|
111
|
+
account: options.account,
|
|
112
|
+
search: options.search,
|
|
113
|
+
limit: Number(options.limit),
|
|
114
|
+
});
|
|
115
|
+
print(items, asJson());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
mutesCommand
|
|
119
|
+
.command("add <query>")
|
|
120
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
121
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
122
|
+
.action(async (query, options) => {
|
|
123
|
+
const result = await addMute(
|
|
124
|
+
options.account,
|
|
125
|
+
query,
|
|
126
|
+
resolveActionOptions(options),
|
|
127
|
+
);
|
|
128
|
+
print(result, asJson());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
mutesCommand
|
|
132
|
+
.command("remove <query>")
|
|
133
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
134
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
135
|
+
.action(async (query, options) => {
|
|
136
|
+
const result = await removeMute(
|
|
137
|
+
options.account,
|
|
138
|
+
query,
|
|
139
|
+
resolveActionOptions(options),
|
|
140
|
+
);
|
|
141
|
+
print(result, asJson());
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
mutesCommand
|
|
145
|
+
.command("record <query>")
|
|
146
|
+
.description(
|
|
147
|
+
"Record a known-good remote mute locally without another live write",
|
|
148
|
+
)
|
|
149
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
150
|
+
.action(async (query, options) => {
|
|
151
|
+
const result = await recordMute(options.account, query);
|
|
152
|
+
print(result, asJson());
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
program
|
|
156
|
+
.command("ban <query>")
|
|
157
|
+
.description("Alias for blocks add")
|
|
158
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
159
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
160
|
+
.action(async (query, options) => {
|
|
161
|
+
const result = await addBlock(
|
|
162
|
+
options.account,
|
|
163
|
+
query,
|
|
164
|
+
resolveActionOptions(options),
|
|
165
|
+
);
|
|
166
|
+
print(result, asJson());
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
program
|
|
170
|
+
.command("unban <query>")
|
|
171
|
+
.description("Alias for blocks remove")
|
|
172
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
173
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
174
|
+
.action(async (query, options) => {
|
|
175
|
+
const result = await removeBlock(
|
|
176
|
+
options.account,
|
|
177
|
+
query,
|
|
178
|
+
resolveActionOptions(options),
|
|
179
|
+
);
|
|
180
|
+
print(result, asJson());
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
program
|
|
184
|
+
.command("mute <query>")
|
|
185
|
+
.description("Mute a user for one account")
|
|
186
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
187
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
188
|
+
.action(async (query, options) => {
|
|
189
|
+
const result = await addMute(
|
|
190
|
+
options.account,
|
|
191
|
+
query,
|
|
192
|
+
resolveActionOptions(options),
|
|
193
|
+
);
|
|
194
|
+
print(result, asJson());
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
program
|
|
198
|
+
.command("unmute <query>")
|
|
199
|
+
.description("Unmute a user for one account")
|
|
200
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
201
|
+
.option("--transport <mode>", "auto, bird, or xurl")
|
|
202
|
+
.action(async (query, options) => {
|
|
203
|
+
const result = await removeMute(
|
|
204
|
+
options.account,
|
|
205
|
+
query,
|
|
206
|
+
resolveActionOptions(options),
|
|
207
|
+
);
|
|
208
|
+
print(result, asJson());
|
|
209
|
+
});
|
|
210
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { registerModerationCommands } from "#/cli-moderation";
|
|
9
|
+
import { findArchives } from "#/lib/archive-finder";
|
|
10
|
+
import { importArchive } from "#/lib/archive-import";
|
|
11
|
+
import { importBlocklist } from "#/lib/blocklist";
|
|
12
|
+
import {
|
|
13
|
+
type ActionsTransport,
|
|
14
|
+
ensureBirdclawDirs,
|
|
15
|
+
getBirdclawPaths,
|
|
16
|
+
resolveMentionsDataSource,
|
|
17
|
+
} from "#/lib/config";
|
|
18
|
+
import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
|
|
19
|
+
import { listInboxItems, scoreInbox } from "#/lib/inbox";
|
|
20
|
+
import { exportMentionItems } from "#/lib/mentions-export";
|
|
21
|
+
import {
|
|
22
|
+
exportMentionsViaCachedBird,
|
|
23
|
+
exportMentionsViaCachedXurl,
|
|
24
|
+
} from "#/lib/mentions-live";
|
|
25
|
+
import { hydrateProfilesFromX } from "#/lib/profile-hydration";
|
|
26
|
+
import { inspectProfileReplies } from "#/lib/profile-replies";
|
|
27
|
+
import {
|
|
28
|
+
createDmReply,
|
|
29
|
+
createPost,
|
|
30
|
+
createTweetReply,
|
|
31
|
+
getQueryEnvelope,
|
|
32
|
+
listDmConversations,
|
|
33
|
+
listTimelineItems,
|
|
34
|
+
} from "#/lib/queries";
|
|
35
|
+
|
|
36
|
+
const program = new Command();
|
|
37
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
38
|
+
const packageVersion = JSON.parse(
|
|
39
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
40
|
+
) as { version?: string };
|
|
41
|
+
|
|
42
|
+
function print(data: unknown, asJson: boolean) {
|
|
43
|
+
if (asJson) {
|
|
44
|
+
console.log(JSON.stringify(data, null, 2));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
console.log(data);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveActionOptions(options: { transport?: string }) {
|
|
51
|
+
return {
|
|
52
|
+
transport: options.transport as ActionsTransport | undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.name("birdclaw")
|
|
58
|
+
.description("Local-first X workspace")
|
|
59
|
+
.version(packageVersion.version ?? "0.0.0")
|
|
60
|
+
.option("--json", "Emit JSON output");
|
|
61
|
+
|
|
62
|
+
program
|
|
63
|
+
.command("init")
|
|
64
|
+
.description("Create local birdclaw root and seed the database")
|
|
65
|
+
.action(async () => {
|
|
66
|
+
const paths = ensureBirdclawDirs();
|
|
67
|
+
await getQueryEnvelope();
|
|
68
|
+
print(
|
|
69
|
+
{
|
|
70
|
+
ok: true,
|
|
71
|
+
rootDir: paths.rootDir,
|
|
72
|
+
configPath: paths.configPath,
|
|
73
|
+
dbPath: paths.dbPath,
|
|
74
|
+
mediaOriginalsDir: paths.mediaOriginalsDir,
|
|
75
|
+
mediaThumbsDir: paths.mediaThumbsDir,
|
|
76
|
+
},
|
|
77
|
+
program.opts().json ?? false,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
program
|
|
82
|
+
.command("auth status")
|
|
83
|
+
.description("Show transport status")
|
|
84
|
+
.action(async () => {
|
|
85
|
+
const meta = await getQueryEnvelope();
|
|
86
|
+
print(meta.transport, program.opts().json ?? false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
program
|
|
90
|
+
.command("archive find")
|
|
91
|
+
.description("Find likely X/Twitter archives on disk")
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const items = await findArchives();
|
|
94
|
+
print(items, program.opts().json ?? false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const importCommand = program
|
|
98
|
+
.command("import")
|
|
99
|
+
.description("Import local archive data");
|
|
100
|
+
|
|
101
|
+
importCommand
|
|
102
|
+
.command("archive [archivePath]")
|
|
103
|
+
.description("Import an X/Twitter archive into the local SQLite store")
|
|
104
|
+
.action(async (archivePath) => {
|
|
105
|
+
let resolvedArchivePath = archivePath;
|
|
106
|
+
if (!resolvedArchivePath) {
|
|
107
|
+
const [latestArchive] = await findArchives();
|
|
108
|
+
resolvedArchivePath = latestArchive?.path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!resolvedArchivePath) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"No archive found. Pass a path or place one in Downloads.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = await importArchive(resolvedArchivePath);
|
|
118
|
+
print(result, program.opts().json ?? false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
importCommand
|
|
122
|
+
.command("hydrate-profiles")
|
|
123
|
+
.description("Backfill archive-imported profiles from live X metadata")
|
|
124
|
+
.action(async () => {
|
|
125
|
+
const result = await hydrateProfilesFromX();
|
|
126
|
+
print(result, program.opts().json ?? false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const searchCommand = program
|
|
130
|
+
.command("search")
|
|
131
|
+
.description("Search local data");
|
|
132
|
+
|
|
133
|
+
searchCommand
|
|
134
|
+
.command("tweets <query>")
|
|
135
|
+
.option("--resource <resource>", "home or mentions", "home")
|
|
136
|
+
.option("--replied", "Only replied items")
|
|
137
|
+
.option("--unreplied", "Only unreplied items")
|
|
138
|
+
.option("--limit <n>", "Limit results", "20")
|
|
139
|
+
.action((query, options) => {
|
|
140
|
+
const replyFilter = options.replied
|
|
141
|
+
? "replied"
|
|
142
|
+
: options.unreplied
|
|
143
|
+
? "unreplied"
|
|
144
|
+
: "all";
|
|
145
|
+
const items = listTimelineItems({
|
|
146
|
+
resource: options.resource === "mentions" ? "mentions" : "home",
|
|
147
|
+
search: query,
|
|
148
|
+
replyFilter,
|
|
149
|
+
limit: Number(options.limit),
|
|
150
|
+
});
|
|
151
|
+
print(items, program.opts().json ?? false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
searchCommand
|
|
155
|
+
.command("dms <query>")
|
|
156
|
+
.option("--participant <value>")
|
|
157
|
+
.option("--min-followers <n>", "Minimum sender follower count")
|
|
158
|
+
.option("--max-followers <n>", "Maximum sender follower count")
|
|
159
|
+
.option("--min-influence-score <n>", "Minimum derived influence score")
|
|
160
|
+
.option("--max-influence-score <n>", "Maximum derived influence score")
|
|
161
|
+
.option("--sort <mode>", "recent or influence", "recent")
|
|
162
|
+
.option("--replied", "Only replied threads")
|
|
163
|
+
.option("--unreplied", "Only unreplied threads")
|
|
164
|
+
.option("--limit <n>", "Limit results", "20")
|
|
165
|
+
.action((query, options) => {
|
|
166
|
+
const replyFilter = options.replied
|
|
167
|
+
? "replied"
|
|
168
|
+
: options.unreplied
|
|
169
|
+
? "unreplied"
|
|
170
|
+
: "all";
|
|
171
|
+
const items = listDmConversations({
|
|
172
|
+
search: query,
|
|
173
|
+
participant: options.participant,
|
|
174
|
+
minFollowers: options.minFollowers
|
|
175
|
+
? Number(options.minFollowers)
|
|
176
|
+
: undefined,
|
|
177
|
+
maxFollowers: options.maxFollowers
|
|
178
|
+
? Number(options.maxFollowers)
|
|
179
|
+
: undefined,
|
|
180
|
+
minInfluenceScore: options.minInfluenceScore
|
|
181
|
+
? Number(options.minInfluenceScore)
|
|
182
|
+
: undefined,
|
|
183
|
+
maxInfluenceScore: options.maxInfluenceScore
|
|
184
|
+
? Number(options.maxInfluenceScore)
|
|
185
|
+
: undefined,
|
|
186
|
+
sort: options.sort === "influence" ? "influence" : "recent",
|
|
187
|
+
replyFilter,
|
|
188
|
+
limit: Number(options.limit),
|
|
189
|
+
});
|
|
190
|
+
print(items, program.opts().json ?? false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const mentionsCommand = program
|
|
194
|
+
.command("mentions")
|
|
195
|
+
.description("Export local mention tweets for scripts and agents");
|
|
196
|
+
|
|
197
|
+
mentionsCommand
|
|
198
|
+
.command("export [query]")
|
|
199
|
+
.description("Return mention tweets with plain-text and markdown renderings")
|
|
200
|
+
.option("--account <accountId>", "Account id")
|
|
201
|
+
.option("--mode <mode>", "birdclaw, xurl, or bird")
|
|
202
|
+
.option("--replied", "Only replied items")
|
|
203
|
+
.option("--unreplied", "Only unreplied items")
|
|
204
|
+
.option("--refresh", "Refresh the live xurl cache before returning")
|
|
205
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
206
|
+
.option("--all", "Fetch every retrievable xurl mentions page")
|
|
207
|
+
.option(
|
|
208
|
+
"--max-pages <n>",
|
|
209
|
+
"Maximum xurl mention pages to fetch (implies --all)",
|
|
210
|
+
)
|
|
211
|
+
.option("--limit <n>", "Limit results", "20")
|
|
212
|
+
.action(async (query, options) => {
|
|
213
|
+
const replyFilter = options.replied
|
|
214
|
+
? "replied"
|
|
215
|
+
: options.unreplied
|
|
216
|
+
? "unreplied"
|
|
217
|
+
: "all";
|
|
218
|
+
const limit = Number(options.limit);
|
|
219
|
+
const mode = resolveMentionsDataSource(options.mode);
|
|
220
|
+
if (mode === "xurl") {
|
|
221
|
+
const payload = await exportMentionsViaCachedXurl({
|
|
222
|
+
account: options.account,
|
|
223
|
+
search: query,
|
|
224
|
+
replyFilter,
|
|
225
|
+
limit,
|
|
226
|
+
all: Boolean(options.all) || options.maxPages !== undefined,
|
|
227
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
228
|
+
refresh: Boolean(options.refresh),
|
|
229
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
230
|
+
});
|
|
231
|
+
print(payload, true);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (mode === "bird") {
|
|
235
|
+
const payload = await exportMentionsViaCachedBird({
|
|
236
|
+
account: options.account,
|
|
237
|
+
search: query,
|
|
238
|
+
replyFilter,
|
|
239
|
+
limit,
|
|
240
|
+
all: Boolean(options.all) || options.maxPages !== undefined,
|
|
241
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
242
|
+
refresh: Boolean(options.refresh),
|
|
243
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
244
|
+
});
|
|
245
|
+
print(payload, true);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const items = exportMentionItems({
|
|
250
|
+
account: options.account,
|
|
251
|
+
search: query,
|
|
252
|
+
replyFilter,
|
|
253
|
+
limit,
|
|
254
|
+
});
|
|
255
|
+
print({ resource: "mentions", count: items.length, items }, true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const profilesCommand = program
|
|
259
|
+
.command("profiles")
|
|
260
|
+
.description("Inspect live profile context for moderation and triage");
|
|
261
|
+
|
|
262
|
+
profilesCommand
|
|
263
|
+
.command("replies <query>")
|
|
264
|
+
.description("Inspect recent authored replies for one profile")
|
|
265
|
+
.option("--limit <n>", "Limit replies", "12")
|
|
266
|
+
.action(async (query, options) => {
|
|
267
|
+
const result = await inspectProfileReplies(query, {
|
|
268
|
+
limit: Number(options.limit),
|
|
269
|
+
});
|
|
270
|
+
print(result, program.opts().json ?? false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const dmsCommand = program.command("dms").description("Direct messages");
|
|
274
|
+
|
|
275
|
+
dmsCommand
|
|
276
|
+
.command("list")
|
|
277
|
+
.option("--account <accountId>", "Account id")
|
|
278
|
+
.option("--refresh", "Refresh live DMs through bird before listing")
|
|
279
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
280
|
+
.option("--participant <value>")
|
|
281
|
+
.option("--min-followers <n>", "Minimum sender follower count")
|
|
282
|
+
.option("--max-followers <n>", "Maximum sender follower count")
|
|
283
|
+
.option("--min-influence-score <n>", "Minimum derived influence score")
|
|
284
|
+
.option("--max-influence-score <n>", "Maximum derived influence score")
|
|
285
|
+
.option("--sort <mode>", "recent or influence", "recent")
|
|
286
|
+
.option("--replied", "Only replied threads")
|
|
287
|
+
.option("--unreplied", "Only unreplied threads")
|
|
288
|
+
.option("--limit <n>", "Limit results", "20")
|
|
289
|
+
.action(async (options) => {
|
|
290
|
+
const replyFilter = options.replied
|
|
291
|
+
? "replied"
|
|
292
|
+
: options.unreplied
|
|
293
|
+
? "unreplied"
|
|
294
|
+
: "all";
|
|
295
|
+
if (options.refresh) {
|
|
296
|
+
await syncDirectMessagesViaCachedBird({
|
|
297
|
+
account: options.account,
|
|
298
|
+
limit: Number(options.limit),
|
|
299
|
+
refresh: true,
|
|
300
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const items = listDmConversations({
|
|
304
|
+
account: options.account,
|
|
305
|
+
participant: options.participant,
|
|
306
|
+
minFollowers: options.minFollowers
|
|
307
|
+
? Number(options.minFollowers)
|
|
308
|
+
: undefined,
|
|
309
|
+
maxFollowers: options.maxFollowers
|
|
310
|
+
? Number(options.maxFollowers)
|
|
311
|
+
: undefined,
|
|
312
|
+
minInfluenceScore: options.minInfluenceScore
|
|
313
|
+
? Number(options.minInfluenceScore)
|
|
314
|
+
: undefined,
|
|
315
|
+
maxInfluenceScore: options.maxInfluenceScore
|
|
316
|
+
? Number(options.maxInfluenceScore)
|
|
317
|
+
: undefined,
|
|
318
|
+
sort: options.sort === "influence" ? "influence" : "recent",
|
|
319
|
+
replyFilter,
|
|
320
|
+
limit: Number(options.limit),
|
|
321
|
+
});
|
|
322
|
+
print(items, program.opts().json ?? false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
dmsCommand
|
|
326
|
+
.command("sync")
|
|
327
|
+
.description("Refresh live direct messages through bird into the local store")
|
|
328
|
+
.option("--account <accountId>", "Account id")
|
|
329
|
+
.option("--limit <n>", "Limit messages", "20")
|
|
330
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
331
|
+
.option("--refresh", "Bypass live-cache freshness window")
|
|
332
|
+
.action(async (options) => {
|
|
333
|
+
const result = await syncDirectMessagesViaCachedBird({
|
|
334
|
+
account: options.account,
|
|
335
|
+
limit: Number(options.limit),
|
|
336
|
+
refresh: Boolean(options.refresh),
|
|
337
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
338
|
+
});
|
|
339
|
+
print(result, true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
registerModerationCommands({
|
|
343
|
+
program,
|
|
344
|
+
print,
|
|
345
|
+
asJson: () => program.opts().json ?? false,
|
|
346
|
+
importBlocklist,
|
|
347
|
+
resolveActionOptions,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const composeCommand = program
|
|
351
|
+
.command("compose")
|
|
352
|
+
.description("Create local/xurl actions");
|
|
353
|
+
|
|
354
|
+
composeCommand
|
|
355
|
+
.command("post <text>")
|
|
356
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
357
|
+
.action(async (text, options) => {
|
|
358
|
+
const result = await createPost(options.account, text);
|
|
359
|
+
print(result, program.opts().json ?? false);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
composeCommand
|
|
363
|
+
.command("reply <tweetId> <text>")
|
|
364
|
+
.option("--account <accountId>", "Account id", "acct_primary")
|
|
365
|
+
.action(async (tweetId, text, options) => {
|
|
366
|
+
const result = await createTweetReply(options.account, tweetId, text);
|
|
367
|
+
print(result, program.opts().json ?? false);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
composeCommand
|
|
371
|
+
.command("dm <conversationId> <text>")
|
|
372
|
+
.description("Reply inside an existing DM conversation")
|
|
373
|
+
.action(async (conversationId, text) => {
|
|
374
|
+
const result = await createDmReply(conversationId, text);
|
|
375
|
+
print(result, program.opts().json ?? false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
program
|
|
379
|
+
.command("inbox")
|
|
380
|
+
.option("--kind <kind>", "mixed, mentions, or dms", "mixed")
|
|
381
|
+
.option("--min-score <n>", "Minimum rank", "0")
|
|
382
|
+
.option("--hide-low-signal", "Hide low-signal items")
|
|
383
|
+
.option("--score", "Score top items with OpenAI before listing")
|
|
384
|
+
.option("--limit <n>", "Limit results", "20")
|
|
385
|
+
.action(async (options) => {
|
|
386
|
+
const kind =
|
|
387
|
+
options.kind === "mentions" || options.kind === "dms"
|
|
388
|
+
? options.kind
|
|
389
|
+
: "mixed";
|
|
390
|
+
if (options.score) {
|
|
391
|
+
await scoreInbox({
|
|
392
|
+
kind,
|
|
393
|
+
limit: Number(options.limit),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
print(
|
|
397
|
+
listInboxItems({
|
|
398
|
+
kind,
|
|
399
|
+
minScore: Number(options.minScore),
|
|
400
|
+
hideLowSignal: Boolean(options.hideLowSignal),
|
|
401
|
+
limit: Number(options.limit),
|
|
402
|
+
}),
|
|
403
|
+
program.opts().json ?? false,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
program
|
|
408
|
+
.command("db stats")
|
|
409
|
+
.description("Show local storage and dataset stats")
|
|
410
|
+
.action(async () => {
|
|
411
|
+
const meta = await getQueryEnvelope();
|
|
412
|
+
const paths = getBirdclawPaths();
|
|
413
|
+
print(
|
|
414
|
+
{
|
|
415
|
+
paths,
|
|
416
|
+
stats: meta.stats,
|
|
417
|
+
transport: meta.transport,
|
|
418
|
+
},
|
|
419
|
+
program.opts().json ?? false,
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
program
|
|
424
|
+
.command("serve")
|
|
425
|
+
.description("Run the local web app")
|
|
426
|
+
.action(() => {
|
|
427
|
+
const child = spawn(
|
|
428
|
+
process.execPath,
|
|
429
|
+
["node_modules/vite/bin/vite.js", "dev", "--port", "3000"],
|
|
430
|
+
{
|
|
431
|
+
cwd: packageRoot,
|
|
432
|
+
stdio: "inherit",
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
child.on("exit", (code) => {
|
|
436
|
+
process.exit(code ?? 0);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
export async function runCli(argv = process.argv) {
|
|
441
|
+
await program.parseAsync(argv);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* v8 ignore next 5 */
|
|
445
|
+
if (process.argv[1]) {
|
|
446
|
+
const entryUrl = pathToFileURL(process.argv[1]).href;
|
|
447
|
+
if (import.meta.url === entryUrl) {
|
|
448
|
+
void runCli();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
brandMarkClass,
|
|
4
|
+
cx,
|
|
5
|
+
eyebrowClass,
|
|
6
|
+
navClass,
|
|
7
|
+
navLinkActiveClass,
|
|
8
|
+
navLinkClass,
|
|
9
|
+
navLinksClass,
|
|
10
|
+
} from "#/lib/ui";
|
|
11
|
+
import { ThemeSlider } from "./ThemeSlider";
|
|
12
|
+
|
|
13
|
+
const links = [
|
|
14
|
+
{ to: "/inbox", label: "Inbox" },
|
|
15
|
+
{ to: "/", label: "Home" },
|
|
16
|
+
{ to: "/mentions", label: "Mentions" },
|
|
17
|
+
{ to: "/dms", label: "DMs" },
|
|
18
|
+
{ to: "/blocks", label: "Blocks" },
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export function AppNav() {
|
|
22
|
+
const pathname = useRouterState({
|
|
23
|
+
select: (state) => state.location.pathname,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<nav className={navClass}>
|
|
28
|
+
<div>
|
|
29
|
+
<p className={eyebrowClass}>birdclaw</p>
|
|
30
|
+
<h1 className={brandMarkClass}>Quiet signal for X.</h1>
|
|
31
|
+
</div>
|
|
32
|
+
<div className={navLinksClass}>
|
|
33
|
+
{links.map((link) => {
|
|
34
|
+
const active = pathname === link.to;
|
|
35
|
+
return (
|
|
36
|
+
<Link
|
|
37
|
+
key={link.to}
|
|
38
|
+
to={link.to}
|
|
39
|
+
className={cx(navLinkClass, active && navLinkActiveClass)}
|
|
40
|
+
>
|
|
41
|
+
{link.label}
|
|
42
|
+
</Link>
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
45
|
+
<ThemeSlider />
|
|
46
|
+
</div>
|
|
47
|
+
</nav>
|
|
48
|
+
);
|
|
49
|
+
}
|