@opengeni/api-router 0.15.1 → 0.15.4

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.
@@ -0,0 +1,504 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { SocialConnection } from "@opengeni/contracts";
3
+ import type { Observability } from "@opengeni/observability";
4
+ import type { Database } from "@opengeni/db";
5
+ import { OAUTH_MAX_RESPONSE_BYTES, pinnedFetch, readResponseJsonBounded } from "@opengeni/network";
6
+ import {
7
+ freshSocialAccessToken,
8
+ markNeedsReauth,
9
+ SOCIAL_TIMEOUT_MS,
10
+ SOCIAL_USER_AGENT,
11
+ type SocialCredentialBundle,
12
+ type SocialProviderFetch,
13
+ } from "./social-oauth";
14
+
15
+ // One normalized shape across providers so agent prompts do not need
16
+ // provider-specific parsing: X tweets and Reddit links/comments both flatten
17
+ // into this.
18
+ export type SocialLivePost = {
19
+ id: string;
20
+ provider: "x" | "reddit";
21
+ url: string | null;
22
+ author: string | null;
23
+ text: string;
24
+ createdAt: string | null;
25
+ metrics: Record<string, number>;
26
+ // X: conversation_id; Reddit: subreddit + kind. Kept small and provider-tagged.
27
+ context: Record<string, string>;
28
+ };
29
+
30
+ type SocialApiDeps = {
31
+ db: Database;
32
+ settings: Settings;
33
+ observability?: Observability | undefined;
34
+ // Test-only provider seam; production leaves it unset and uses pinnedFetch.
35
+ providerFetch?: SocialProviderFetch | undefined;
36
+ };
37
+
38
+ type ConnectionRef = { workspaceId: string; connectionId: string };
39
+
40
+ const MAX_LIVE_RESULTS = 50;
41
+
42
+ export async function socialSearchLive(
43
+ deps: SocialApiDeps,
44
+ ref: ConnectionRef,
45
+ input: { query: string; limit?: number | undefined; subreddit?: string | undefined },
46
+ ): Promise<{ connection: SocialConnection; posts: SocialLivePost[] }> {
47
+ const { connection, bundle } = await freshSocialAccessToken(deps, ref);
48
+ const limit = boundedLiveLimit(input.limit);
49
+ if (bundle.provider === "x") {
50
+ // X recent search requires max_results in [10, 100].
51
+ const url = new URL("https://api.x.com/2/tweets/search/recent");
52
+ url.searchParams.set("query", input.query);
53
+ url.searchParams.set("max_results", String(Math.max(10, limit)));
54
+ url.searchParams.set("tweet.fields", "created_at,public_metrics,author_id,conversation_id");
55
+ url.searchParams.set("expansions", "author_id");
56
+ url.searchParams.set("user.fields", "username");
57
+ const payload = await socialApiGet(deps, ref, bundle, url);
58
+ return { connection, posts: mapXTweets(payload).slice(0, limit) };
59
+ }
60
+ const base = input.subreddit
61
+ ? `https://oauth.reddit.com/r/${redditSubredditName(input.subreddit)}/search`
62
+ : "https://oauth.reddit.com/search";
63
+ const url = new URL(base);
64
+ url.searchParams.set("q", input.query);
65
+ url.searchParams.set("limit", String(limit));
66
+ url.searchParams.set("sort", "new");
67
+ url.searchParams.set("raw_json", "1");
68
+ if (input.subreddit) {
69
+ url.searchParams.set("restrict_sr", "1");
70
+ }
71
+ const payload = await socialApiGet(deps, ref, bundle, url);
72
+ return { connection, posts: mapRedditListing(payload).slice(0, limit) };
73
+ }
74
+
75
+ export async function socialMentionsLive(
76
+ deps: SocialApiDeps,
77
+ ref: ConnectionRef,
78
+ input: { limit?: number | undefined; sinceId?: string | undefined },
79
+ ): Promise<{ connection: SocialConnection; posts: SocialLivePost[] }> {
80
+ const { connection, bundle } = await freshSocialAccessToken(deps, ref);
81
+ const limit = boundedLiveLimit(input.limit);
82
+ if (bundle.provider === "x") {
83
+ if (!connection.externalAccountId) {
84
+ throw new Error(
85
+ "x connection has no stored account id; reconnect it via the social OAuth flow",
86
+ );
87
+ }
88
+ const url = new URL(
89
+ `https://api.x.com/2/users/${encodeURIComponent(connection.externalAccountId)}/mentions`,
90
+ );
91
+ url.searchParams.set("max_results", String(Math.max(5, limit)));
92
+ url.searchParams.set("tweet.fields", "created_at,public_metrics,author_id,conversation_id");
93
+ url.searchParams.set("expansions", "author_id");
94
+ url.searchParams.set("user.fields", "username");
95
+ if (input.sinceId) {
96
+ url.searchParams.set("since_id", input.sinceId);
97
+ }
98
+ const payload = await socialApiGet(deps, ref, bundle, url);
99
+ return { connection, posts: mapXTweets(payload).slice(0, limit) };
100
+ }
101
+ // Reddit surfaces username mentions and comment replies in the inbox.
102
+ const url = new URL("https://oauth.reddit.com/message/inbox");
103
+ url.searchParams.set("limit", String(limit));
104
+ url.searchParams.set("raw_json", "1");
105
+ if (input.sinceId) {
106
+ // Reddit listings page with fullname anchors; `before` returns only items
107
+ // newer than the anchor, matching X's since_id semantics.
108
+ url.searchParams.set("before", input.sinceId);
109
+ }
110
+ const payload = await socialApiGet(deps, ref, bundle, url);
111
+ return { connection, posts: mapRedditListing(payload).slice(0, limit) };
112
+ }
113
+
114
+ export async function socialThreadLive(
115
+ deps: SocialApiDeps,
116
+ ref: ConnectionRef,
117
+ input: { id: string; limit?: number | undefined },
118
+ ): Promise<{ connection: SocialConnection; posts: SocialLivePost[] }> {
119
+ const { connection, bundle } = await freshSocialAccessToken(deps, ref);
120
+ const limit = boundedLiveLimit(input.limit);
121
+ if (bundle.provider === "x") {
122
+ const url = new URL("https://api.x.com/2/tweets/search/recent");
123
+ url.searchParams.set("query", `conversation_id:${input.id}`);
124
+ url.searchParams.set("max_results", String(Math.max(10, limit)));
125
+ url.searchParams.set("tweet.fields", "created_at,public_metrics,author_id,conversation_id");
126
+ url.searchParams.set("expansions", "author_id");
127
+ url.searchParams.set("user.fields", "username");
128
+ const payload = await socialApiGet(deps, ref, bundle, url);
129
+ return { connection, posts: mapXTweets(payload).slice(0, limit) };
130
+ }
131
+ const article = redditArticleId(input.id);
132
+ const url = new URL(`https://oauth.reddit.com/comments/${article}`);
133
+ url.searchParams.set("limit", String(limit));
134
+ url.searchParams.set("depth", "2");
135
+ url.searchParams.set("raw_json", "1");
136
+ const payload = await socialApiGet(deps, ref, bundle, url);
137
+ return { connection, posts: mapRedditThread(payload).slice(0, limit + 1) };
138
+ }
139
+
140
+ export async function socialOwnPostsLive(
141
+ deps: SocialApiDeps,
142
+ ref: ConnectionRef,
143
+ input: { limit?: number | undefined },
144
+ ): Promise<{ connection: SocialConnection; posts: SocialLivePost[] }> {
145
+ const { connection, bundle } = await freshSocialAccessToken(deps, ref);
146
+ const limit = boundedLiveLimit(input.limit);
147
+ if (bundle.provider === "x") {
148
+ if (!connection.externalAccountId) {
149
+ throw new Error(
150
+ "x connection has no stored account id; reconnect it via the social OAuth flow",
151
+ );
152
+ }
153
+ const url = new URL(
154
+ `https://api.x.com/2/users/${encodeURIComponent(connection.externalAccountId)}/tweets`,
155
+ );
156
+ url.searchParams.set("max_results", String(Math.max(5, limit)));
157
+ url.searchParams.set("tweet.fields", "created_at,public_metrics,author_id,conversation_id");
158
+ const payload = await socialApiGet(deps, ref, bundle, url);
159
+ return { connection, posts: mapXTweets(payload, connection.accountHandle).slice(0, limit) };
160
+ }
161
+ const url = new URL(
162
+ `https://oauth.reddit.com/user/${encodeURIComponent(connection.accountHandle)}/submitted`,
163
+ );
164
+ url.searchParams.set("limit", String(limit));
165
+ url.searchParams.set("raw_json", "1");
166
+ const payload = await socialApiGet(deps, ref, bundle, url);
167
+ return { connection, posts: mapRedditListing(payload).slice(0, limit) };
168
+ }
169
+
170
+ export async function socialPostReply(
171
+ deps: SocialApiDeps,
172
+ ref: ConnectionRef,
173
+ input: { inReplyToId: string; text: string },
174
+ ): Promise<{ connection: SocialConnection; postedId: string | null; url: string | null }> {
175
+ const { connection, bundle } = await freshSocialAccessToken(deps, ref);
176
+ if (bundle.provider === "x") {
177
+ const payload = await socialApiSend(
178
+ deps,
179
+ ref,
180
+ bundle,
181
+ new URL("https://api.x.com/2/tweets"),
182
+ {
183
+ "content-type": "application/json",
184
+ },
185
+ JSON.stringify({
186
+ text: input.text,
187
+ reply: { in_reply_to_tweet_id: input.inReplyToId },
188
+ }),
189
+ );
190
+ const data = payload.data as Record<string, unknown> | undefined;
191
+ const id = typeof data?.id === "string" ? data.id : null;
192
+ return {
193
+ connection,
194
+ postedId: id,
195
+ url: id
196
+ ? `https://x.com/${encodeURIComponent(connection.accountHandle)}/status/${encodeURIComponent(id)}`
197
+ : null,
198
+ };
199
+ }
200
+ const thingId = redditThingId(input.inReplyToId);
201
+ const body = new URLSearchParams({
202
+ api_type: "json",
203
+ thing_id: thingId,
204
+ text: input.text,
205
+ });
206
+ const payload = await socialApiSend(
207
+ deps,
208
+ ref,
209
+ bundle,
210
+ new URL("https://oauth.reddit.com/api/comment"),
211
+ { "content-type": "application/x-www-form-urlencoded" },
212
+ body,
213
+ );
214
+ const posted = redditCommentFromApiJson(payload);
215
+ return { connection, postedId: posted.id, url: posted.url };
216
+ }
217
+
218
+ /**
219
+ * Reddit write endpoints address targets by fullname (t3_xxx post, t1_xxx
220
+ * comment). Bare ids are ambiguous, so require the caller to be explicit.
221
+ */
222
+ export function redditThingId(id: string): string {
223
+ if (/^t[1-6]_[a-z0-9]+$/i.test(id)) {
224
+ return id;
225
+ }
226
+ throw new Error(
227
+ `Reddit reply targets must be fullnames like t3_<postid> or t1_<commentid>; got: ${id}`,
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Agent-controlled values that land in a provider URL PATH must be shape-
233
+ * validated: encodeURIComponent leaves `.` intact, so `..` would collapse a
234
+ * path segment and select a different endpoint on the same host.
235
+ */
236
+ export function redditSubredditName(value: string): string {
237
+ if (/^[A-Za-z0-9_]{1,50}$/.test(value)) {
238
+ return value;
239
+ }
240
+ throw new Error(`invalid subreddit name: ${value}`);
241
+ }
242
+
243
+ export function redditArticleId(value: string): string {
244
+ const article = value.replace(/^t3_/, "");
245
+ if (/^[a-z0-9]{1,20}$/i.test(article)) {
246
+ return article;
247
+ }
248
+ throw new Error(`invalid Reddit post id: ${value}`);
249
+ }
250
+
251
+ /**
252
+ * Joins a provider-supplied path against the provider origin and refuses
253
+ * results that escape it (e.g. a hostile `@evil.com/x` or `//evil.com`
254
+ * permalink), so mapper output URLs can be trusted downstream.
255
+ */
256
+ export function redditUrl(path: string): string | null {
257
+ try {
258
+ const url = new URL(path, "https://www.reddit.com");
259
+ return url.host === "www.reddit.com" ? url.toString() : null;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ async function socialApiGet(
266
+ deps: SocialApiDeps,
267
+ ref: ConnectionRef,
268
+ bundle: SocialCredentialBundle,
269
+ url: URL,
270
+ ): Promise<Record<string, unknown>> {
271
+ return await socialApiRequest(deps, ref, bundle, url, {
272
+ headers: socialApiHeaders(bundle),
273
+ });
274
+ }
275
+
276
+ async function socialApiSend(
277
+ deps: SocialApiDeps,
278
+ ref: ConnectionRef,
279
+ bundle: SocialCredentialBundle,
280
+ url: URL,
281
+ headers: Record<string, string>,
282
+ body: BodyInit,
283
+ ): Promise<Record<string, unknown>> {
284
+ return await socialApiRequest(deps, ref, bundle, url, {
285
+ method: "POST",
286
+ headers: { ...socialApiHeaders(bundle), ...headers },
287
+ body,
288
+ });
289
+ }
290
+
291
+ function socialApiHeaders(bundle: SocialCredentialBundle): Record<string, string> {
292
+ return {
293
+ authorization: `Bearer ${bundle.accessToken}`,
294
+ accept: "application/json",
295
+ "user-agent": SOCIAL_USER_AGENT,
296
+ };
297
+ }
298
+
299
+ async function socialApiRequest(
300
+ deps: SocialApiDeps,
301
+ ref: ConnectionRef,
302
+ bundle: SocialCredentialBundle,
303
+ url: URL,
304
+ init: RequestInit,
305
+ ): Promise<Record<string, unknown>> {
306
+ const requestInit = { ...init, signal: AbortSignal.timeout(SOCIAL_TIMEOUT_MS) };
307
+ const label = `social ${bundle.provider} API`;
308
+ const response = deps.providerFetch
309
+ ? await deps.providerFetch(url.toString(), requestInit, label)
310
+ : await pinnedFetch(url.toString(), requestInit, deps.settings, {
311
+ label,
312
+ requireHttpsOutsideLocalTest: true,
313
+ });
314
+ if (response.status === 401) {
315
+ await response.body?.cancel().catch(() => undefined);
316
+ // A 401 after freshSocialAccessToken means the access token the provider
317
+ // just vouched for is no longer honored — the grant itself is gone.
318
+ await markNeedsReauth(deps, ref);
319
+ deps.observability?.warn("social connection marked needs_reauth after provider 401", {
320
+ "opengeni.social.provider": bundle.provider,
321
+ "opengeni.social.connection_id": ref.connectionId,
322
+ });
323
+ throw new Error(
324
+ `${bundle.provider} API rejected the stored credential (HTTP 401); reconnect the social connection`,
325
+ );
326
+ }
327
+ // 403 is NOT a credential failure: X uses it for duplicate content and
328
+ // access-tier limits, Reddit for banned/private subreddits. The grant is
329
+ // healthy — report the rejection without poisoning connection status.
330
+ if (response.status === 403) {
331
+ await response.body?.cancel().catch(() => undefined);
332
+ throw new Error(
333
+ `${bundle.provider} API refused this request (HTTP 403) — likely a permissions, content, or access-tier rule for ${url.pathname}; the connection itself is still valid`,
334
+ );
335
+ }
336
+ if (response.status === 429) {
337
+ const retryAfter = response.headers.get("retry-after");
338
+ await response.body?.cancel().catch(() => undefined);
339
+ deps.observability?.warn("social API rate limited", {
340
+ "opengeni.social.provider": bundle.provider,
341
+ "opengeni.social.connection_id": ref.connectionId,
342
+ "opengeni.social.retry_after": retryAfter ?? undefined,
343
+ });
344
+ throw new Error(
345
+ `${bundle.provider} API rate limit hit${retryAfter ? `; retry after ${retryAfter}s` : ""}. Reduce frequency or limit.`,
346
+ );
347
+ }
348
+ if (!response.ok) {
349
+ await response.body?.cancel().catch(() => undefined);
350
+ throw new Error(`${bundle.provider} API returned HTTP ${response.status} for ${url.pathname}`);
351
+ }
352
+ return await readResponseJsonBounded<Record<string, unknown>>(
353
+ response,
354
+ OAUTH_MAX_RESPONSE_BYTES,
355
+ "social API response",
356
+ );
357
+ }
358
+
359
+ function boundedLiveLimit(limit: number | undefined): number {
360
+ if (typeof limit !== "number" || !Number.isFinite(limit)) {
361
+ return 25;
362
+ }
363
+ const floored = Math.floor(limit);
364
+ if (floored <= 0) {
365
+ return 25;
366
+ }
367
+ return Math.min(floored, MAX_LIVE_RESULTS);
368
+ }
369
+
370
+ // --- Pure response mappers (exported for unit tests) ---
371
+
372
+ export function mapXTweets(
373
+ payload: Record<string, unknown>,
374
+ fallbackAuthor?: string,
375
+ ): SocialLivePost[] {
376
+ const data = Array.isArray(payload.data) ? payload.data : [];
377
+ const includes = payload.includes as Record<string, unknown> | undefined;
378
+ const users = Array.isArray(includes?.users) ? includes.users : [];
379
+ const usernamesById = new Map<string, string>();
380
+ for (const user of users) {
381
+ const entry = user as Record<string, unknown>;
382
+ if (typeof entry.id === "string" && typeof entry.username === "string") {
383
+ usernamesById.set(entry.id, entry.username);
384
+ }
385
+ }
386
+ const posts: SocialLivePost[] = [];
387
+ for (const item of data) {
388
+ const tweet = item as Record<string, unknown>;
389
+ if (typeof tweet.id !== "string" || typeof tweet.text !== "string") {
390
+ continue;
391
+ }
392
+ const authorId = typeof tweet.author_id === "string" ? tweet.author_id : null;
393
+ const author = (authorId ? usernamesById.get(authorId) : undefined) ?? fallbackAuthor ?? null;
394
+ const publicMetrics = tweet.public_metrics as Record<string, unknown> | undefined;
395
+ const metrics: Record<string, number> = {};
396
+ for (const [name, value] of Object.entries(publicMetrics ?? {})) {
397
+ if (typeof value === "number") {
398
+ metrics[name] = value;
399
+ }
400
+ }
401
+ posts.push({
402
+ id: tweet.id,
403
+ provider: "x",
404
+ url: author
405
+ ? `https://x.com/${encodeURIComponent(author)}/status/${encodeURIComponent(tweet.id)}`
406
+ : null,
407
+ author,
408
+ text: tweet.text,
409
+ createdAt: typeof tweet.created_at === "string" ? tweet.created_at : null,
410
+ metrics,
411
+ context: {
412
+ ...(typeof tweet.conversation_id === "string"
413
+ ? { conversationId: tweet.conversation_id }
414
+ : {}),
415
+ },
416
+ });
417
+ }
418
+ return posts;
419
+ }
420
+
421
+ export function mapRedditListing(payload: Record<string, unknown>): SocialLivePost[] {
422
+ const data = payload.data as Record<string, unknown> | undefined;
423
+ const children = Array.isArray(data?.children) ? data.children : [];
424
+ const posts: SocialLivePost[] = [];
425
+ for (const child of children) {
426
+ const post = mapRedditChild(child as Record<string, unknown>);
427
+ if (post) {
428
+ posts.push(post);
429
+ }
430
+ }
431
+ return posts;
432
+ }
433
+
434
+ /**
435
+ * /comments/{article} returns [post listing, comment listing]; flatten the
436
+ * submission first, then its comment tree in order.
437
+ */
438
+ export function mapRedditThread(payload: Record<string, unknown>): SocialLivePost[] {
439
+ if (!Array.isArray(payload)) {
440
+ return mapRedditListing(payload);
441
+ }
442
+ const posts: SocialLivePost[] = [];
443
+ for (const listing of payload) {
444
+ posts.push(...mapRedditListing(listing as Record<string, unknown>));
445
+ }
446
+ return posts;
447
+ }
448
+
449
+ function mapRedditChild(child: Record<string, unknown>): SocialLivePost | null {
450
+ const kind = typeof child.kind === "string" ? child.kind : "";
451
+ const data = child.data as Record<string, unknown> | undefined;
452
+ if (!data || typeof data.name !== "string") {
453
+ return null;
454
+ }
455
+ const title = typeof data.title === "string" ? data.title : "";
456
+ const selftext = typeof data.selftext === "string" ? data.selftext : "";
457
+ const commentBody = typeof data.body === "string" ? data.body : "";
458
+ const text = kind === "t3" ? [title, selftext].filter(Boolean).join("\n\n") : commentBody;
459
+ if (!text) {
460
+ return null;
461
+ }
462
+ const permalink = typeof data.permalink === "string" ? data.permalink : null;
463
+ const context = typeof data.context === "string" && data.context ? data.context : null;
464
+ const createdUtc = typeof data.created_utc === "number" ? data.created_utc : null;
465
+ const metrics: Record<string, number> = {};
466
+ if (typeof data.score === "number") {
467
+ metrics.score = data.score;
468
+ }
469
+ if (typeof data.num_comments === "number") {
470
+ metrics.comments = data.num_comments;
471
+ }
472
+ return {
473
+ id: data.name,
474
+ provider: "reddit",
475
+ url: permalink ? redditUrl(permalink) : context ? redditUrl(context) : null,
476
+ author: typeof data.author === "string" ? data.author : null,
477
+ text,
478
+ createdAt: createdUtc ? new Date(createdUtc * 1000).toISOString() : null,
479
+ metrics,
480
+ context: {
481
+ kind,
482
+ ...(typeof data.subreddit === "string" ? { subreddit: data.subreddit } : {}),
483
+ ...(typeof data.type === "string" ? { inboxType: data.type } : {}),
484
+ },
485
+ };
486
+ }
487
+
488
+ export function redditCommentFromApiJson(payload: Record<string, unknown>): {
489
+ id: string | null;
490
+ url: string | null;
491
+ } {
492
+ const json = payload.json as Record<string, unknown> | undefined;
493
+ const errors = Array.isArray(json?.errors) ? json.errors : [];
494
+ if (errors.length > 0) {
495
+ throw new Error(`reddit comment failed: ${JSON.stringify(errors[0])}`);
496
+ }
497
+ const jsonData = json?.data as Record<string, unknown> | undefined;
498
+ const things = Array.isArray(jsonData?.things) ? jsonData.things : [];
499
+ const first = things[0] as Record<string, unknown> | undefined;
500
+ const data = first?.data as Record<string, unknown> | undefined;
501
+ const id = typeof data?.name === "string" ? data.name : null;
502
+ const permalink = typeof data?.permalink === "string" ? data.permalink : null;
503
+ return { id, url: permalink ? redditUrl(permalink) : null };
504
+ }