birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -6,15 +6,16 @@ import {
6
6
  unmuteUserViaBird,
7
7
  } from "./bird-actions";
8
8
  import { type ActionsTransport, resolveActionsTransport } from "./config";
9
+ import { Effect } from "effect";
10
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
9
11
  import type {
10
12
  ModerationAction,
11
13
  ModerationActionTransportResult,
12
14
  ModerationTransportKind,
13
15
  } from "./types";
14
- import { blockUserViaXWeb, unblockUserViaXWeb } from "./x-web";
15
16
  import {
16
17
  blockUserViaXurl,
17
- lookupAuthenticatedUser,
18
+ lookupAuthenticatedUserFresh,
18
19
  muteUserViaXurl,
19
20
  unblockUserViaXurl,
20
21
  unmuteUserViaXurl,
@@ -27,29 +28,96 @@ interface RunActionParams {
27
28
  query: string;
28
29
  targetUserId?: string;
29
30
  transport?: string;
31
+ expectedAccount?: ExpectedActionAccount;
32
+ }
33
+
34
+ export interface ExpectedActionAccount {
35
+ id: string;
36
+ handle: string;
37
+ externalUserId?: string | null;
38
+ }
39
+
40
+ function toError(error: unknown) {
41
+ return error instanceof Error ? error : new Error(String(error));
42
+ }
43
+
44
+ function trySync<T>(try_: () => T) {
45
+ return Effect.try({
46
+ try: try_,
47
+ catch: toError,
48
+ });
30
49
  }
31
50
 
32
51
  function normalizeFailure(transport: ModerationTransportKind, output: string) {
33
52
  return `${transport}: ${output}`;
34
53
  }
35
54
 
36
- async function runBirdAction(
55
+ function liveWritesDisabled() {
56
+ return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
57
+ }
58
+
59
+ function normalizeHandle(value: string | null | undefined) {
60
+ return value?.replace(/^@/, "").toLowerCase() ?? "";
61
+ }
62
+
63
+ function verifyExpectedAccountEffect(
64
+ expectedAccount: ExpectedActionAccount | undefined,
65
+ ) {
66
+ return Effect.gen(function* () {
67
+ if (!expectedAccount) return null;
68
+ if (liveWritesDisabled()) return null;
69
+
70
+ const sourceUser = yield* tryPromise(() => lookupAuthenticatedUserFresh());
71
+ const sourceUserId =
72
+ sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
73
+ const sourceUsername =
74
+ sourceUser && typeof sourceUser.username === "string"
75
+ ? normalizeHandle(sourceUser.username)
76
+ : "";
77
+ const expectedExternalUserId = expectedAccount.externalUserId?.trim() ?? "";
78
+ const expectedHandle = normalizeHandle(expectedAccount.handle);
79
+
80
+ if (expectedExternalUserId) {
81
+ if (sourceUserId === expectedExternalUserId) return sourceUserId;
82
+ return yield* Effect.fail(
83
+ new Error(
84
+ `xurl is authenticated as user ${sourceUserId || "unknown"}, not account ${expectedAccount.id}`,
85
+ ),
86
+ );
87
+ }
88
+
89
+ if (expectedHandle && sourceUsername === expectedHandle)
90
+ return sourceUserId;
91
+ return yield* Effect.fail(
92
+ new Error(
93
+ sourceUsername
94
+ ? `xurl is authenticated as @${sourceUsername}, not @${expectedHandle}`
95
+ : "xurl authenticated user unavailable",
96
+ ),
97
+ );
98
+ });
99
+ }
100
+
101
+ function runBirdActionEffect(
37
102
  action: ModerationAction,
38
103
  query: string,
39
- ): Promise<ActionTransportResult> {
40
- const result =
41
- action === "block"
42
- ? await blockUserViaBird(query)
43
- : action === "unblock"
44
- ? await unblockUserViaBird(query)
45
- : action === "mute"
46
- ? await muteUserViaBird(query)
47
- : await unmuteUserViaBird(query);
48
-
49
- return {
50
- ...result,
51
- transport: "bird",
52
- };
104
+ ): Effect.Effect<ActionTransportResult, unknown> {
105
+ return Effect.gen(function* () {
106
+ const result = yield* tryPromise(() =>
107
+ action === "block"
108
+ ? blockUserViaBird(query)
109
+ : action === "unblock"
110
+ ? unblockUserViaBird(query)
111
+ : action === "mute"
112
+ ? muteUserViaBird(query)
113
+ : unmuteUserViaBird(query),
114
+ );
115
+
116
+ return {
117
+ ...result,
118
+ transport: "bird",
119
+ };
120
+ });
53
121
  }
54
122
 
55
123
  function getVerifyExpectation(action: ModerationAction) {
@@ -64,145 +132,151 @@ function getVerifyExpectation(action: ModerationAction) {
64
132
  };
65
133
  }
66
134
 
67
- async function runXurlAction(
135
+ function runXurlActionEffect(
68
136
  action: ModerationAction,
69
137
  query: string,
70
138
  targetUserId?: string,
71
- ): Promise<ActionTransportResult> {
72
- if (!targetUserId) {
73
- return {
74
- ok: false,
75
- output: "missing target user id for xurl transport",
76
- transport: "xurl",
77
- };
78
- }
139
+ verifiedSourceUserId?: string | null,
140
+ ): Effect.Effect<ActionTransportResult, unknown> {
141
+ return Effect.gen(function* () {
142
+ if (!targetUserId) {
143
+ return {
144
+ ok: false,
145
+ output: "missing target user id for xurl transport",
146
+ transport: "xurl",
147
+ };
148
+ }
79
149
 
80
- const sourceUser = await lookupAuthenticatedUser();
81
- const sourceUserId =
82
- sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
83
- if (!sourceUserId) {
84
- return {
85
- ok: false,
86
- output: "xurl authenticated user unavailable",
87
- transport: "xurl",
88
- };
89
- }
90
-
91
- const result =
92
- action === "block"
93
- ? await blockUserViaXurl(sourceUserId, targetUserId)
94
- : action === "unblock"
95
- ? await unblockUserViaXurl(sourceUserId, targetUserId)
96
- : action === "mute"
97
- ? await muteUserViaXurl(sourceUserId, targetUserId)
98
- : await unmuteUserViaXurl(sourceUserId, targetUserId);
99
-
100
- if (!result.ok) {
101
- return {
102
- ...result,
103
- transport: "xurl",
104
- };
105
- }
150
+ let sourceUserId = verifiedSourceUserId ?? "";
151
+ if (!sourceUserId) {
152
+ const sourceUser = yield* tryPromise(() =>
153
+ lookupAuthenticatedUserFresh(),
154
+ );
155
+ sourceUserId =
156
+ sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
157
+ }
158
+ if (!sourceUserId) {
159
+ return {
160
+ ok: false,
161
+ output: "xurl authenticated user unavailable",
162
+ transport: "xurl",
163
+ };
164
+ }
106
165
 
107
- const status = await readBirdStatusViaBird(query);
108
- const { field: verifyField, expected: expectedValue } =
109
- getVerifyExpectation(action);
110
- const actualValue =
111
- status && typeof status[verifyField] === "boolean"
112
- ? Boolean(status[verifyField])
113
- : null;
166
+ const result = yield* tryPromise(() =>
167
+ action === "block"
168
+ ? blockUserViaXurl(sourceUserId, targetUserId)
169
+ : action === "unblock"
170
+ ? unblockUserViaXurl(sourceUserId, targetUserId)
171
+ : action === "mute"
172
+ ? muteUserViaXurl(sourceUserId, targetUserId)
173
+ : unmuteUserViaXurl(sourceUserId, targetUserId),
174
+ );
114
175
 
115
- if (actualValue === null) {
116
- return {
117
- ok: false,
118
- output: `${result.output}\nxurl verify unavailable from bird status`,
119
- transport: "xurl",
120
- };
121
- }
176
+ if (!result.ok) {
177
+ return {
178
+ ...result,
179
+ transport: "xurl",
180
+ };
181
+ }
122
182
 
123
- if (actualValue !== expectedValue) {
124
- return {
125
- ok: false,
126
- output: `${result.output}\nxurl verify mismatch ${verifyField}=${String(actualValue)}`,
127
- transport: "xurl",
128
- };
129
- }
183
+ const status = yield* tryPromise(() => readBirdStatusViaBird(query));
184
+ const { field: verifyField, expected: expectedValue } =
185
+ getVerifyExpectation(action);
186
+ const actualValue =
187
+ status && typeof status[verifyField] === "boolean"
188
+ ? Boolean(status[verifyField])
189
+ : null;
130
190
 
131
- return {
132
- ok: true,
133
- output: `${result.output}\nverified ${verifyField}=${String(actualValue)}`,
134
- transport: "xurl",
135
- };
136
- }
191
+ if (actualValue === null) {
192
+ return {
193
+ ok: false,
194
+ output: `${result.output}\nxurl verify unavailable from bird status`,
195
+ transport: "xurl",
196
+ };
197
+ }
137
198
 
138
- async function runXWebAction(
139
- action: ModerationAction,
140
- targetUserId?: string,
141
- ): Promise<ActionTransportResult> {
142
- if (action !== "block" && action !== "unblock") {
143
- return {
144
- ok: false,
145
- output: `x-web does not support ${action}`,
146
- transport: "x-web",
147
- };
148
- }
199
+ if (actualValue !== expectedValue) {
200
+ return {
201
+ ok: false,
202
+ output: `${result.output}\nxurl verify mismatch ${verifyField}=${String(actualValue)}`,
203
+ transport: "xurl",
204
+ };
205
+ }
149
206
 
150
- if (!targetUserId) {
151
207
  return {
152
- ok: false,
153
- output: "missing target user id for x-web transport",
154
- transport: "x-web",
208
+ ok: true,
209
+ output: `${result.output}\nverified ${verifyField}=${String(actualValue)}`,
210
+ transport: "xurl",
155
211
  };
156
- }
157
-
158
- const result =
159
- action === "block"
160
- ? await blockUserViaXWeb(targetUserId)
161
- : await unblockUserViaXWeb(targetUserId);
162
-
163
- return {
164
- ...result,
165
- transport: "x-web",
166
- };
212
+ });
167
213
  }
168
214
 
169
- export async function runModerationAction({
215
+ export function runModerationActionEffect({
170
216
  action,
171
217
  query,
172
218
  targetUserId,
173
219
  transport,
174
- }: RunActionParams): Promise<ActionTransportResult> {
175
- const requestedTransport = resolveActionsTransport(transport);
176
- if (requestedTransport === "bird") {
177
- return runBirdAction(action, query);
178
- }
179
- if (requestedTransport === "xurl") {
180
- return runXurlAction(action, query, targetUserId);
181
- }
182
-
183
- const birdResult = await runBirdAction(action, query);
184
- if (birdResult.ok) {
185
- return birdResult;
186
- }
187
-
188
- const xurlResult = await runXurlAction(action, query, targetUserId);
189
- if (xurlResult.ok) {
190
- return {
191
- ...xurlResult,
192
- output: `${xurlResult.output}\nfalling back after ${normalizeFailure("bird", birdResult.output)}`,
193
- };
194
- }
220
+ expectedAccount,
221
+ }: RunActionParams): Effect.Effect<ActionTransportResult, unknown> {
222
+ return Effect.gen(function* () {
223
+ const requestedTransport = yield* trySync(() =>
224
+ resolveActionsTransport(transport),
225
+ );
226
+
227
+ const verifyXurlAccount = () =>
228
+ verifyExpectedAccountEffect(expectedAccount).pipe(
229
+ Effect.catchAll((error) =>
230
+ Effect.succeed({
231
+ ok: false as const,
232
+ output: error instanceof Error ? error.message : String(error),
233
+ transport: "xurl" as const,
234
+ }),
235
+ ),
236
+ );
195
237
 
196
- if (action === "block" || action === "unblock") {
197
- const xWebResult = await runXWebAction(action, targetUserId);
198
- if (xWebResult.ok) {
238
+ if (requestedTransport === "bird") {
239
+ return yield* runBirdActionEffect(action, query);
240
+ }
241
+ if (requestedTransport === "xurl") {
242
+ const accountCheck = yield* verifyXurlAccount();
243
+ if (accountCheck && typeof accountCheck === "object") {
244
+ return accountCheck;
245
+ }
246
+ return yield* runXurlActionEffect(
247
+ action,
248
+ query,
249
+ targetUserId,
250
+ typeof accountCheck === "string" ? accountCheck : null,
251
+ );
252
+ }
253
+
254
+ const birdResult = yield* runBirdActionEffect(action, query);
255
+ if (birdResult.ok) {
256
+ return birdResult;
257
+ }
258
+
259
+ const accountCheck = yield* verifyXurlAccount();
260
+ if (accountCheck && typeof accountCheck === "object") {
199
261
  return {
200
- ...xWebResult,
262
+ ok: false,
201
263
  output: [
202
- xWebResult.output,
203
- `falling back after ${normalizeFailure("bird", birdResult.output)}`,
204
- `falling back after ${normalizeFailure("xurl", xurlResult.output)}`,
264
+ normalizeFailure("bird", birdResult.output),
265
+ normalizeFailure("xurl", accountCheck.output),
205
266
  ].join("\n"),
267
+ transport: "xurl",
268
+ };
269
+ }
270
+ const xurlResult = yield* runXurlActionEffect(
271
+ action,
272
+ query,
273
+ targetUserId,
274
+ typeof accountCheck === "string" ? accountCheck : null,
275
+ );
276
+ if (xurlResult.ok) {
277
+ return {
278
+ ...xurlResult,
279
+ output: `${xurlResult.output}\nfalling back after ${normalizeFailure("bird", birdResult.output)}`,
206
280
  };
207
281
  }
208
282
 
@@ -211,20 +285,16 @@ export async function runModerationAction({
211
285
  output: [
212
286
  normalizeFailure("bird", birdResult.output),
213
287
  normalizeFailure("xurl", xurlResult.output),
214
- normalizeFailure("x-web", xWebResult.output),
215
288
  ].join("\n"),
216
- transport: xWebResult.transport,
289
+ transport: xurlResult.transport,
217
290
  };
218
- }
219
-
220
- return {
221
- ok: false,
222
- output: [
223
- normalizeFailure("bird", birdResult.output),
224
- normalizeFailure("xurl", xurlResult.output),
225
- ].join("\n"),
226
- transport: xurlResult.transport,
227
- };
291
+ });
292
+ }
293
+
294
+ export function runModerationAction(
295
+ params: RunActionParams,
296
+ ): Promise<ActionTransportResult> {
297
+ return runEffectPromise(runModerationActionEffect(params));
228
298
  }
229
299
 
230
300
  export type { ActionsTransport };