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,184 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getBirdclawPaths } from "./config";
|
|
5
|
+
import { getNativeDb } from "./db";
|
|
6
|
+
|
|
7
|
+
const AVATAR_SIZE_SUFFIX =
|
|
8
|
+
/(?:(?:_normal|_bigger|_mini))(?=\.(?:jpg|jpeg|png|webp|gif)(?:$|\?))/i;
|
|
9
|
+
|
|
10
|
+
function sanitizeFileToken(value: string) {
|
|
11
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getAvatarCacheDir() {
|
|
15
|
+
const { mediaThumbsDir } = getBirdclawPaths();
|
|
16
|
+
const dir = path.join(mediaThumbsDir, "avatars");
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getExtensionFromContentType(contentType: string | null) {
|
|
22
|
+
const mime = contentType?.split(";")[0].trim().toLowerCase() ?? "";
|
|
23
|
+
if (mime === "image/png") return ".png";
|
|
24
|
+
if (mime === "image/webp") return ".webp";
|
|
25
|
+
if (mime === "image/gif") return ".gif";
|
|
26
|
+
if (mime === "image/svg+xml") return ".svg";
|
|
27
|
+
return ".jpg";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getContentTypeFromExtension(extension: string) {
|
|
31
|
+
switch (extension.toLowerCase()) {
|
|
32
|
+
case ".png":
|
|
33
|
+
return "image/png";
|
|
34
|
+
case ".webp":
|
|
35
|
+
return "image/webp";
|
|
36
|
+
case ".gif":
|
|
37
|
+
return "image/gif";
|
|
38
|
+
case ".svg":
|
|
39
|
+
return "image/svg+xml";
|
|
40
|
+
default:
|
|
41
|
+
return "image/jpeg";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getExtensionFromAvatarUrl(avatarUrl: string) {
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(avatarUrl);
|
|
48
|
+
const extension = path.extname(url.pathname).toLowerCase();
|
|
49
|
+
if (extension === ".png" || extension === ".webp" || extension === ".gif") {
|
|
50
|
+
return extension;
|
|
51
|
+
}
|
|
52
|
+
return extension === ".svg" ? ".svg" : ".jpg";
|
|
53
|
+
} catch {
|
|
54
|
+
return ".jpg";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function decodeDataUrl(dataUrl: string) {
|
|
59
|
+
if (!dataUrl.startsWith("data:")) {
|
|
60
|
+
throw new Error("Invalid avatar data URL");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const separatorIndex = dataUrl.indexOf(",");
|
|
64
|
+
if (separatorIndex < 0) {
|
|
65
|
+
throw new Error("Invalid avatar data URL");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const metadata = dataUrl.slice(5, separatorIndex);
|
|
69
|
+
const payload = dataUrl.slice(separatorIndex + 1);
|
|
70
|
+
const contentType = metadata.split(";")[0] || "application/octet-stream";
|
|
71
|
+
const isBase64 = metadata.includes(";base64");
|
|
72
|
+
return {
|
|
73
|
+
contentType,
|
|
74
|
+
buffer: isBase64
|
|
75
|
+
? Buffer.from(payload, "base64")
|
|
76
|
+
: Buffer.from(decodeURIComponent(payload), "utf8"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getAvatarUrlForProfile(profileId: string) {
|
|
81
|
+
const row = getNativeDb()
|
|
82
|
+
.prepare("select avatar_url from profiles where id = ?")
|
|
83
|
+
.get(profileId) as { avatar_url: string | null } | undefined;
|
|
84
|
+
return row?.avatar_url ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function normalizeAvatarUrl(value: unknown) {
|
|
88
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
if (trimmed.startsWith("data:image/")) {
|
|
94
|
+
return trimmed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(trimmed);
|
|
99
|
+
url.pathname = url.pathname.replace(AVATAR_SIZE_SUFFIX, "");
|
|
100
|
+
return url.toString();
|
|
101
|
+
} catch {
|
|
102
|
+
return trimmed;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getAvatarCachePath(profileId: string, avatarUrl: string) {
|
|
107
|
+
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
|
108
|
+
if (!normalizedAvatarUrl) {
|
|
109
|
+
throw new Error("Missing avatar URL");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hash = createHash("sha1").update(normalizedAvatarUrl).digest("hex");
|
|
113
|
+
const extension = normalizedAvatarUrl.startsWith("data:")
|
|
114
|
+
? getExtensionFromContentType(
|
|
115
|
+
/^data:([^;,]+)/i.exec(normalizedAvatarUrl)?.[1] ?? null,
|
|
116
|
+
)
|
|
117
|
+
: getExtensionFromAvatarUrl(normalizedAvatarUrl);
|
|
118
|
+
|
|
119
|
+
return path.join(
|
|
120
|
+
getAvatarCacheDir(),
|
|
121
|
+
`${sanitizeFileToken(profileId)}-${hash}${extension}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function fetchRemoteAvatar(avatarUrl: string) {
|
|
126
|
+
const response = await fetch(avatarUrl, {
|
|
127
|
+
headers: {
|
|
128
|
+
"user-agent": "birdclaw/avatar-cache",
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new Error(`Avatar fetch failed with ${response.status}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
136
|
+
return {
|
|
137
|
+
contentType: response.headers.get("content-type") ?? "image/jpeg",
|
|
138
|
+
buffer,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function readCachedAvatar(profileId: string) {
|
|
143
|
+
const avatarUrl = getAvatarUrlForProfile(profileId);
|
|
144
|
+
if (!avatarUrl) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
|
149
|
+
if (!normalizedAvatarUrl) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cachePath = getAvatarCachePath(profileId, normalizedAvatarUrl);
|
|
154
|
+
const cachedExtension = path.extname(cachePath);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
return {
|
|
158
|
+
buffer: readFileSync(cachePath),
|
|
159
|
+
contentType: getContentTypeFromExtension(cachedExtension),
|
|
160
|
+
cachePath,
|
|
161
|
+
avatarUrl: normalizedAvatarUrl,
|
|
162
|
+
};
|
|
163
|
+
} catch {
|
|
164
|
+
const payload = normalizedAvatarUrl.startsWith("data:")
|
|
165
|
+
? decodeDataUrl(normalizedAvatarUrl)
|
|
166
|
+
: await fetchRemoteAvatar(normalizedAvatarUrl);
|
|
167
|
+
|
|
168
|
+
writeFileSync(cachePath, payload.buffer);
|
|
169
|
+
return {
|
|
170
|
+
buffer: payload.buffer,
|
|
171
|
+
contentType: payload.contentType,
|
|
172
|
+
cachePath,
|
|
173
|
+
avatarUrl: normalizedAvatarUrl,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const __test__ = {
|
|
179
|
+
decodeDataUrl,
|
|
180
|
+
getAvatarCacheDir,
|
|
181
|
+
getContentTypeFromExtension,
|
|
182
|
+
getExtensionFromAvatarUrl,
|
|
183
|
+
sanitizeFileToken,
|
|
184
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { getBirdCommand } from "./config";
|
|
4
|
+
import type { XurlMentionUser } from "./types";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
function liveWritesDisabled() {
|
|
9
|
+
return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripAnsi(value: string) {
|
|
13
|
+
// ANSI escape parsing needs a constructor to avoid literal control characters.
|
|
14
|
+
return value.replace(new RegExp("\\u001b\\[[0-9;]*m", "g"), "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatExecError(error: unknown, fallback: string) {
|
|
18
|
+
if (!(error instanceof Error)) {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parts = [error.message];
|
|
23
|
+
if (
|
|
24
|
+
"stdout" in error &&
|
|
25
|
+
typeof error.stdout === "string" &&
|
|
26
|
+
error.stdout.trim().length > 0
|
|
27
|
+
) {
|
|
28
|
+
parts.push(stripAnsi(error.stdout).trim());
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
"stderr" in error &&
|
|
32
|
+
typeof error.stderr === "string" &&
|
|
33
|
+
error.stderr.trim().length > 0
|
|
34
|
+
) {
|
|
35
|
+
parts.push(stripAnsi(error.stderr).trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return parts.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeOutput(stdout?: string, stderr?: string) {
|
|
42
|
+
return stripAnsi(stdout || stderr || "ok").trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runBirdCommand(args: string[]) {
|
|
46
|
+
const birdCommand = getBirdCommand();
|
|
47
|
+
return execFileAsync(birdCommand, args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runBirdJsonCommand(args: string[]) {
|
|
51
|
+
const { stdout } = await runBirdCommand(args);
|
|
52
|
+
return JSON.parse(stripAnsi(stdout)) as Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readBirdStatusViaBird(query: string) {
|
|
56
|
+
try {
|
|
57
|
+
const payload = await runBirdJsonCommand(["status", query, "--json"]);
|
|
58
|
+
return payload;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toBirdLookupUser(payload: Record<string, unknown>): XurlMentionUser {
|
|
65
|
+
const user =
|
|
66
|
+
payload.user && typeof payload.user === "object"
|
|
67
|
+
? (payload.user as Record<string, unknown>)
|
|
68
|
+
: null;
|
|
69
|
+
if (!user) {
|
|
70
|
+
throw new Error("bird user payload missing user object");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const id = typeof user.id === "string" ? user.id : "";
|
|
74
|
+
const username =
|
|
75
|
+
typeof user.username === "string"
|
|
76
|
+
? user.username.replace(/^@/, "").trim()
|
|
77
|
+
: "";
|
|
78
|
+
if (!id || !username) {
|
|
79
|
+
throw new Error("bird user payload missing id or username");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const followersCount = Number(user.followersCount ?? 0);
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
name:
|
|
86
|
+
typeof user.name === "string" && user.name.trim().length > 0
|
|
87
|
+
? user.name
|
|
88
|
+
: username,
|
|
89
|
+
username,
|
|
90
|
+
description:
|
|
91
|
+
typeof user.description === "string" ? user.description : undefined,
|
|
92
|
+
profile_image_url:
|
|
93
|
+
typeof user.profileImageUrl === "string"
|
|
94
|
+
? user.profileImageUrl
|
|
95
|
+
: undefined,
|
|
96
|
+
public_metrics: {
|
|
97
|
+
followers_count: Number.isFinite(followersCount) ? followersCount : 0,
|
|
98
|
+
},
|
|
99
|
+
created_at: typeof user.createdAt === "string" ? user.createdAt : undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function lookupProfileViaBird(query: string) {
|
|
104
|
+
try {
|
|
105
|
+
const payload = await runBirdJsonCommand([
|
|
106
|
+
"user",
|
|
107
|
+
query,
|
|
108
|
+
"-n",
|
|
109
|
+
"1",
|
|
110
|
+
"--json",
|
|
111
|
+
]);
|
|
112
|
+
return toBirdLookupUser(payload);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function runVerifiedBirdMutation({
|
|
119
|
+
action,
|
|
120
|
+
query,
|
|
121
|
+
verifyField,
|
|
122
|
+
expectedValue,
|
|
123
|
+
}: {
|
|
124
|
+
action: "block" | "unblock" | "mute" | "unmute";
|
|
125
|
+
query: string;
|
|
126
|
+
verifyField: "blocking" | "muting";
|
|
127
|
+
expectedValue: boolean;
|
|
128
|
+
}) {
|
|
129
|
+
if (liveWritesDisabled()) {
|
|
130
|
+
return { ok: true, output: "live writes disabled" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let baseOutput = "";
|
|
134
|
+
try {
|
|
135
|
+
const { stdout, stderr } = await runBirdCommand([action, query]);
|
|
136
|
+
baseOutput = normalizeOutput(stdout, stderr);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
output: formatExecError(error, `bird ${action} failed`),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const status = await readBirdStatusViaBird(query);
|
|
145
|
+
if (!status || typeof status[verifyField] !== "boolean") {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
output: `${baseOutput}; bird status verify unavailable`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const actualValue = Boolean(status[verifyField]);
|
|
153
|
+
if (actualValue !== expectedValue) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
output: `${baseOutput}; bird status verify ${verifyField}=${String(actualValue)}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
output: `${baseOutput}; verified ${verifyField}=${String(actualValue)}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function blockUserViaBird(query: string) {
|
|
167
|
+
return runVerifiedBirdMutation({
|
|
168
|
+
action: "block",
|
|
169
|
+
query,
|
|
170
|
+
verifyField: "blocking",
|
|
171
|
+
expectedValue: true,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function unblockUserViaBird(query: string) {
|
|
176
|
+
return runVerifiedBirdMutation({
|
|
177
|
+
action: "unblock",
|
|
178
|
+
query,
|
|
179
|
+
verifyField: "blocking",
|
|
180
|
+
expectedValue: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function muteUserViaBird(query: string) {
|
|
185
|
+
return runVerifiedBirdMutation({
|
|
186
|
+
action: "mute",
|
|
187
|
+
query,
|
|
188
|
+
verifyField: "muting",
|
|
189
|
+
expectedValue: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function unmuteUserViaBird(query: string) {
|
|
194
|
+
return runVerifiedBirdMutation({
|
|
195
|
+
action: "unmute",
|
|
196
|
+
query,
|
|
197
|
+
verifyField: "muting",
|
|
198
|
+
expectedValue: false,
|
|
199
|
+
});
|
|
200
|
+
}
|
package/src/lib/bird.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { getBirdCommand } from "./config";
|
|
4
|
+
import type {
|
|
5
|
+
XurlMentionData,
|
|
6
|
+
XurlMentionsResponse,
|
|
7
|
+
XurlMentionUser,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
interface BirdMentionMedia {
|
|
13
|
+
type?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface BirdMentionAuthor {
|
|
18
|
+
username?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface BirdMentionItem {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
replyCount?: number;
|
|
27
|
+
retweetCount?: number;
|
|
28
|
+
likeCount?: number;
|
|
29
|
+
conversationId?: string;
|
|
30
|
+
inReplyToStatusId?: string;
|
|
31
|
+
author?: BirdMentionAuthor;
|
|
32
|
+
authorId?: string;
|
|
33
|
+
media?: BirdMentionMedia[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BirdDmUser {
|
|
37
|
+
id: string;
|
|
38
|
+
username?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
profileImageUrl?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BirdDmEvent {
|
|
44
|
+
id: string;
|
|
45
|
+
conversationId?: string;
|
|
46
|
+
text: string;
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
senderId?: string;
|
|
49
|
+
recipientId?: string;
|
|
50
|
+
sender?: BirdDmUser;
|
|
51
|
+
recipient?: BirdDmUser;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BirdDmConversation {
|
|
55
|
+
id: string;
|
|
56
|
+
participants: BirdDmUser[];
|
|
57
|
+
messages: BirdDmEvent[];
|
|
58
|
+
lastMessageAt?: string;
|
|
59
|
+
lastMessagePreview?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BirdDmsResponse {
|
|
63
|
+
success: true;
|
|
64
|
+
conversations: BirdDmConversation[];
|
|
65
|
+
events: BirdDmEvent[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toIsoTimestamp(value: string) {
|
|
69
|
+
const parsed = new Date(value);
|
|
70
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
return parsed.toISOString();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toMediaEntities(media: BirdMentionMedia[] | undefined) {
|
|
77
|
+
if (!Array.isArray(media) || media.length === 0) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
urls: media
|
|
83
|
+
.filter((item) => typeof item?.url === "string" && item.url.length > 0)
|
|
84
|
+
.map((item, index) => ({
|
|
85
|
+
start: index,
|
|
86
|
+
end: index,
|
|
87
|
+
url: item.url as string,
|
|
88
|
+
expanded_url: item.url as string,
|
|
89
|
+
display_url: item.url as string,
|
|
90
|
+
media_key: `bird_media_${index}`,
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeBirdMentions(items: BirdMentionItem[]): XurlMentionsResponse {
|
|
96
|
+
const users = new Map<string, XurlMentionUser>();
|
|
97
|
+
const data = items.map((item): XurlMentionData => {
|
|
98
|
+
const authorId = String(
|
|
99
|
+
item.authorId ?? item.author?.username ?? "unknown",
|
|
100
|
+
);
|
|
101
|
+
if (!users.has(authorId)) {
|
|
102
|
+
users.set(authorId, {
|
|
103
|
+
id: authorId,
|
|
104
|
+
username: item.author?.username ?? `user_${authorId}`,
|
|
105
|
+
name: item.author?.name ?? item.author?.username ?? `user_${authorId}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id: item.id,
|
|
111
|
+
author_id: authorId,
|
|
112
|
+
text: item.text,
|
|
113
|
+
created_at: toIsoTimestamp(item.createdAt),
|
|
114
|
+
conversation_id: item.conversationId ?? item.id,
|
|
115
|
+
entities: toMediaEntities(item.media),
|
|
116
|
+
public_metrics: {
|
|
117
|
+
reply_count: Number(item.replyCount ?? 0),
|
|
118
|
+
retweet_count: Number(item.retweetCount ?? 0),
|
|
119
|
+
like_count: Number(item.likeCount ?? 0),
|
|
120
|
+
},
|
|
121
|
+
edit_history_tweet_ids: [item.id],
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
data,
|
|
127
|
+
includes:
|
|
128
|
+
users.size > 0 ? { users: Array.from(users.values()) } : undefined,
|
|
129
|
+
meta: {
|
|
130
|
+
result_count: data.length,
|
|
131
|
+
page_count: 1,
|
|
132
|
+
next_token: null,
|
|
133
|
+
...(data[0] ? { newest_id: data[0].id } : {}),
|
|
134
|
+
...(data.at(-1) ? { oldest_id: data.at(-1)?.id } : {}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function listMentionsViaBird({
|
|
140
|
+
maxResults,
|
|
141
|
+
}: {
|
|
142
|
+
maxResults: number;
|
|
143
|
+
}): Promise<XurlMentionsResponse> {
|
|
144
|
+
const birdCommand = getBirdCommand();
|
|
145
|
+
const { stdout } = await execFileAsync(birdCommand, [
|
|
146
|
+
"mentions",
|
|
147
|
+
"-n",
|
|
148
|
+
String(maxResults),
|
|
149
|
+
"--json",
|
|
150
|
+
]);
|
|
151
|
+
const payload = JSON.parse(stdout) as unknown;
|
|
152
|
+
if (!Array.isArray(payload)) {
|
|
153
|
+
throw new Error("bird mentions returned unexpected JSON");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return normalizeBirdMentions(payload as BirdMentionItem[]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function listDirectMessagesViaBird({
|
|
160
|
+
maxResults,
|
|
161
|
+
}: {
|
|
162
|
+
maxResults: number;
|
|
163
|
+
}): Promise<BirdDmsResponse> {
|
|
164
|
+
const birdCommand = getBirdCommand();
|
|
165
|
+
const { stdout } = await execFileAsync(birdCommand, [
|
|
166
|
+
"dms",
|
|
167
|
+
"-n",
|
|
168
|
+
String(maxResults),
|
|
169
|
+
"--json",
|
|
170
|
+
]);
|
|
171
|
+
const payload = JSON.parse(stdout) as unknown;
|
|
172
|
+
if (
|
|
173
|
+
!payload ||
|
|
174
|
+
typeof payload !== "object" ||
|
|
175
|
+
(payload as { success?: unknown }).success !== true ||
|
|
176
|
+
!Array.isArray((payload as { conversations?: unknown }).conversations) ||
|
|
177
|
+
!Array.isArray((payload as { events?: unknown }).events)
|
|
178
|
+
) {
|
|
179
|
+
throw new Error("bird dms returned unexpected JSON");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return payload as BirdDmsResponse;
|
|
183
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { addBlock } from "./blocks";
|
|
3
|
+
|
|
4
|
+
export interface BlocklistImportItem {
|
|
5
|
+
query: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
blockedAt?: string;
|
|
8
|
+
handle?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BlocklistImportResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
accountId: string;
|
|
15
|
+
path: string;
|
|
16
|
+
requestedCount: number;
|
|
17
|
+
blockedCount: number;
|
|
18
|
+
failedCount: number;
|
|
19
|
+
items: BlocklistImportItem[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stripInlineCode(value: string) {
|
|
23
|
+
const match = value.match(/`([^`]+)`/);
|
|
24
|
+
return match?.[1] ?? value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractQueryFromLine(value: string) {
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
!trimmed.startsWith("@") &&
|
|
35
|
+
!trimmed.startsWith("-") &&
|
|
36
|
+
!trimmed.startsWith("*") &&
|
|
37
|
+
!trimmed.startsWith("+") &&
|
|
38
|
+
!trimmed.startsWith("`") &&
|
|
39
|
+
!trimmed.startsWith("http") &&
|
|
40
|
+
trimmed.split(/\s+/).length > 1
|
|
41
|
+
) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const withoutBullet = trimmed.replace(/^(?:[-*+]|\d+\.)\s+/, "");
|
|
46
|
+
const withoutCode = stripInlineCode(withoutBullet).trim();
|
|
47
|
+
if (!withoutCode) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (/^(?:https?:\/\/)/i.test(withoutCode)) {
|
|
52
|
+
return withoutCode.split(/\s+/)[0] ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
withoutCode
|
|
57
|
+
.split(/[,\s:;]+/)[0]
|
|
58
|
+
?.replace(/^["'`]+|["'`,.]+$/g, "")
|
|
59
|
+
.trim() ?? null
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseBlocklistText(text: string) {
|
|
64
|
+
const items: string[] = [];
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
|
|
67
|
+
for (const line of text.split(/\r?\n/)) {
|
|
68
|
+
const query = extractQueryFromLine(line);
|
|
69
|
+
if (!query || seen.has(query)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seen.add(query);
|
|
73
|
+
items.push(query);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return items;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function importBlocklist(accountId: string, filePath: string) {
|
|
80
|
+
const text = await readFile(filePath, "utf8");
|
|
81
|
+
const queries = parseBlocklistText(text);
|
|
82
|
+
const items: BlocklistImportItem[] = [];
|
|
83
|
+
|
|
84
|
+
for (const query of queries) {
|
|
85
|
+
try {
|
|
86
|
+
const result = await addBlock(accountId, query);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
items.push({
|
|
89
|
+
query,
|
|
90
|
+
ok: false,
|
|
91
|
+
error: result.transport.output || "block failed",
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
items.push({
|
|
96
|
+
query,
|
|
97
|
+
ok: true,
|
|
98
|
+
blockedAt: result.blockedAt,
|
|
99
|
+
handle: result.profile.handle,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
items.push({
|
|
103
|
+
query,
|
|
104
|
+
ok: false,
|
|
105
|
+
error: error instanceof Error ? error.message : "block failed",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ok: items.every((item) => item.ok),
|
|
112
|
+
accountId,
|
|
113
|
+
path: filePath,
|
|
114
|
+
requestedCount: queries.length,
|
|
115
|
+
blockedCount: items.filter((item) => item.ok).length,
|
|
116
|
+
failedCount: items.filter((item) => !item.ok).length,
|
|
117
|
+
items,
|
|
118
|
+
} satisfies BlocklistImportResult;
|
|
119
|
+
}
|