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,118 @@
|
|
|
1
|
+
import { runModerationAction } from "./actions-transport";
|
|
2
|
+
import {
|
|
3
|
+
deleteModerationRow,
|
|
4
|
+
type ModerationActionOptions,
|
|
5
|
+
resolveModerationTarget,
|
|
6
|
+
writeModerationRow,
|
|
7
|
+
} from "./moderation-write";
|
|
8
|
+
|
|
9
|
+
export async function addMute(
|
|
10
|
+
accountId: string,
|
|
11
|
+
query: string,
|
|
12
|
+
options: ModerationActionOptions = {},
|
|
13
|
+
) {
|
|
14
|
+
const { db, resolved, resolvedAccountId, actionQuery } =
|
|
15
|
+
await resolveModerationTarget({
|
|
16
|
+
accountId,
|
|
17
|
+
query,
|
|
18
|
+
selfActionError: "Cannot mute the current account",
|
|
19
|
+
});
|
|
20
|
+
const transport = await runModerationAction({
|
|
21
|
+
action: "mute",
|
|
22
|
+
query: actionQuery,
|
|
23
|
+
targetUserId: resolved.externalUserId,
|
|
24
|
+
transport: options.transport,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!transport.ok) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
action: "mute",
|
|
31
|
+
accountId: resolvedAccountId,
|
|
32
|
+
profile: resolved.profile,
|
|
33
|
+
transport,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mutedAt = new Date().toISOString();
|
|
38
|
+
writeModerationRow(
|
|
39
|
+
db,
|
|
40
|
+
"mutes",
|
|
41
|
+
resolvedAccountId,
|
|
42
|
+
resolved.profile.id,
|
|
43
|
+
mutedAt,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
action: "mute",
|
|
49
|
+
accountId: resolvedAccountId,
|
|
50
|
+
mutedAt,
|
|
51
|
+
profile: resolved.profile,
|
|
52
|
+
transport,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function recordMute(accountId: string, query: string) {
|
|
57
|
+
const { db, resolved, resolvedAccountId } = await resolveModerationTarget({
|
|
58
|
+
accountId,
|
|
59
|
+
query,
|
|
60
|
+
selfActionError: "Cannot mute the current account",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const mutedAt = new Date().toISOString();
|
|
64
|
+
writeModerationRow(
|
|
65
|
+
db,
|
|
66
|
+
"mutes",
|
|
67
|
+
resolvedAccountId,
|
|
68
|
+
resolved.profile.id,
|
|
69
|
+
mutedAt,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
action: "record-mute",
|
|
75
|
+
accountId: resolvedAccountId,
|
|
76
|
+
mutedAt,
|
|
77
|
+
profile: resolved.profile,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeMute(
|
|
82
|
+
accountId: string,
|
|
83
|
+
query: string,
|
|
84
|
+
options: ModerationActionOptions = {},
|
|
85
|
+
) {
|
|
86
|
+
const { db, resolved, resolvedAccountId, actionQuery } =
|
|
87
|
+
await resolveModerationTarget({
|
|
88
|
+
accountId,
|
|
89
|
+
query,
|
|
90
|
+
selfActionError: "Cannot mute the current account",
|
|
91
|
+
});
|
|
92
|
+
const transport = await runModerationAction({
|
|
93
|
+
action: "unmute",
|
|
94
|
+
query: actionQuery,
|
|
95
|
+
targetUserId: resolved.externalUserId,
|
|
96
|
+
transport: options.transport,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!transport.ok) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
action: "unmute",
|
|
103
|
+
accountId: resolvedAccountId,
|
|
104
|
+
profile: resolved.profile,
|
|
105
|
+
transport,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
deleteModerationRow(db, "mutes", resolvedAccountId, resolved.profile.id);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
action: "unmute",
|
|
114
|
+
accountId: resolvedAccountId,
|
|
115
|
+
profile: resolved.profile,
|
|
116
|
+
transport,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/lib/mutes.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getNativeDb } from "./db";
|
|
2
|
+
import { toProfile } from "./moderation-target";
|
|
3
|
+
import type { BlockItem } from "./types";
|
|
4
|
+
|
|
5
|
+
export { addMute, recordMute, removeMute } from "./mutes-write";
|
|
6
|
+
|
|
7
|
+
export interface MuteItem {
|
|
8
|
+
accountId: string;
|
|
9
|
+
accountHandle: string;
|
|
10
|
+
source: string;
|
|
11
|
+
mutedAt: string;
|
|
12
|
+
profile: BlockItem["profile"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function listMutes({
|
|
16
|
+
account,
|
|
17
|
+
search,
|
|
18
|
+
limit = 50,
|
|
19
|
+
}: {
|
|
20
|
+
account?: string;
|
|
21
|
+
search?: string;
|
|
22
|
+
limit?: number;
|
|
23
|
+
} = {}): MuteItem[] {
|
|
24
|
+
const db = getNativeDb();
|
|
25
|
+
const params: Array<string | number> = [];
|
|
26
|
+
let where = "where 1 = 1";
|
|
27
|
+
|
|
28
|
+
if (account && account !== "all") {
|
|
29
|
+
where += " and m.account_id = ?";
|
|
30
|
+
params.push(account);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (search?.trim()) {
|
|
34
|
+
where += " and (p.handle like ? or p.display_name like ? or p.bio like ?)";
|
|
35
|
+
params.push(
|
|
36
|
+
`%${search.trim()}%`,
|
|
37
|
+
`%${search.trim()}%`,
|
|
38
|
+
`%${search.trim()}%`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
params.push(limit);
|
|
43
|
+
|
|
44
|
+
const rows = db
|
|
45
|
+
.prepare(
|
|
46
|
+
`
|
|
47
|
+
select
|
|
48
|
+
m.account_id,
|
|
49
|
+
a.handle as account_handle,
|
|
50
|
+
m.source,
|
|
51
|
+
m.created_at as muted_at,
|
|
52
|
+
p.id,
|
|
53
|
+
p.handle,
|
|
54
|
+
p.display_name,
|
|
55
|
+
p.bio,
|
|
56
|
+
p.followers_count,
|
|
57
|
+
p.avatar_hue,
|
|
58
|
+
p.avatar_url,
|
|
59
|
+
p.created_at
|
|
60
|
+
from mutes m
|
|
61
|
+
join accounts a on a.id = m.account_id
|
|
62
|
+
join profiles p on p.id = m.profile_id
|
|
63
|
+
${where}
|
|
64
|
+
order by m.created_at desc
|
|
65
|
+
limit ?
|
|
66
|
+
`,
|
|
67
|
+
)
|
|
68
|
+
.all(...params) as Array<Record<string, unknown>>;
|
|
69
|
+
|
|
70
|
+
return rows.map((row) => ({
|
|
71
|
+
accountId: String(row.account_id),
|
|
72
|
+
accountHandle: String(row.account_handle),
|
|
73
|
+
source: String(row.source),
|
|
74
|
+
mutedAt: String(row.muted_at),
|
|
75
|
+
profile: toProfile(row),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface OpenAIInboxScore {
|
|
2
|
+
score: number;
|
|
3
|
+
summary: string;
|
|
4
|
+
reasoning: string;
|
|
5
|
+
model: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface OpenAIInboxInput {
|
|
9
|
+
entityKind: "mention" | "dm";
|
|
10
|
+
title: string;
|
|
11
|
+
text: string;
|
|
12
|
+
participant: {
|
|
13
|
+
handle: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
bio: string;
|
|
16
|
+
followersCount: number;
|
|
17
|
+
};
|
|
18
|
+
influenceScore: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clampScore(value: number) {
|
|
22
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function scoreInboxItemWithOpenAI(
|
|
26
|
+
input: OpenAIInboxInput,
|
|
27
|
+
): Promise<OpenAIInboxScore> {
|
|
28
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
29
|
+
if (!apiKey) {
|
|
30
|
+
throw new Error("OPENAI_API_KEY is not set");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const model = process.env.BIRDCLAW_OPENAI_MODEL || "gpt-5.2";
|
|
34
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
authorization: `Bearer ${apiKey}`,
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
model,
|
|
42
|
+
response_format: { type: "json_object" },
|
|
43
|
+
messages: [
|
|
44
|
+
{
|
|
45
|
+
role: "system",
|
|
46
|
+
content:
|
|
47
|
+
"You rank inbound X mentions and DMs for Peter Steinberger. Return JSON only with keys score, summary, reasoning. Score 0-100. High score means worth replying soon. Prefer specific, actionable, novel, high-signal items. Penalize generic praise, low-context asks, and low-signal chatter. summary max 18 words. reasoning max 28 words.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
role: "user",
|
|
51
|
+
content: JSON.stringify(input),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`OpenAI request failed: ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const payload = (await response.json()) as {
|
|
62
|
+
choices?: Array<{
|
|
63
|
+
message?: {
|
|
64
|
+
content?: string;
|
|
65
|
+
};
|
|
66
|
+
}>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const content = payload.choices?.[0]?.message?.content;
|
|
70
|
+
if (!content) {
|
|
71
|
+
throw new Error("OpenAI returned no content");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parsed = JSON.parse(content) as {
|
|
75
|
+
score?: number;
|
|
76
|
+
summary?: string;
|
|
77
|
+
reasoning?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
model,
|
|
82
|
+
score: clampScore(parsed.score ?? 0),
|
|
83
|
+
summary: String(parsed.summary ?? "No summary"),
|
|
84
|
+
reasoning: String(parsed.reasoning ?? "No reasoning"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function formatCompactNumber(value: number) {
|
|
2
|
+
return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatShortTimestamp(value: string) {
|
|
6
|
+
return new Intl.DateTimeFormat("en", {
|
|
7
|
+
hour: "numeric",
|
|
8
|
+
minute: "2-digit",
|
|
9
|
+
month: "short",
|
|
10
|
+
day: "numeric",
|
|
11
|
+
}).format(new Date(value));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getInitials(value: string) {
|
|
15
|
+
return value
|
|
16
|
+
.split(" ")
|
|
17
|
+
.map((part) => part[0] ?? "")
|
|
18
|
+
.join("")
|
|
19
|
+
.slice(0, 2);
|
|
20
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { normalizeAvatarUrl } from "./avatar-cache";
|
|
2
|
+
import { getNativeDb } from "./db";
|
|
3
|
+
import {
|
|
4
|
+
getTransportStatus,
|
|
5
|
+
lookupAuthenticatedUser,
|
|
6
|
+
lookupUsersByIds,
|
|
7
|
+
} from "./xurl";
|
|
8
|
+
|
|
9
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
10
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
11
|
+
? (value as Record<string, unknown>)
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toInt(value: unknown) {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function hydrateProfilesFromX() {
|
|
21
|
+
const transport = await getTransportStatus();
|
|
22
|
+
if (transport.availableTransport !== "xurl") {
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
hydratedProfiles: 0,
|
|
26
|
+
hydratedAccount: false,
|
|
27
|
+
reason: transport.statusText,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const db = getNativeDb();
|
|
32
|
+
const candidateRows = db
|
|
33
|
+
.prepare(
|
|
34
|
+
`
|
|
35
|
+
select id
|
|
36
|
+
from profiles
|
|
37
|
+
where id like 'profile_user_%'
|
|
38
|
+
and (followers_count = 0 or bio like 'Imported from archive user %' or handle like 'id%')
|
|
39
|
+
order by id asc
|
|
40
|
+
`,
|
|
41
|
+
)
|
|
42
|
+
.all() as Array<{ id: string }>;
|
|
43
|
+
|
|
44
|
+
const candidateIds = candidateRows
|
|
45
|
+
.map((row) => row.id.replace(/^profile_user_/, ""))
|
|
46
|
+
.filter((id) => id.length > 0);
|
|
47
|
+
|
|
48
|
+
const updateProfile = db.prepare(`
|
|
49
|
+
update profiles
|
|
50
|
+
set handle = ?,
|
|
51
|
+
display_name = ?,
|
|
52
|
+
bio = ?,
|
|
53
|
+
followers_count = ?,
|
|
54
|
+
avatar_url = coalesce(?, avatar_url),
|
|
55
|
+
created_at = coalesce(?, created_at)
|
|
56
|
+
where id = ?
|
|
57
|
+
`);
|
|
58
|
+
const updateConversationTitle = db.prepare(`
|
|
59
|
+
update dm_conversations
|
|
60
|
+
set title = ?
|
|
61
|
+
where participant_profile_id = ?
|
|
62
|
+
`);
|
|
63
|
+
const updateLocalProfile = db.prepare(`
|
|
64
|
+
update profiles
|
|
65
|
+
set handle = ?,
|
|
66
|
+
display_name = ?,
|
|
67
|
+
bio = ?,
|
|
68
|
+
followers_count = ?,
|
|
69
|
+
avatar_url = coalesce(?, avatar_url),
|
|
70
|
+
created_at = coalesce(?, created_at)
|
|
71
|
+
where id = 'profile_me'
|
|
72
|
+
`);
|
|
73
|
+
const updateAccount = db.prepare(`
|
|
74
|
+
update accounts
|
|
75
|
+
set name = ?,
|
|
76
|
+
handle = ?,
|
|
77
|
+
transport = 'xurl'
|
|
78
|
+
where id = 'acct_primary'
|
|
79
|
+
`);
|
|
80
|
+
|
|
81
|
+
let hydratedProfiles = 0;
|
|
82
|
+
|
|
83
|
+
for (let index = 0; index < candidateIds.length; index += 100) {
|
|
84
|
+
const batch = candidateIds.slice(index, index + 100);
|
|
85
|
+
const users = await lookupUsersByIds(batch);
|
|
86
|
+
|
|
87
|
+
db.transaction(() => {
|
|
88
|
+
for (const user of users) {
|
|
89
|
+
const metrics = asRecord(user.public_metrics);
|
|
90
|
+
const profileId = `profile_user_${String(user.id ?? "")}`;
|
|
91
|
+
if (profileId === "profile_user_") continue;
|
|
92
|
+
|
|
93
|
+
const username = String(user.username ?? "").replace(/^@/, "");
|
|
94
|
+
const displayName = String(user.name ?? username);
|
|
95
|
+
updateProfile.run(
|
|
96
|
+
username || profileId,
|
|
97
|
+
displayName || username || profileId,
|
|
98
|
+
String(user.description ?? ""),
|
|
99
|
+
toInt(metrics?.followers_count),
|
|
100
|
+
normalizeAvatarUrl(user.profile_image_url),
|
|
101
|
+
typeof user.created_at === "string" ? user.created_at : null,
|
|
102
|
+
profileId,
|
|
103
|
+
);
|
|
104
|
+
updateConversationTitle.run(
|
|
105
|
+
displayName || username || profileId,
|
|
106
|
+
profileId,
|
|
107
|
+
);
|
|
108
|
+
hydratedProfiles += 1;
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let hydratedAccount = false;
|
|
114
|
+
const me = await lookupAuthenticatedUser().catch(() => null);
|
|
115
|
+
if (me) {
|
|
116
|
+
const metrics = asRecord(me.public_metrics);
|
|
117
|
+
db.transaction(() => {
|
|
118
|
+
updateLocalProfile.run(
|
|
119
|
+
String(me.username ?? "steipete").replace(/^@/, ""),
|
|
120
|
+
String(me.name ?? "Peter Steinberger"),
|
|
121
|
+
String(me.description ?? ""),
|
|
122
|
+
toInt(metrics?.followers_count),
|
|
123
|
+
normalizeAvatarUrl(me.profile_image_url),
|
|
124
|
+
typeof me.created_at === "string" ? me.created_at : null,
|
|
125
|
+
);
|
|
126
|
+
updateAccount.run(
|
|
127
|
+
String(me.name ?? "Peter Steinberger"),
|
|
128
|
+
`@${String(me.username ?? "steipete").replace(/^@/, "")}`,
|
|
129
|
+
);
|
|
130
|
+
})();
|
|
131
|
+
hydratedAccount = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
hydratedProfiles,
|
|
137
|
+
hydratedAccount,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const __test__ = {
|
|
142
|
+
asRecord,
|
|
143
|
+
toInt,
|
|
144
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { resolveProfile } from "./moderation-target";
|
|
2
|
+
import type { ProfileRepliesResponse, XurlReferencedTweet } from "./types";
|
|
3
|
+
import { listUserTweets } from "./xurl";
|
|
4
|
+
|
|
5
|
+
function getReplyTargetId(references?: XurlReferencedTweet[]) {
|
|
6
|
+
return references?.find((item) => item.type === "replied_to")?.id;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getScanSize(limit: number) {
|
|
10
|
+
return Math.min(Math.max(limit * 3, 20), 100);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function inspectProfileReplies(
|
|
14
|
+
query: string,
|
|
15
|
+
{ limit = 12 }: { limit?: number } = {},
|
|
16
|
+
): Promise<ProfileRepliesResponse> {
|
|
17
|
+
const resolved = await resolveProfile(query);
|
|
18
|
+
if (!resolved.externalUserId) {
|
|
19
|
+
throw new Error(`Profile has no external X user id: ${query}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const timeline = await listUserTweets(resolved.externalUserId, {
|
|
23
|
+
maxResults: getScanSize(limit),
|
|
24
|
+
excludeRetweets: true,
|
|
25
|
+
});
|
|
26
|
+
const items = timeline.items
|
|
27
|
+
.map((tweet) => {
|
|
28
|
+
const replyToTweetId = getReplyTargetId(tweet.referenced_tweets);
|
|
29
|
+
if (!replyToTweetId) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
id: tweet.id,
|
|
35
|
+
text: tweet.text,
|
|
36
|
+
createdAt: tweet.created_at,
|
|
37
|
+
conversationId: tweet.conversation_id,
|
|
38
|
+
replyToTweetId,
|
|
39
|
+
likeCount: Number(tweet.public_metrics?.like_count ?? 0),
|
|
40
|
+
replyCount: Number(tweet.public_metrics?.reply_count ?? 0),
|
|
41
|
+
retweetCount: Number(tweet.public_metrics?.retweet_count ?? 0),
|
|
42
|
+
quoteCount: Number(tweet.public_metrics?.quote_count ?? 0),
|
|
43
|
+
bookmarkCount: Number(tweet.public_metrics?.bookmark_count ?? 0),
|
|
44
|
+
impressionCount: Number(tweet.public_metrics?.impression_count ?? 0),
|
|
45
|
+
};
|
|
46
|
+
})
|
|
47
|
+
.filter((item): item is NonNullable<typeof item> => item !== null)
|
|
48
|
+
.slice(0, limit);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
profile: resolved.profile,
|
|
52
|
+
externalUserId: resolved.externalUserId,
|
|
53
|
+
items,
|
|
54
|
+
meta: {
|
|
55
|
+
scannedCount: timeline.items.length,
|
|
56
|
+
returnedCount: items.length,
|
|
57
|
+
nextToken: timeline.nextToken,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|