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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,118 @@
1
+ import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
+ import type { TimelineItem } from "#/lib/types";
3
+ import {
4
+ actionButtonClass,
5
+ cardFooterClass,
6
+ cardHeaderClass,
7
+ contentCardClass,
8
+ cx,
9
+ identityBlockClass,
10
+ identityRowClass,
11
+ linkPreviewCardClass,
12
+ metaStackClass,
13
+ metricRowClass,
14
+ mutedDotClass,
15
+ pillAlertClass,
16
+ pillClass,
17
+ pillSoftClass,
18
+ timestampClass,
19
+ } from "#/lib/ui";
20
+ import { AvatarChip } from "./AvatarChip";
21
+ import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
22
+ import { ProfilePreview } from "./ProfilePreview";
23
+ import { TweetMediaGrid } from "./TweetMediaGrid";
24
+ import { TweetRichText } from "./TweetRichText";
25
+
26
+ function getVisibleUrlCards(item: TimelineItem) {
27
+ const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
28
+ return (item.entities.urls ?? []).filter((entry) => {
29
+ if (!item.quotedTweet) return true;
30
+ return !entry.expandedUrl.includes(quotedUrl ?? "");
31
+ });
32
+ }
33
+
34
+ export function TimelineCard({
35
+ item,
36
+ onReply,
37
+ }: {
38
+ item: TimelineItem;
39
+ onReply: (tweetId: string) => void;
40
+ }) {
41
+ return (
42
+ <article className={contentCardClass}>
43
+ <header className={cardHeaderClass}>
44
+ <div className={identityBlockClass}>
45
+ <AvatarChip
46
+ avatarUrl={item.author.avatarUrl}
47
+ hue={item.author.avatarHue}
48
+ name={item.author.displayName}
49
+ profileId={item.author.id}
50
+ />
51
+ <div>
52
+ <ProfilePreview profile={item.author}>
53
+ <div className={identityRowClass}>
54
+ <strong>{item.author.displayName}</strong>
55
+ <span>@{item.author.handle}</span>
56
+ <span className={mutedDotClass} />
57
+ <span>
58
+ {formatCompactNumber(item.author.followersCount)} followers
59
+ </span>
60
+ </div>
61
+ </ProfilePreview>
62
+ </div>
63
+ </div>
64
+ <div className={metaStackClass}>
65
+ <span
66
+ className={cx(
67
+ pillClass,
68
+ item.isReplied ? pillSoftClass : pillAlertClass,
69
+ )}
70
+ >
71
+ {item.isReplied ? "replied" : "needs reply"}
72
+ </span>
73
+ <span className={timestampClass}>
74
+ {formatShortTimestamp(item.createdAt)}
75
+ </span>
76
+ </div>
77
+ </header>
78
+ <TweetRichText entities={item.entities} text={item.text} />
79
+ <TweetMediaGrid items={item.media} />
80
+ {item.replyToTweet ? (
81
+ <EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
82
+ ) : null}
83
+ {item.quotedTweet ? (
84
+ <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
85
+ ) : null}
86
+ {getVisibleUrlCards(item).map((entry) => (
87
+ <a
88
+ key={entry.expandedUrl}
89
+ className={linkPreviewCardClass}
90
+ href={entry.expandedUrl}
91
+ rel="noreferrer"
92
+ target="_blank"
93
+ >
94
+ <strong>{entry.title ?? entry.displayUrl}</strong>
95
+ <span className="text-[var(--ink-soft)]">
96
+ {entry.description ?? entry.displayUrl}
97
+ </span>
98
+ <span className={timestampClass}>{entry.displayUrl}</span>
99
+ </a>
100
+ ))}
101
+ <footer className={cardFooterClass}>
102
+ <div className={metricRowClass}>
103
+ <span>{formatCompactNumber(item.likeCount)} likes</span>
104
+ <span>{item.mediaCount} media</span>
105
+ <span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
106
+ <span>{item.accountHandle}</span>
107
+ </div>
108
+ <button
109
+ className={actionButtonClass}
110
+ onClick={() => onReply(item.id)}
111
+ type="button"
112
+ >
113
+ Reply
114
+ </button>
115
+ </footer>
116
+ </article>
117
+ );
118
+ }
@@ -0,0 +1,39 @@
1
+ import type { TweetMediaItem } from "#/lib/types";
2
+ import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
3
+
4
+ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
5
+ if (items.length === 0) {
6
+ return null;
7
+ }
8
+
9
+ return (
10
+ <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
11
+ {items.slice(0, 4).map((item, index) => (
12
+ <a
13
+ key={item.url + String(index)}
14
+ className={tweetMediaTileClass(index, Math.min(items.length, 4))}
15
+ href={item.url}
16
+ rel="noreferrer"
17
+ target="_blank"
18
+ >
19
+ {item.type === "image" ? (
20
+ <img
21
+ alt={item.altText ?? `Tweet media ${String(index + 1)}`}
22
+ className="tweet-media-image block size-full object-cover"
23
+ loading="lazy"
24
+ src={item.thumbnailUrl ?? item.url}
25
+ />
26
+ ) : (
27
+ <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
28
+ {item.type === "video"
29
+ ? "Video"
30
+ : item.type === "gif"
31
+ ? "GIF"
32
+ : "Media"}
33
+ </span>
34
+ )}
35
+ </a>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,85 @@
1
+ import { Fragment } from "react";
2
+ import { collectTweetSegments } from "#/lib/tweet-render";
3
+ import type { TweetEntities } from "#/lib/types";
4
+ import {
5
+ bodyCopyClass,
6
+ tweetHashtagClass,
7
+ tweetLinkClass,
8
+ tweetMentionClass,
9
+ } from "#/lib/ui";
10
+ import { ProfilePreview } from "./ProfilePreview";
11
+
12
+ export function TweetRichText({
13
+ text,
14
+ entities,
15
+ className = "body-copy",
16
+ }: {
17
+ text: string;
18
+ entities: TweetEntities;
19
+ className?: string;
20
+ }) {
21
+ const segments = collectTweetSegments(entities);
22
+ let cursor = 0;
23
+
24
+ return (
25
+ <p className={className === "body-copy" ? bodyCopyClass : className}>
26
+ {segments.map((segment, index) => {
27
+ if (
28
+ segment.start < cursor ||
29
+ segment.end <= segment.start ||
30
+ segment.end > text.length
31
+ ) {
32
+ return null;
33
+ }
34
+
35
+ const prefix = text.slice(cursor, segment.start);
36
+ cursor = segment.end;
37
+
38
+ let node = (
39
+ <Fragment key={`segment-${String(index)}`}>
40
+ {text.slice(segment.start, segment.end)}
41
+ </Fragment>
42
+ );
43
+ if (segment.kind === "mention" && segment.profile) {
44
+ node = (
45
+ <ProfilePreview
46
+ key={`segment-${String(index)}`}
47
+ profile={segment.profile}
48
+ >
49
+ <span className={tweetMentionClass}>@{segment.username}</span>
50
+ </ProfilePreview>
51
+ );
52
+ } else if (segment.kind === "url") {
53
+ node = (
54
+ <a
55
+ key={`segment-${String(index)}`}
56
+ className={tweetLinkClass}
57
+ href={segment.expandedUrl}
58
+ rel="noreferrer"
59
+ target="_blank"
60
+ >
61
+ {segment.displayUrl}
62
+ </a>
63
+ );
64
+ } else if (segment.kind === "hashtag") {
65
+ node = (
66
+ <span
67
+ className={tweetHashtagClass}
68
+ key={`segment-${String(index)}`}
69
+ >
70
+ #{segment.tag}
71
+ </span>
72
+ );
73
+ }
74
+
75
+ return (
76
+ <Fragment key={`piece-${String(index)}`}>
77
+ {prefix}
78
+ {node}
79
+ </Fragment>
80
+ );
81
+ })}
82
+ {text.slice(cursor)}
83
+ </p>
84
+ );
85
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ blockUserViaBird,
3
+ muteUserViaBird,
4
+ readBirdStatusViaBird,
5
+ unblockUserViaBird,
6
+ unmuteUserViaBird,
7
+ } from "./bird-actions";
8
+ import { type ActionsTransport, resolveActionsTransport } from "./config";
9
+ import type {
10
+ ModerationAction,
11
+ ModerationActionTransportResult,
12
+ } from "./types";
13
+ import {
14
+ blockUserViaXurl,
15
+ lookupAuthenticatedUser,
16
+ muteUserViaXurl,
17
+ unblockUserViaXurl,
18
+ unmuteUserViaXurl,
19
+ } from "./xurl";
20
+
21
+ export type ActionTransportResult = ModerationActionTransportResult;
22
+
23
+ interface RunActionParams {
24
+ action: ModerationAction;
25
+ query: string;
26
+ targetUserId?: string;
27
+ transport?: string;
28
+ }
29
+
30
+ function normalizeFailure(transport: "bird" | "xurl", output: string) {
31
+ return `${transport}: ${output}`;
32
+ }
33
+
34
+ async function runBirdAction(
35
+ action: ModerationAction,
36
+ query: string,
37
+ ): Promise<ActionTransportResult> {
38
+ const result =
39
+ action === "block"
40
+ ? await blockUserViaBird(query)
41
+ : action === "unblock"
42
+ ? await unblockUserViaBird(query)
43
+ : action === "mute"
44
+ ? await muteUserViaBird(query)
45
+ : await unmuteUserViaBird(query);
46
+
47
+ return {
48
+ ...result,
49
+ transport: "bird",
50
+ };
51
+ }
52
+
53
+ function getVerifyExpectation(action: ModerationAction) {
54
+ return action === "block" || action === "unblock"
55
+ ? {
56
+ field: "blocking" as const,
57
+ expected: action === "block",
58
+ }
59
+ : {
60
+ field: "muting" as const,
61
+ expected: action === "mute",
62
+ };
63
+ }
64
+
65
+ async function runXurlAction(
66
+ action: ModerationAction,
67
+ query: string,
68
+ targetUserId?: string,
69
+ ): Promise<ActionTransportResult> {
70
+ if (!targetUserId) {
71
+ return {
72
+ ok: false,
73
+ output: "missing target user id for xurl transport",
74
+ transport: "xurl",
75
+ };
76
+ }
77
+
78
+ const sourceUser = await lookupAuthenticatedUser();
79
+ const sourceUserId =
80
+ sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
81
+ if (!sourceUserId) {
82
+ return {
83
+ ok: false,
84
+ output: "xurl authenticated user unavailable",
85
+ transport: "xurl",
86
+ };
87
+ }
88
+
89
+ const result =
90
+ action === "block"
91
+ ? await blockUserViaXurl(sourceUserId, targetUserId)
92
+ : action === "unblock"
93
+ ? await unblockUserViaXurl(sourceUserId, targetUserId)
94
+ : action === "mute"
95
+ ? await muteUserViaXurl(sourceUserId, targetUserId)
96
+ : await unmuteUserViaXurl(sourceUserId, targetUserId);
97
+
98
+ if (!result.ok) {
99
+ return {
100
+ ...result,
101
+ transport: "xurl",
102
+ };
103
+ }
104
+
105
+ const status = await readBirdStatusViaBird(query);
106
+ const { field: verifyField, expected: expectedValue } =
107
+ getVerifyExpectation(action);
108
+ const actualValue =
109
+ status && typeof status[verifyField] === "boolean"
110
+ ? Boolean(status[verifyField])
111
+ : null;
112
+
113
+ if (actualValue === null) {
114
+ return {
115
+ ok: false,
116
+ output: `${result.output}\nxurl verify unavailable from bird status`,
117
+ transport: "xurl",
118
+ };
119
+ }
120
+
121
+ if (actualValue !== expectedValue) {
122
+ return {
123
+ ok: false,
124
+ output: `${result.output}\nxurl verify mismatch ${verifyField}=${String(actualValue)}`,
125
+ transport: "xurl",
126
+ };
127
+ }
128
+
129
+ return {
130
+ ok: true,
131
+ output: `${result.output}\nverified ${verifyField}=${String(actualValue)}`,
132
+ transport: "xurl",
133
+ };
134
+ }
135
+
136
+ export async function runModerationAction({
137
+ action,
138
+ query,
139
+ targetUserId,
140
+ transport,
141
+ }: RunActionParams): Promise<ActionTransportResult> {
142
+ const requestedTransport = resolveActionsTransport(transport);
143
+ if (requestedTransport === "bird") {
144
+ return runBirdAction(action, query);
145
+ }
146
+ if (requestedTransport === "xurl") {
147
+ return runXurlAction(action, query, targetUserId);
148
+ }
149
+
150
+ const birdResult = await runBirdAction(action, query);
151
+ if (birdResult.ok) {
152
+ return birdResult;
153
+ }
154
+
155
+ const xurlResult = await runXurlAction(action, query, targetUserId);
156
+ if (xurlResult.ok) {
157
+ return {
158
+ ...xurlResult,
159
+ output: `${xurlResult.output}\nfalling back after ${normalizeFailure("bird", birdResult.output)}`,
160
+ };
161
+ }
162
+
163
+ return {
164
+ ok: false,
165
+ output: [
166
+ normalizeFailure("bird", birdResult.output),
167
+ normalizeFailure("xurl", xurlResult.output),
168
+ ].join("\n"),
169
+ transport: xurlResult.transport,
170
+ };
171
+ }
172
+
173
+ export type { ActionsTransport };
@@ -0,0 +1,128 @@
1
+ import { exec } from "node:child_process";
2
+ import { existsSync, promises as fs } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import type { ArchiveCandidate } from "./types";
7
+
8
+ const execAsync = promisify(exec);
9
+ const ARCHIVE_NAME_PATTERNS = [
10
+ /^twitter-.*\.zip$/i,
11
+ /^x-.*\.zip$/i,
12
+ /archive.*\.zip$/i,
13
+ ];
14
+
15
+ function formatFileSize(bytes: number): string {
16
+ const units = ["B", "KB", "MB", "GB"];
17
+ let value = bytes;
18
+ let index = 0;
19
+
20
+ while (value >= 1024 && index < units.length - 1) {
21
+ value /= 1024;
22
+ index += 1;
23
+ }
24
+
25
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[index]}`;
26
+ }
27
+
28
+ function formatRelativeDate(date: Date): string {
29
+ const days = Math.floor(
30
+ (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24),
31
+ );
32
+ if (days <= 0) return "Today";
33
+ if (days === 1) return "Yesterday";
34
+ if (days < 7) return `${days} days ago`;
35
+ if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
36
+ if (days < 365) return `${Math.floor(days / 30)} months ago`;
37
+ return `${Math.floor(days / 365)} years ago`;
38
+ }
39
+
40
+ async function getCandidate(
41
+ filePath: string,
42
+ ): Promise<ArchiveCandidate | null> {
43
+ try {
44
+ const stats = await fs.stat(filePath);
45
+ if (!stats.isFile() || stats.size < 1024 * 1024) {
46
+ return null;
47
+ }
48
+
49
+ return {
50
+ path: filePath,
51
+ name: path.basename(filePath),
52
+ size: stats.size,
53
+ sizeFormatted: formatFileSize(stats.size),
54
+ modifiedTime: stats.mtime.toISOString(),
55
+ dateFormatted: formatRelativeDate(stats.mtime),
56
+ };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ async function searchDirectory(
63
+ directoryPath: string,
64
+ ): Promise<ArchiveCandidate[]> {
65
+ if (!existsSync(directoryPath)) {
66
+ return [];
67
+ }
68
+
69
+ const entries = await fs.readdir(directoryPath);
70
+ const matches = entries.filter((entry) =>
71
+ ARCHIVE_NAME_PATTERNS.some((pattern) => pattern.test(entry)),
72
+ );
73
+
74
+ const candidates = await Promise.all(
75
+ matches.map((entry) => getCandidate(path.join(directoryPath, entry))),
76
+ );
77
+
78
+ return candidates.filter((item) => item !== null);
79
+ }
80
+
81
+ export async function findArchives(): Promise<ArchiveCandidate[]> {
82
+ if (process.platform !== "darwin") {
83
+ return [];
84
+ }
85
+
86
+ const found = new Map<string, ArchiveCandidate>();
87
+ const downloads = await searchDirectory(path.join(homedir(), "Downloads"));
88
+
89
+ for (const candidate of downloads) {
90
+ found.set(candidate.path, candidate);
91
+ }
92
+
93
+ const queries = [
94
+ 'kMDItemDisplayName == "twitter-*.zip"',
95
+ 'kMDItemDisplayName == "x-*.zip"',
96
+ 'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
97
+ ];
98
+
99
+ for (const query of queries) {
100
+ try {
101
+ const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
102
+ timeout: 5000,
103
+ });
104
+
105
+ const paths = stdout
106
+ .split("\n")
107
+ .map((item) => item.trim())
108
+ .filter((item) => item.length > 0 && item.endsWith(".zip"));
109
+
110
+ const candidates = await Promise.all(
111
+ paths.map((filePath) => getCandidate(filePath)),
112
+ );
113
+ for (const candidate of candidates) {
114
+ if (candidate) {
115
+ found.set(candidate.path, candidate);
116
+ }
117
+ }
118
+ } catch {
119
+ // Best-effort only.
120
+ }
121
+ }
122
+
123
+ return [...found.values()].sort(
124
+ (left, right) =>
125
+ new Date(right.modifiedTime).getTime() -
126
+ new Date(left.modifiedTime).getTime(),
127
+ );
128
+ }