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
package/src/lib/ui.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
export function cx(...values: Array<string | false | null | undefined>) {
|
|
2
|
+
return values.filter(Boolean).join(" ");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const bodyClass =
|
|
6
|
+
"min-h-screen bg-[var(--bg)] font-sans text-[var(--ink)] antialiased transition-[background,color] duration-200";
|
|
7
|
+
|
|
8
|
+
export const siteShellClass =
|
|
9
|
+
"mx-auto w-[min(1120px,calc(100vw-32px))] pb-12 pt-[18px] max-[760px]:w-[min(100vw-20px,100%)]";
|
|
10
|
+
|
|
11
|
+
export const pageWrapClass = "pt-6";
|
|
12
|
+
|
|
13
|
+
export const feedPageClass = "mx-auto grid w-full max-w-[960px] gap-6";
|
|
14
|
+
|
|
15
|
+
export const timelineLaneClass = "mx-auto grid w-full max-w-[680px] gap-4";
|
|
16
|
+
|
|
17
|
+
export const inboxLaneClass = "mx-auto grid w-full max-w-[760px] gap-4";
|
|
18
|
+
|
|
19
|
+
export const dmPageClass = "mx-auto grid w-full max-w-[1040px] gap-6";
|
|
20
|
+
|
|
21
|
+
export const eyebrowClass =
|
|
22
|
+
"m-0 mb-2 text-[11px] uppercase tracking-[0.24em] text-[var(--ink-soft)]";
|
|
23
|
+
|
|
24
|
+
export const brandMarkClass =
|
|
25
|
+
"m-0 max-w-[12ch] font-display text-[clamp(1.8rem,3vw,3.2rem)] leading-[0.95] font-semibold";
|
|
26
|
+
|
|
27
|
+
export const navClass =
|
|
28
|
+
"flex items-end justify-between gap-6 border-b border-[var(--line)] py-[18px] pb-5 max-[760px]:flex-col max-[760px]:items-start";
|
|
29
|
+
|
|
30
|
+
export const navLinksClass = "nav-links flex flex-wrap items-center gap-2.5";
|
|
31
|
+
|
|
32
|
+
export const navLinkClass =
|
|
33
|
+
"nav-link inline-flex items-center rounded-full border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_76%,transparent)] px-4 py-2.5 text-[var(--ink-soft)] transition duration-180 hover:-translate-y-px hover:border-[var(--line-strong)] hover:text-[var(--ink)]";
|
|
34
|
+
|
|
35
|
+
export const navLinkActiveClass =
|
|
36
|
+
"nav-link-active border-[var(--line-strong)] bg-[var(--panel)] text-[var(--ink)] shadow-[0_16px_42px_var(--shadow)]";
|
|
37
|
+
|
|
38
|
+
export const heroShellClass =
|
|
39
|
+
"flex items-end justify-between gap-6 py-7 pb-6 max-[760px]:flex-col max-[760px]:items-start";
|
|
40
|
+
|
|
41
|
+
export const heroShellDmClass = "items-start";
|
|
42
|
+
|
|
43
|
+
export const heroTitleClass =
|
|
44
|
+
"m-0 max-w-[12ch] font-display text-[clamp(2.2rem,4vw,4.4rem)] leading-[0.95]";
|
|
45
|
+
|
|
46
|
+
export const heroCopyClass =
|
|
47
|
+
"hero-copy mt-3.5 max-w-[46ch] text-base text-[var(--ink-soft)]";
|
|
48
|
+
|
|
49
|
+
export const heroControlsClass =
|
|
50
|
+
"flex flex-wrap justify-end gap-3 max-[760px]:w-full max-[760px]:justify-start";
|
|
51
|
+
|
|
52
|
+
export const heroControlsDmClass = "max-w-[420px]";
|
|
53
|
+
|
|
54
|
+
export const heroControlsBlocksClass = "max-w-[560px]";
|
|
55
|
+
|
|
56
|
+
export const textFieldClass =
|
|
57
|
+
"text-field w-full rounded-2xl border border-[var(--line)] bg-[var(--panel)] px-3.5 py-3 text-[var(--ink)] shadow-[0_18px_40px_var(--shadow)] outline-none transition duration-180 placeholder:text-[var(--ink-soft)] focus:border-[var(--line-strong)] disabled:cursor-default disabled:opacity-55";
|
|
58
|
+
|
|
59
|
+
export const textFieldWideClass = "w-[min(360px,100%)] max-[760px]:w-full";
|
|
60
|
+
|
|
61
|
+
export const textFieldShortClass =
|
|
62
|
+
"text-field-short w-[140px] max-[760px]:w-full";
|
|
63
|
+
|
|
64
|
+
export const segmentedClass =
|
|
65
|
+
"inline-flex gap-1.5 rounded-full border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_76%,transparent)] p-1.5";
|
|
66
|
+
|
|
67
|
+
export const segmentClass =
|
|
68
|
+
"segment rounded-full border-0 bg-transparent px-3.5 py-2.5 text-[var(--ink-soft)] transition duration-180";
|
|
69
|
+
|
|
70
|
+
export const segmentActiveClass =
|
|
71
|
+
"segment-active bg-[var(--panel)] text-[var(--ink)] shadow-[0_8px_22px_var(--shadow)]";
|
|
72
|
+
|
|
73
|
+
export const stackGridClass = "grid gap-4";
|
|
74
|
+
|
|
75
|
+
export const surfaceCardClass =
|
|
76
|
+
"rounded-[24px] border border-[var(--line)] bg-[var(--panel)] shadow-[0_24px_60px_var(--shadow)]";
|
|
77
|
+
|
|
78
|
+
export const contentCardClass = cx("content-card", surfaceCardClass, "p-5");
|
|
79
|
+
|
|
80
|
+
export const cardHeaderClass =
|
|
81
|
+
"flex items-start justify-between gap-3 max-[760px]:flex-col max-[760px]:items-start";
|
|
82
|
+
|
|
83
|
+
export const cardFooterClass =
|
|
84
|
+
"flex items-center justify-between gap-3 max-[760px]:flex-col max-[760px]:items-start";
|
|
85
|
+
|
|
86
|
+
export const identityBlockClass = "flex items-start gap-3.5";
|
|
87
|
+
|
|
88
|
+
export const identityRowClass =
|
|
89
|
+
"flex flex-wrap items-center gap-2.5 text-[0.92rem] text-[var(--ink-soft)]";
|
|
90
|
+
|
|
91
|
+
export const metaRowClass =
|
|
92
|
+
"flex flex-wrap items-center gap-2.5 text-[0.92rem] text-[var(--ink-soft)]";
|
|
93
|
+
|
|
94
|
+
export const mutedDotClass =
|
|
95
|
+
"muted-dot inline-block size-1 rounded-full bg-[var(--line-strong)]";
|
|
96
|
+
|
|
97
|
+
export const metaStackClass = "flex flex-col items-end gap-2";
|
|
98
|
+
|
|
99
|
+
export const pillClass =
|
|
100
|
+
"pill inline-flex items-center rounded-full px-3 py-[7px] text-[0.76rem] uppercase tracking-[0.08em]";
|
|
101
|
+
|
|
102
|
+
export const pillSoftClass =
|
|
103
|
+
"pill-soft bg-[var(--accent-soft)] text-[var(--accent)]";
|
|
104
|
+
|
|
105
|
+
export const pillAlertClass =
|
|
106
|
+
"pill-alert bg-[var(--alert-soft)] text-[var(--alert)]";
|
|
107
|
+
|
|
108
|
+
export const timestampClass = "timestamp text-[0.9rem] text-[var(--ink-soft)]";
|
|
109
|
+
|
|
110
|
+
export const bodyCopyClass = "body-copy my-4 text-[1.08rem] leading-[1.6]";
|
|
111
|
+
|
|
112
|
+
export const inboxTitleClass = "inbox-title m-0 font-display text-[1.4rem]";
|
|
113
|
+
|
|
114
|
+
export const inboxAnalysisClass =
|
|
115
|
+
"inbox-analysis pb-1 pt-4 text-[var(--ink-soft)] [&_p]:mt-2 [&_p]:mb-0";
|
|
116
|
+
|
|
117
|
+
export const metricRowClass =
|
|
118
|
+
"metric-row flex flex-wrap gap-3.5 text-[0.9rem] text-[var(--ink-soft)]";
|
|
119
|
+
|
|
120
|
+
export const actionRowClass = "flex items-center gap-2.5";
|
|
121
|
+
|
|
122
|
+
export const actionButtonClass =
|
|
123
|
+
"action-button inline-flex items-center justify-center rounded-full border-0 bg-[var(--ink)] px-4 py-[11px] text-sm font-medium text-white transition duration-180 hover:-translate-y-px disabled:cursor-default disabled:opacity-55";
|
|
124
|
+
|
|
125
|
+
export const errorCopyClass = "mb-4 text-[var(--alert)]";
|
|
126
|
+
|
|
127
|
+
export const composerShellClass =
|
|
128
|
+
"mt-2.5 border-t border-[var(--line)] pt-[18px]";
|
|
129
|
+
|
|
130
|
+
export const composerInputClass =
|
|
131
|
+
"composer-input min-h-[120px] w-full resize-y rounded-[18px] border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_88%,transparent)] px-4 py-4 text-[var(--ink)] shadow-[inset_0_1px_0_color-mix(in_srgb,white_36%,transparent)] outline-none transition duration-180 placeholder:text-[var(--ink-soft)] focus:border-[var(--line-strong)]";
|
|
132
|
+
|
|
133
|
+
export const composerBarClass =
|
|
134
|
+
"flex items-center justify-between gap-4 pt-3 max-[760px]:flex-col max-[760px]:items-start";
|
|
135
|
+
|
|
136
|
+
export const linkPreviewCardClass =
|
|
137
|
+
"grid gap-2.5 rounded-[20px] border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_84%,transparent)] px-4 py-3.5";
|
|
138
|
+
|
|
139
|
+
export const embeddedTweetCardClass = linkPreviewCardClass;
|
|
140
|
+
|
|
141
|
+
export const embeddedTweetLabelClass =
|
|
142
|
+
"embedded-tweet-label m-0 text-[0.78rem] uppercase tracking-[0.12em] text-[var(--ink-soft)]";
|
|
143
|
+
|
|
144
|
+
export const embeddedTweetHeaderClass =
|
|
145
|
+
"embedded-tweet-header flex items-center justify-between gap-2.5";
|
|
146
|
+
|
|
147
|
+
export const embeddedTweetAuthorClass =
|
|
148
|
+
"embedded-tweet-author flex flex-wrap items-center gap-2.5 text-[var(--ink-soft)]";
|
|
149
|
+
|
|
150
|
+
export const embeddedTweetCopyClass =
|
|
151
|
+
"embedded-tweet-copy m-0 text-[0.98rem] leading-[1.55]";
|
|
152
|
+
|
|
153
|
+
export const dmGridClass =
|
|
154
|
+
"grid items-start gap-4 min-[980px]:grid-cols-[minmax(280px,320px)_minmax(0,1fr)] min-[1120px]:gap-5";
|
|
155
|
+
|
|
156
|
+
export const dmListClass = cx("dm-list", surfaceCardClass, "p-2.5");
|
|
157
|
+
|
|
158
|
+
export const dmListItemClass =
|
|
159
|
+
"dm-list-item grid w-full grid-cols-[auto_1fr_auto] gap-3 rounded-[18px] border-0 bg-transparent p-3.5 text-left transition duration-180 hover:bg-[color:color-mix(in_srgb,var(--panel-strong)_72%,transparent)]";
|
|
160
|
+
|
|
161
|
+
export const dmListItemActiveClass =
|
|
162
|
+
"dm-list-item-active bg-[color:color-mix(in_srgb,var(--panel-strong)_86%,transparent)]";
|
|
163
|
+
|
|
164
|
+
export const dmListCopyClass = "dm-list-copy min-w-0";
|
|
165
|
+
|
|
166
|
+
export const dmPreviewTextClass =
|
|
167
|
+
"mt-1.5 overflow-hidden text-ellipsis whitespace-nowrap text-[var(--ink-soft)]";
|
|
168
|
+
|
|
169
|
+
export const threadShellClass =
|
|
170
|
+
"thread-shell min-h-[70vh] rounded-[24px] border border-[var(--line)] bg-[var(--panel)] p-[18px] shadow-[0_24px_60px_var(--shadow)]";
|
|
171
|
+
|
|
172
|
+
export const threadHeaderClass =
|
|
173
|
+
"thread-header grid gap-5 border-b border-[var(--line)] pb-4 min-[860px]:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]";
|
|
174
|
+
|
|
175
|
+
export const threadTitleClass = "m-0 font-display text-[2rem]";
|
|
176
|
+
|
|
177
|
+
export const threadSubtitleClass = "mt-1.5 text-[var(--ink-soft)]";
|
|
178
|
+
|
|
179
|
+
export const threadBioClass =
|
|
180
|
+
"mt-2.5 max-w-[48ch] text-[var(--ink-soft)] leading-[1.55]";
|
|
181
|
+
|
|
182
|
+
export const threadDetailColumnClass =
|
|
183
|
+
"grid gap-3 rounded-[20px] border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_82%,transparent)] p-4";
|
|
184
|
+
|
|
185
|
+
export const threadDetailHeaderClass = "flex items-center gap-3";
|
|
186
|
+
|
|
187
|
+
export const messageStackClass = "flex flex-col gap-3.5 py-5 pb-[18px]";
|
|
188
|
+
|
|
189
|
+
export const messageRowClass = "message-row max-w-[80%] max-[760px]:max-w-full";
|
|
190
|
+
|
|
191
|
+
export const messageRowOutboundClass = "message-row-outbound self-end";
|
|
192
|
+
|
|
193
|
+
export const messageMetaClass =
|
|
194
|
+
"message-meta mb-2 flex justify-between gap-3 text-sm text-[var(--ink-soft)]";
|
|
195
|
+
|
|
196
|
+
export const messageBubbleClass =
|
|
197
|
+
"rounded-[18px] rounded-bl-[6px] bg-[color:color-mix(in_srgb,var(--panel-strong)_86%,transparent)] px-4 py-3.5 leading-[1.55]";
|
|
198
|
+
|
|
199
|
+
export const messageBubbleOutboundClass =
|
|
200
|
+
"rounded-bl-[18px] rounded-br-[6px] bg-[var(--accent-soft)]";
|
|
201
|
+
|
|
202
|
+
export const contextHandleClass = "mt-1.5 text-[var(--ink-soft)]";
|
|
203
|
+
|
|
204
|
+
export const contextBioClass = bodyCopyClass;
|
|
205
|
+
|
|
206
|
+
export const contextStatsClass = "mt-[18px] grid gap-3";
|
|
207
|
+
|
|
208
|
+
export const contextStatRowClass = "border-t border-[var(--line)] pt-3";
|
|
209
|
+
|
|
210
|
+
export const contextStatTermClass = "text-[0.84rem] text-[var(--ink-soft)]";
|
|
211
|
+
|
|
212
|
+
export const contextStatValueClass = "mt-1.5 font-semibold";
|
|
213
|
+
|
|
214
|
+
export const avatarChipClass =
|
|
215
|
+
"avatar-chip inline-grid size-[42px] place-items-center overflow-hidden rounded-2xl font-bold text-white";
|
|
216
|
+
|
|
217
|
+
export const avatarChipLargeClass =
|
|
218
|
+
"avatar-chip-large mb-3.5 size-16 rounded-[20px]";
|
|
219
|
+
|
|
220
|
+
export const emptyStateClass = "py-6 text-[var(--ink-soft)]";
|
|
221
|
+
|
|
222
|
+
export const profilePreviewClass = "profile-preview relative inline-flex";
|
|
223
|
+
|
|
224
|
+
export const profilePreviewTriggerClass =
|
|
225
|
+
"profile-preview-trigger inline-flex text-inherit";
|
|
226
|
+
|
|
227
|
+
export const profilePreviewCardClass =
|
|
228
|
+
"profile-preview-card pointer-events-none absolute left-0 top-[calc(100%+10px)] z-20 grid min-w-[240px] translate-y-1.5 gap-2.5 rounded-[18px] border border-[var(--line-strong)] bg-[var(--panel-strong)] p-3.5 opacity-0 shadow-[0_24px_60px_var(--shadow)] transition duration-160 group-hover:pointer-events-auto group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0 group-focus-within:opacity-100";
|
|
229
|
+
|
|
230
|
+
export const profilePreviewHeaderClass =
|
|
231
|
+
"profile-preview-header flex items-center gap-3";
|
|
232
|
+
|
|
233
|
+
export const profilePreviewHandleClass =
|
|
234
|
+
"profile-preview-handle block text-[0.88rem] text-[var(--ink-soft)]";
|
|
235
|
+
|
|
236
|
+
export const profilePreviewBioClass =
|
|
237
|
+
"profile-preview-bio block text-[0.95rem] leading-[1.5] text-[var(--ink)]";
|
|
238
|
+
|
|
239
|
+
export const profilePreviewMetaClass =
|
|
240
|
+
"profile-preview-meta block text-[0.88rem] text-[var(--ink-soft)]";
|
|
241
|
+
|
|
242
|
+
export const tweetLinkClass =
|
|
243
|
+
"tweet-link font-semibold text-[var(--accent)] underline decoration-[1px] underline-offset-[3px]";
|
|
244
|
+
|
|
245
|
+
export const tweetMentionClass =
|
|
246
|
+
"tweet-mention font-semibold text-[color:color-mix(in_srgb,var(--accent)_78%,var(--ink))]";
|
|
247
|
+
|
|
248
|
+
export const tweetHashtagClass = tweetMentionClass;
|
|
249
|
+
|
|
250
|
+
export function tweetMediaGridClass(count: number) {
|
|
251
|
+
const columns =
|
|
252
|
+
count === 1
|
|
253
|
+
? "grid-cols-1"
|
|
254
|
+
: count === 3
|
|
255
|
+
? "grid-cols-[1.3fr_1fr]"
|
|
256
|
+
: "grid-cols-2";
|
|
257
|
+
|
|
258
|
+
return cx(
|
|
259
|
+
`tweet-media-grid tweet-media-grid-${Math.min(count, 4)}`,
|
|
260
|
+
"mb-4 grid gap-2.5",
|
|
261
|
+
columns,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function tweetMediaTileClass(index: number, count: number) {
|
|
266
|
+
return cx(
|
|
267
|
+
"tweet-media-tile overflow-hidden rounded-[20px] border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_88%,transparent)]",
|
|
268
|
+
count === 3 && index === 0 && "row-span-2",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { normalizeAvatarUrl } from "./avatar-cache";
|
|
3
|
+
import type { ProfileRecord, XurlMentionUser } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface ResolvedXProfile {
|
|
6
|
+
profile: ProfileRecord;
|
|
7
|
+
externalUserId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildExternalProfileId(externalUserId: string) {
|
|
11
|
+
return `profile_user_${externalUserId}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getExternalUserId(profileId: string) {
|
|
15
|
+
if (profileId.startsWith("profile_user_")) {
|
|
16
|
+
return profileId.replace(/^profile_user_/, "");
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function randomAvatarHue(input: string) {
|
|
22
|
+
return (
|
|
23
|
+
input
|
|
24
|
+
.split("")
|
|
25
|
+
.reduce((sum, character) => sum + character.charCodeAt(0), 0) % 360
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
30
|
+
return {
|
|
31
|
+
id: String(row.id),
|
|
32
|
+
handle: String(row.handle),
|
|
33
|
+
displayName: String(row.display_name),
|
|
34
|
+
bio: String(row.bio),
|
|
35
|
+
followersCount: Number(row.followers_count),
|
|
36
|
+
avatarHue: Number(row.avatar_hue),
|
|
37
|
+
avatarUrl:
|
|
38
|
+
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
39
|
+
createdAt: String(row.created_at),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function updateExistingProfileFromUser(
|
|
44
|
+
db: Database.Database,
|
|
45
|
+
profileId: string,
|
|
46
|
+
user: XurlMentionUser,
|
|
47
|
+
): ResolvedXProfile {
|
|
48
|
+
const username = String(user.username ?? "").replace(/^@/, "");
|
|
49
|
+
const displayName = String(user.name ?? "").trim() || username;
|
|
50
|
+
const followersCount = Number(user.public_metrics?.followers_count ?? 0);
|
|
51
|
+
const bio = String(user.description ?? "");
|
|
52
|
+
const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
|
|
53
|
+
|
|
54
|
+
db.prepare(
|
|
55
|
+
`
|
|
56
|
+
update profiles
|
|
57
|
+
set handle = ?,
|
|
58
|
+
display_name = ?,
|
|
59
|
+
bio = ?,
|
|
60
|
+
followers_count = ?,
|
|
61
|
+
avatar_url = coalesce(?, avatar_url)
|
|
62
|
+
where id = ?
|
|
63
|
+
`,
|
|
64
|
+
).run(username, displayName, bio, followersCount, avatarUrl, profileId);
|
|
65
|
+
|
|
66
|
+
const row = db
|
|
67
|
+
.prepare(
|
|
68
|
+
`
|
|
69
|
+
select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
|
|
70
|
+
from profiles
|
|
71
|
+
where id = ?
|
|
72
|
+
`,
|
|
73
|
+
)
|
|
74
|
+
.get(profileId) as Record<string, unknown>;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
profile: toProfile(row),
|
|
78
|
+
externalUserId: String(user.id),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function upsertProfileFromXUser(
|
|
83
|
+
db: Database.Database,
|
|
84
|
+
user: XurlMentionUser,
|
|
85
|
+
): ResolvedXProfile {
|
|
86
|
+
const externalUserId = String(user.id ?? "");
|
|
87
|
+
if (!externalUserId) {
|
|
88
|
+
throw new Error("Resolved user is missing an id");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const username = String(user.username ?? "").replace(/^@/, "");
|
|
92
|
+
if (!username) {
|
|
93
|
+
throw new Error("Resolved user is missing a username");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const profileId = buildExternalProfileId(externalUserId);
|
|
97
|
+
const existingRow = db
|
|
98
|
+
.prepare(
|
|
99
|
+
`
|
|
100
|
+
select id
|
|
101
|
+
from profiles
|
|
102
|
+
where id = ? or handle = ?
|
|
103
|
+
limit 1
|
|
104
|
+
`,
|
|
105
|
+
)
|
|
106
|
+
.get(profileId, username) as { id: string } | undefined;
|
|
107
|
+
|
|
108
|
+
if (existingRow) {
|
|
109
|
+
return updateExistingProfileFromUser(db, existingRow.id, user);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const displayName = String(user.name ?? "").trim() || username;
|
|
113
|
+
const followersCount = Number(user.public_metrics?.followers_count ?? 0);
|
|
114
|
+
const bio = String(user.description ?? "");
|
|
115
|
+
const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
|
|
116
|
+
const createdAt = new Date().toISOString();
|
|
117
|
+
const avatarHue = randomAvatarHue(username);
|
|
118
|
+
|
|
119
|
+
db.prepare(
|
|
120
|
+
`
|
|
121
|
+
insert into profiles (
|
|
122
|
+
id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
|
|
123
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
124
|
+
on conflict(id) do update set
|
|
125
|
+
handle = excluded.handle,
|
|
126
|
+
display_name = excluded.display_name,
|
|
127
|
+
bio = excluded.bio,
|
|
128
|
+
followers_count = excluded.followers_count,
|
|
129
|
+
avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url)
|
|
130
|
+
`,
|
|
131
|
+
).run(
|
|
132
|
+
profileId,
|
|
133
|
+
username,
|
|
134
|
+
displayName,
|
|
135
|
+
bio,
|
|
136
|
+
followersCount,
|
|
137
|
+
avatarHue,
|
|
138
|
+
avatarUrl,
|
|
139
|
+
createdAt,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
profile: {
|
|
144
|
+
id: profileId,
|
|
145
|
+
handle: username,
|
|
146
|
+
displayName,
|
|
147
|
+
bio,
|
|
148
|
+
followersCount,
|
|
149
|
+
avatarHue,
|
|
150
|
+
avatarUrl: avatarUrl ?? undefined,
|
|
151
|
+
createdAt,
|
|
152
|
+
},
|
|
153
|
+
externalUserId,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function ensureStubProfileForXUser(
|
|
158
|
+
db: Database.Database,
|
|
159
|
+
externalUserId: string,
|
|
160
|
+
): ResolvedXProfile {
|
|
161
|
+
const profileId = buildExternalProfileId(externalUserId);
|
|
162
|
+
const existingRow = db
|
|
163
|
+
.prepare(
|
|
164
|
+
`
|
|
165
|
+
select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
|
|
166
|
+
from profiles
|
|
167
|
+
where id = ?
|
|
168
|
+
limit 1
|
|
169
|
+
`,
|
|
170
|
+
)
|
|
171
|
+
.get(profileId) as Record<string, unknown> | undefined;
|
|
172
|
+
|
|
173
|
+
if (existingRow) {
|
|
174
|
+
return {
|
|
175
|
+
profile: toProfile(existingRow),
|
|
176
|
+
externalUserId,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const handle = `user_${externalUserId}`;
|
|
181
|
+
const createdAt = new Date().toISOString();
|
|
182
|
+
const avatarHue = randomAvatarHue(handle);
|
|
183
|
+
db.prepare(
|
|
184
|
+
`
|
|
185
|
+
insert into profiles (
|
|
186
|
+
id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
|
|
187
|
+
) values (?, ?, ?, '', 0, ?, null, ?)
|
|
188
|
+
`,
|
|
189
|
+
).run(profileId, handle, handle, avatarHue, createdAt);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
profile: {
|
|
193
|
+
id: profileId,
|
|
194
|
+
handle,
|
|
195
|
+
displayName: handle,
|
|
196
|
+
bio: "",
|
|
197
|
+
followersCount: 0,
|
|
198
|
+
avatarHue,
|
|
199
|
+
createdAt,
|
|
200
|
+
},
|
|
201
|
+
externalUserId,
|
|
202
|
+
};
|
|
203
|
+
}
|
package/src/lib/x-web.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getCookies } from "@steipete/sweet-cookie";
|
|
3
|
+
|
|
4
|
+
const X_WEB_BEARER_TOKEN =
|
|
5
|
+
"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
|
|
6
|
+
const X_WEB_USER_AGENT =
|
|
7
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
8
|
+
const X_WEB_COOKIE_NAMES = ["auth_token", "ct0"] as const;
|
|
9
|
+
const X_WEB_ORIGINS = ["https://x.com/", "https://twitter.com/"];
|
|
10
|
+
|
|
11
|
+
type CookieSource = "safari" | "chrome" | "firefox";
|
|
12
|
+
|
|
13
|
+
function normalizeCookieValue(value: unknown) {
|
|
14
|
+
return typeof value === "string" && value.trim().length > 0
|
|
15
|
+
? value.trim()
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildCookieHeader(authToken: string, ct0: string) {
|
|
20
|
+
return `auth_token=${authToken}; ct0=${ct0}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pickCookieValue(
|
|
24
|
+
cookies: Array<{ name?: string; value?: string; domain?: string }>,
|
|
25
|
+
name: (typeof X_WEB_COOKIE_NAMES)[number],
|
|
26
|
+
) {
|
|
27
|
+
const matches = cookies.filter(
|
|
28
|
+
(cookie) => cookie?.name === name && typeof cookie.value === "string",
|
|
29
|
+
);
|
|
30
|
+
if (matches.length === 0) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const xDomain = matches.find((cookie) =>
|
|
35
|
+
(cookie.domain ?? "").endsWith("x.com"),
|
|
36
|
+
);
|
|
37
|
+
if (xDomain?.value) {
|
|
38
|
+
return xDomain.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const twitterDomain = matches.find((cookie) =>
|
|
42
|
+
(cookie.domain ?? "").endsWith("twitter.com"),
|
|
43
|
+
);
|
|
44
|
+
if (twitterDomain?.value) {
|
|
45
|
+
return twitterDomain.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return matches[0]?.value ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function resolveXWebCookies() {
|
|
52
|
+
const envAuthToken = normalizeCookieValue(
|
|
53
|
+
process.env.AUTH_TOKEN ?? process.env.TWITTER_AUTH_TOKEN,
|
|
54
|
+
);
|
|
55
|
+
const envCt0 = normalizeCookieValue(
|
|
56
|
+
process.env.CT0 ?? process.env.TWITTER_CT0,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (envAuthToken && envCt0) {
|
|
60
|
+
return {
|
|
61
|
+
authToken: envAuthToken,
|
|
62
|
+
ct0: envCt0,
|
|
63
|
+
cookieHeader: buildCookieHeader(envAuthToken, envCt0),
|
|
64
|
+
source: "env",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cookieResult = await getCookies({
|
|
69
|
+
url: "https://x.com/",
|
|
70
|
+
origins: [...X_WEB_ORIGINS],
|
|
71
|
+
names: [...X_WEB_COOKIE_NAMES],
|
|
72
|
+
browsers: ["safari", "chrome", "firefox"] satisfies CookieSource[],
|
|
73
|
+
mode: "merge",
|
|
74
|
+
timeoutMs: process.platform === "darwin" ? 30_000 : undefined,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const authToken = pickCookieValue(cookieResult.cookies, "auth_token");
|
|
78
|
+
const ct0 = pickCookieValue(cookieResult.cookies, "ct0");
|
|
79
|
+
if (!authToken || !ct0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
authToken,
|
|
85
|
+
ct0,
|
|
86
|
+
cookieHeader: buildCookieHeader(authToken, ct0),
|
|
87
|
+
source: "browser",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runXWebBlockMutation(
|
|
92
|
+
path: string,
|
|
93
|
+
params: URLSearchParams,
|
|
94
|
+
action: string,
|
|
95
|
+
) {
|
|
96
|
+
try {
|
|
97
|
+
const cookies = await resolveXWebCookies();
|
|
98
|
+
if (!cookies) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
output: `x-web ${action} unavailable: missing auth_token/ct0 cookies`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await fetch(`https://x.com/i/api/1.1/${path}`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
accept: "*/*",
|
|
109
|
+
"accept-language": "en-US,en;q=0.9",
|
|
110
|
+
authorization: `Bearer ${X_WEB_BEARER_TOKEN}`,
|
|
111
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
112
|
+
cookie: cookies.cookieHeader,
|
|
113
|
+
origin: "https://x.com",
|
|
114
|
+
referer: "https://x.com/",
|
|
115
|
+
"user-agent": X_WEB_USER_AGENT,
|
|
116
|
+
"x-client-transaction-id": randomUUID().replaceAll("-", ""),
|
|
117
|
+
"x-csrf-token": cookies.ct0,
|
|
118
|
+
"x-twitter-active-user": "yes",
|
|
119
|
+
"x-twitter-auth-type": "OAuth2Session",
|
|
120
|
+
"x-twitter-client-language": "en",
|
|
121
|
+
},
|
|
122
|
+
body: params,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const text = await response.text();
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
output: `x-web ${action} failed (${response.status}): ${text.slice(0, 240)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
output: `x-web ${action} ok via ${cookies.source}`,
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
output:
|
|
141
|
+
error instanceof Error
|
|
142
|
+
? `x-web ${action} failed: ${error.message}`
|
|
143
|
+
: `x-web ${action} failed`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function blockUserViaXWeb(targetUserId: string) {
|
|
149
|
+
return runXWebBlockMutation(
|
|
150
|
+
"blocks/create.json",
|
|
151
|
+
new URLSearchParams({
|
|
152
|
+
user_id: targetUserId,
|
|
153
|
+
skip_status: "true",
|
|
154
|
+
}),
|
|
155
|
+
"block",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function unblockUserViaXWeb(targetUserId: string) {
|
|
160
|
+
return runXWebBlockMutation(
|
|
161
|
+
"blocks/destroy.json",
|
|
162
|
+
new URLSearchParams({
|
|
163
|
+
user_id: targetUserId,
|
|
164
|
+
skip_status: "true",
|
|
165
|
+
}),
|
|
166
|
+
"unblock",
|
|
167
|
+
);
|
|
168
|
+
}
|