@automate.ax/integration-contracts 0.125.0 → 0.126.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.
@@ -0,0 +1,398 @@
1
+ import * as z from "zod";
2
+ const REDDIT_ORIGIN = "https://www.reddit.com";
3
+ const REDDIT_EDITED_SCHEMA = z.union([z.literal(false), z.number()]);
4
+ export const REDDIT_THING_ID_SCHEMA = z
5
+ .string()
6
+ .regex(/^t[1345]_[a-z0-9]+$/i, "Expected a Reddit thing fullname.");
7
+ export const REDDIT_POST_ID_SCHEMA = z
8
+ .string()
9
+ .regex(/^t3_[a-z0-9]+$/i, "Expected a Reddit post fullname.");
10
+ export const REDDIT_CONTENT_ID_SCHEMA = z
11
+ .string()
12
+ .regex(/^t[13]_[a-z0-9]+$/i, "Expected a Reddit post or comment fullname.");
13
+ export const REDDIT_INBOX_ID_SCHEMA = z
14
+ .string()
15
+ .regex(/^t[14]_[a-z0-9]+$/i, "Expected a Reddit inbox item fullname.");
16
+ export const REDDIT_SUBREDDIT_NAME_SCHEMA = z
17
+ .string()
18
+ .trim()
19
+ .regex(/^[A-Za-z0-9_]{2,21}$/, "Expected a Reddit subreddit name.");
20
+ export const REDDIT_USERNAME_SCHEMA = z
21
+ .string()
22
+ .trim()
23
+ .regex(/^[A-Za-z0-9_-]{3,20}$/, "Expected a Reddit username.");
24
+ export const REDDIT_FLAIR_SCHEMA = z.object({
25
+ backgroundColor: z.string().nullable(),
26
+ cssClass: z.string().nullable(),
27
+ templateId: z.string().nullable(),
28
+ text: z.string().nullable(),
29
+ textColor: z.enum(["dark", "light"]).nullable(),
30
+ });
31
+ export const REDDIT_POST_SCHEMA = z.object({
32
+ archived: z.boolean(),
33
+ author: z.string().nullable(),
34
+ body: z.string(),
35
+ commentCount: z.number().int().nonnegative(),
36
+ createdAt: z.date(),
37
+ editedAt: z.date().nullable(),
38
+ flair: REDDIT_FLAIR_SCHEMA,
39
+ hidden: z.boolean(),
40
+ id: REDDIT_POST_ID_SCHEMA,
41
+ isLocked: z.boolean(),
42
+ isNsfw: z.boolean(),
43
+ isSelfPost: z.boolean(),
44
+ isSpoiler: z.boolean(),
45
+ isStickied: z.boolean(),
46
+ permalink: z.url(),
47
+ saved: z.boolean(),
48
+ score: z.number().int(),
49
+ shortId: z.string().min(1),
50
+ subreddit: z.string(),
51
+ subredditId: z.string(),
52
+ title: z.string(),
53
+ upvoteRatio: z.number().min(0).max(1),
54
+ url: z.url(),
55
+ });
56
+ export const REDDIT_COMMENT_SCHEMA = z.object({
57
+ author: z.string().nullable(),
58
+ body: z.string(),
59
+ createdAt: z.date(),
60
+ depth: z.number().int().nonnegative(),
61
+ distinguished: z.enum(["admin", "moderator", "special"]).nullable(),
62
+ editedAt: z.date().nullable(),
63
+ id: z.string().regex(/^t1_[a-z0-9]+$/i),
64
+ isPostAuthor: z.boolean(),
65
+ isStickied: z.boolean(),
66
+ parentId: REDDIT_THING_ID_SCHEMA,
67
+ permalink: z.url(),
68
+ postId: REDDIT_POST_ID_SCHEMA,
69
+ saved: z.boolean(),
70
+ score: z.number().int(),
71
+ shortId: z.string().min(1),
72
+ subreddit: z.string(),
73
+ });
74
+ export const REDDIT_MESSAGE_SCHEMA = z.object({
75
+ author: z.string().nullable(),
76
+ body: z.string(),
77
+ context: z.string(),
78
+ createdAt: z.date(),
79
+ destination: z.string(),
80
+ id: REDDIT_INBOX_ID_SCHEMA,
81
+ isNew: z.boolean(),
82
+ parentId: z.string().nullable(),
83
+ permalink: z.url().nullable(),
84
+ shortId: z.string().min(1),
85
+ subject: z.string(),
86
+ subreddit: z.string().nullable(),
87
+ wasComment: z.boolean(),
88
+ });
89
+ export const REDDIT_SUBREDDIT_SCHEMA = z.object({
90
+ activeUserCount: z.number().int().nonnegative().nullable(),
91
+ createdAt: z.date(),
92
+ description: z.string(),
93
+ displayName: z.string(),
94
+ id: z.string().regex(/^t5_[a-z0-9]+$/i),
95
+ isNsfw: z.boolean(),
96
+ name: z.string(),
97
+ publicDescription: z.string(),
98
+ subscribers: z.number().int().nonnegative(),
99
+ title: z.string(),
100
+ url: z.url(),
101
+ userIsSubscriber: z.boolean(),
102
+ });
103
+ export const REDDIT_PROFILE_SCHEMA = z.object({
104
+ commentKarma: z.number().int(),
105
+ createdAt: z.date(),
106
+ hasVerifiedEmail: z.boolean().nullable(),
107
+ id: z.string().min(1),
108
+ isEmployee: z.boolean(),
109
+ isModerator: z.boolean(),
110
+ linkKarma: z.number().int(),
111
+ username: z.string(),
112
+ });
113
+ export const REDDIT_PAGE_INFO_SCHEMA = z.object({
114
+ after: z.string().nullable(),
115
+ before: z.string().nullable(),
116
+ });
117
+ export const REDDIT_POST_PAGE_SCHEMA = z.object({
118
+ pageInfo: REDDIT_PAGE_INFO_SCHEMA,
119
+ posts: REDDIT_POST_SCHEMA.array(),
120
+ });
121
+ export const REDDIT_CONTENT_PAGE_SCHEMA = z.object({
122
+ content: z.union([REDDIT_POST_SCHEMA, REDDIT_COMMENT_SCHEMA]).array(),
123
+ pageInfo: REDDIT_PAGE_INFO_SCHEMA,
124
+ });
125
+ export const REDDIT_MESSAGE_PAGE_SCHEMA = z.object({
126
+ messages: REDDIT_MESSAGE_SCHEMA.array(),
127
+ pageInfo: REDDIT_PAGE_INFO_SCHEMA,
128
+ });
129
+ export const REDDIT_POST_FLAIR_TEMPLATE_SCHEMA = z.object({
130
+ backgroundColor: z.string(),
131
+ cssClass: z.string(),
132
+ id: z.string(),
133
+ isEditable: z.boolean(),
134
+ text: z.string(),
135
+ textColor: z.enum(["dark", "light"]),
136
+ });
137
+ export const REDDIT_PROVIDER_PROFILE_SCHEMA = z.looseObject({
138
+ comment_karma: z.number().int(),
139
+ created_utc: z.number(),
140
+ has_verified_email: z.boolean().nullable(),
141
+ id: z.string().min(1),
142
+ is_employee: z.boolean(),
143
+ is_mod: z.boolean(),
144
+ link_karma: z.number().int(),
145
+ name: z.string(),
146
+ });
147
+ export const REDDIT_PROVIDER_POST_SCHEMA = z.looseObject({
148
+ archived: z.boolean().default(false),
149
+ author: z.string().nullable().catch(null),
150
+ created_utc: z.number(),
151
+ edited: REDDIT_EDITED_SCHEMA.catch(false),
152
+ hidden: z.boolean().default(false),
153
+ id: z.string().min(1),
154
+ is_self: z.boolean().default(false),
155
+ link_flair_background_color: z.string().nullable().catch(null),
156
+ link_flair_css_class: z.string().nullable().catch(null),
157
+ link_flair_template_id: z.string().nullable().catch(null),
158
+ link_flair_text: z.string().nullable().catch(null),
159
+ link_flair_text_color: z.enum(["dark", "light"]).nullable().catch(null),
160
+ locked: z.boolean().default(false),
161
+ name: REDDIT_POST_ID_SCHEMA,
162
+ num_comments: z.number().int().nonnegative().default(0),
163
+ over_18: z.boolean().default(false),
164
+ permalink: z.string(),
165
+ saved: z.boolean().default(false),
166
+ score: z.number().int().default(0),
167
+ selftext: z.string().default(""),
168
+ spoiler: z.boolean().default(false),
169
+ stickied: z.boolean().default(false),
170
+ subreddit: z.string(),
171
+ subreddit_id: z.string(),
172
+ title: z.string(),
173
+ upvote_ratio: z.number().min(0).max(1).default(0),
174
+ url: z.string(),
175
+ });
176
+ export const REDDIT_PROVIDER_COMMENT_SCHEMA = z.looseObject({
177
+ author: z.string().nullable().catch(null),
178
+ body: z.string().default(""),
179
+ created_utc: z.number(),
180
+ depth: z.number().int().nonnegative().default(0),
181
+ distinguished: z
182
+ .enum(["admin", "moderator", "special"])
183
+ .nullable()
184
+ .catch(null),
185
+ edited: REDDIT_EDITED_SCHEMA.catch(false),
186
+ id: z.string().min(1),
187
+ is_submitter: z.boolean().default(false),
188
+ link_id: REDDIT_POST_ID_SCHEMA,
189
+ name: z.string().regex(/^t1_[a-z0-9]+$/i),
190
+ parent_id: REDDIT_THING_ID_SCHEMA,
191
+ permalink: z.string(),
192
+ saved: z.boolean().default(false),
193
+ score: z.number().int().default(0),
194
+ stickied: z.boolean().default(false),
195
+ subreddit: z.string(),
196
+ });
197
+ export const REDDIT_PROVIDER_MESSAGE_SCHEMA = z.looseObject({
198
+ author: z.string().nullable().catch(null),
199
+ body: z.string().default(""),
200
+ context: z.string().default(""),
201
+ created_utc: z.number(),
202
+ dest: z.string().default(""),
203
+ id: z.string().min(1),
204
+ name: REDDIT_INBOX_ID_SCHEMA,
205
+ new: z.boolean().default(false),
206
+ parent_id: z.string().nullable().catch(null),
207
+ permalink: z.string().nullable().catch(null),
208
+ subject: z.string().default(""),
209
+ subreddit: z.string().nullable().catch(null),
210
+ was_comment: z.boolean().default(false),
211
+ });
212
+ export const REDDIT_PROVIDER_SUBREDDIT_SCHEMA = z.looseObject({
213
+ active_user_count: z.number().int().nonnegative().nullable().catch(null),
214
+ created_utc: z.number(),
215
+ description: z.string().default(""),
216
+ display_name: z.string(),
217
+ display_name_prefixed: z.string(),
218
+ name: z.string().regex(/^t5_[a-z0-9]+$/i),
219
+ over18: z.boolean().default(false),
220
+ public_description: z.string().default(""),
221
+ subscribers: z.number().int().nonnegative().default(0),
222
+ title: z.string(),
223
+ url: z.string(),
224
+ user_is_subscriber: z.boolean().nullable().catch(null),
225
+ });
226
+ export const REDDIT_PROVIDER_POST_THING_SCHEMA = z.object({
227
+ data: REDDIT_PROVIDER_POST_SCHEMA,
228
+ kind: z.literal("t3"),
229
+ });
230
+ export const REDDIT_PROVIDER_COMMENT_THING_SCHEMA = z.object({
231
+ data: REDDIT_PROVIDER_COMMENT_SCHEMA,
232
+ kind: z.literal("t1"),
233
+ });
234
+ export const REDDIT_PROVIDER_MESSAGE_THING_SCHEMA = z.object({
235
+ data: REDDIT_PROVIDER_MESSAGE_SCHEMA,
236
+ kind: z.literal("t4"),
237
+ });
238
+ export const REDDIT_PROVIDER_INBOX_THING_SCHEMA = z.union([
239
+ REDDIT_PROVIDER_MESSAGE_THING_SCHEMA,
240
+ z.object({
241
+ data: REDDIT_PROVIDER_MESSAGE_SCHEMA,
242
+ kind: z.literal("t1"),
243
+ }),
244
+ ]);
245
+ export const REDDIT_PROVIDER_SUBREDDIT_THING_SCHEMA = z.object({
246
+ data: REDDIT_PROVIDER_SUBREDDIT_SCHEMA,
247
+ kind: z.literal("t5"),
248
+ });
249
+ export const REDDIT_PROVIDER_CONTENT_THING_SCHEMA = z.union([
250
+ REDDIT_PROVIDER_POST_THING_SCHEMA,
251
+ REDDIT_PROVIDER_COMMENT_THING_SCHEMA,
252
+ ]);
253
+ /** Builds one provider listing schema with exact child contracts. */
254
+ /** @param childSchema - Schema for one listing child. */
255
+ export function redditProviderListingSchema(childSchema) {
256
+ return z.object({
257
+ data: z.object({
258
+ after: z.string().nullable(),
259
+ before: z.string().nullable(),
260
+ children: childSchema.array(),
261
+ }),
262
+ kind: z.literal("Listing"),
263
+ });
264
+ }
265
+ export const REDDIT_PROVIDER_POST_LISTING_SCHEMA = redditProviderListingSchema(REDDIT_PROVIDER_POST_THING_SCHEMA);
266
+ export const REDDIT_PROVIDER_CONTENT_LISTING_SCHEMA = redditProviderListingSchema(REDDIT_PROVIDER_CONTENT_THING_SCHEMA);
267
+ export const REDDIT_PROVIDER_MESSAGE_LISTING_SCHEMA = redditProviderListingSchema(REDDIT_PROVIDER_INBOX_THING_SCHEMA);
268
+ export const REDDIT_PROVIDER_FLAIR_TEMPLATE_SCHEMA = z.looseObject({
269
+ background_color: z.string().default(""),
270
+ css_class: z.string().default(""),
271
+ flair_template_id: z.string(),
272
+ flair_text_editable: z.boolean().default(false),
273
+ flair_text: z.string().default(""),
274
+ text_color: z.enum(["dark", "light"]).default("dark"),
275
+ });
276
+ /** Converts a Reddit provider post into the stable public post shape. */
277
+ /** @param post - Parsed provider post. */
278
+ export function toRedditPost(post) {
279
+ return REDDIT_POST_SCHEMA.parse({
280
+ archived: post.archived,
281
+ author: post.author,
282
+ body: post.selftext,
283
+ commentCount: post.num_comments,
284
+ createdAt: new Date(post.created_utc * 1_000),
285
+ editedAt: post.edited === false ? null : new Date(post.edited * 1_000),
286
+ flair: {
287
+ backgroundColor: post.link_flair_background_color,
288
+ cssClass: post.link_flair_css_class,
289
+ templateId: post.link_flair_template_id,
290
+ text: post.link_flair_text,
291
+ textColor: post.link_flair_text_color,
292
+ },
293
+ hidden: post.hidden,
294
+ id: post.name,
295
+ isLocked: post.locked,
296
+ isNsfw: post.over_18,
297
+ isSelfPost: post.is_self,
298
+ isSpoiler: post.spoiler,
299
+ isStickied: post.stickied,
300
+ permalink: new URL(post.permalink, REDDIT_ORIGIN).toString(),
301
+ saved: post.saved,
302
+ score: post.score,
303
+ shortId: post.id,
304
+ subreddit: post.subreddit,
305
+ subredditId: post.subreddit_id,
306
+ title: post.title,
307
+ upvoteRatio: post.upvote_ratio,
308
+ url: new URL(post.url, REDDIT_ORIGIN).toString(),
309
+ });
310
+ }
311
+ /** Converts a Reddit provider comment into the stable public comment shape. */
312
+ /** @param comment - Parsed provider comment. */
313
+ export function toRedditComment(comment) {
314
+ return REDDIT_COMMENT_SCHEMA.parse({
315
+ author: comment.author,
316
+ body: comment.body,
317
+ createdAt: new Date(comment.created_utc * 1_000),
318
+ depth: comment.depth,
319
+ distinguished: comment.distinguished,
320
+ editedAt: comment.edited === false ? null : new Date(comment.edited * 1_000),
321
+ id: comment.name,
322
+ isPostAuthor: comment.is_submitter,
323
+ isStickied: comment.stickied,
324
+ parentId: comment.parent_id,
325
+ permalink: new URL(comment.permalink, REDDIT_ORIGIN).toString(),
326
+ postId: comment.link_id,
327
+ saved: comment.saved,
328
+ score: comment.score,
329
+ shortId: comment.id,
330
+ subreddit: comment.subreddit,
331
+ });
332
+ }
333
+ /** Converts a Reddit inbox record into the stable public message shape. */
334
+ /** @param message - Parsed provider inbox record. */
335
+ export function toRedditMessage(message) {
336
+ return REDDIT_MESSAGE_SCHEMA.parse({
337
+ author: message.author,
338
+ body: message.body,
339
+ context: message.context,
340
+ createdAt: new Date(message.created_utc * 1_000),
341
+ destination: message.dest,
342
+ id: message.name,
343
+ isNew: message.new,
344
+ parentId: message.parent_id,
345
+ permalink: message.permalink
346
+ ? new URL(message.permalink, REDDIT_ORIGIN).toString()
347
+ : null,
348
+ shortId: message.id,
349
+ subject: message.subject,
350
+ subreddit: message.subreddit,
351
+ wasComment: message.was_comment,
352
+ });
353
+ }
354
+ /** Converts a Reddit subreddit thing into the stable public shape. */
355
+ /** @param subreddit - Parsed provider subreddit. */
356
+ export function toRedditSubreddit(subreddit) {
357
+ return REDDIT_SUBREDDIT_SCHEMA.parse({
358
+ activeUserCount: subreddit.active_user_count,
359
+ createdAt: new Date(subreddit.created_utc * 1_000),
360
+ description: subreddit.description,
361
+ displayName: subreddit.display_name,
362
+ id: subreddit.name,
363
+ isNsfw: subreddit.over18,
364
+ name: subreddit.display_name_prefixed,
365
+ publicDescription: subreddit.public_description,
366
+ subscribers: subreddit.subscribers,
367
+ title: subreddit.title,
368
+ url: new URL(subreddit.url, REDDIT_ORIGIN).toString(),
369
+ userIsSubscriber: subreddit.user_is_subscriber ?? false,
370
+ });
371
+ }
372
+ /** Converts a Reddit identity response into the stable public profile shape. */
373
+ /** @param profile - Parsed provider identity. */
374
+ export function toRedditProfile(profile) {
375
+ return REDDIT_PROFILE_SCHEMA.parse({
376
+ commentKarma: profile.comment_karma,
377
+ createdAt: new Date(profile.created_utc * 1_000),
378
+ hasVerifiedEmail: profile.has_verified_email,
379
+ id: profile.id,
380
+ isEmployee: profile.is_employee,
381
+ isModerator: profile.is_mod,
382
+ linkKarma: profile.link_karma,
383
+ username: profile.name,
384
+ });
385
+ }
386
+ /** Converts provider listing cursors into stable public pagination metadata. */
387
+ /**
388
+ * @param listing - Parsed provider listing.
389
+ * @param listing.data - Provider listing data.
390
+ * @param listing.data.after - Forward cursor.
391
+ * @param listing.data.before - Backward cursor.
392
+ */
393
+ export function toRedditPageInfo(listing) {
394
+ return REDDIT_PAGE_INFO_SCHEMA.parse({
395
+ after: listing.data.after,
396
+ before: listing.data.before,
397
+ });
398
+ }
@@ -18,6 +18,7 @@ import type { linearTriggerContracts } from "./linear/index.js";
18
18
  import type { millionVerifierTriggerContracts } from "./millionverifier/index.js";
19
19
  import type { notionTriggerContracts } from "./notion/index.js";
20
20
  import type { outlookTriggerContracts } from "./outlook/index.js";
21
+ import type { redditTriggerContracts } from "./reddit/index.js";
21
22
  import type { resendTriggerContracts } from "./resend/index.js";
22
23
  import type { slackTriggerContracts } from "./slack/index.js";
23
24
  import type { stripeTriggerContracts } from "./stripe/index.js";
@@ -27,7 +28,7 @@ import type { vercelTriggerContracts } from "./vercel/index.js";
27
28
  import type { webflowTriggerContracts } from "./webflow/index.js";
28
29
  import type { whatsappTriggerContracts } from "./whatsapp/index.js";
29
30
  import type { z } from "zod";
30
- export type TriggerContractMap = typeof airtableTriggerContracts & typeof apifyTriggerContracts & typeof automateTriggerContracts & typeof asanaTriggerContracts & typeof brevoTriggerContracts & typeof convexTriggerContracts & typeof closeTriggerContracts & typeof cloudflareTriggerContracts & typeof githubTriggerContracts & typeof gmailTriggerContracts & typeof googleCalendarTriggerContracts & typeof googleDriveTriggerContracts & typeof googleFormsTriggerContracts & typeof googleMeetTriggerContracts & typeof googleSheetsTriggerContracts & typeof hubspotTriggerContracts & typeof linearTriggerContracts & typeof millionVerifierTriggerContracts & typeof notionTriggerContracts & typeof outlookTriggerContracts & typeof resendTriggerContracts & typeof slackTriggerContracts & typeof stripeTriggerContracts & typeof teamsTriggerContracts & typeof trelloTriggerContracts & typeof vercelTriggerContracts & typeof webflowTriggerContracts & typeof whatsappTriggerContracts;
31
+ export type TriggerContractMap = typeof airtableTriggerContracts & typeof apifyTriggerContracts & typeof automateTriggerContracts & typeof asanaTriggerContracts & typeof brevoTriggerContracts & typeof convexTriggerContracts & typeof closeTriggerContracts & typeof cloudflareTriggerContracts & typeof githubTriggerContracts & typeof gmailTriggerContracts & typeof googleCalendarTriggerContracts & typeof googleDriveTriggerContracts & typeof googleFormsTriggerContracts & typeof googleMeetTriggerContracts & typeof googleSheetsTriggerContracts & typeof hubspotTriggerContracts & typeof linearTriggerContracts & typeof millionVerifierTriggerContracts & typeof notionTriggerContracts & typeof outlookTriggerContracts & typeof redditTriggerContracts & typeof resendTriggerContracts & typeof slackTriggerContracts & typeof stripeTriggerContracts & typeof teamsTriggerContracts & typeof trelloTriggerContracts & typeof vercelTriggerContracts & typeof webflowTriggerContracts & typeof whatsappTriggerContracts;
31
32
  export type IntegrationTriggerType = keyof TriggerContractMap;
32
33
  /** Canonical authoring configuration for one integration trigger type. */
33
34
  export type TriggerConfig<TType extends IntegrationTriggerType> = z.input<TriggerContractMap[TType]["configSchema"]> extends Record<string, never> ? object : z.input<TriggerContractMap[TType]["configSchema"]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automate.ax/integration-contracts",
3
- "version": "0.125.0",
3
+ "version": "0.126.0",
4
4
  "description": "Shared integration payload contracts and provider primitives for Automate.ax.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,7 @@
39
39
  "./millionverifier": "./src/millionverifier/index.ts",
40
40
  "./notion": "./src/notion/index.ts",
41
41
  "./outlook": "./src/outlook/index.ts",
42
+ "./reddit": "./src/reddit/index.ts",
42
43
  "./resend": "./src/resend/index.ts",
43
44
  "./slack": "./src/slack/index.ts",
44
45
  "./stripe": "./src/stripe/index.ts",
@@ -56,7 +57,7 @@
56
57
  }
57
58
  },
58
59
  "dependencies": {
59
- "@automate.ax/codec": "0.125.0",
60
+ "@automate.ax/codec": "0.126.0",
60
61
  "@cfworker/json-schema": "^4.1.1",
61
62
  "@googleapis/calendar": "^15.0.0",
62
63
  "@googleapis/forms": "^6.0.1",
@@ -212,6 +213,11 @@
212
213
  "types": "./dist/outlook/index.d.ts",
213
214
  "default": "./dist/outlook/index.js"
214
215
  },
216
+ "./reddit": {
217
+ "bun": "./src/reddit/index.ts",
218
+ "types": "./dist/reddit/index.d.ts",
219
+ "default": "./dist/reddit/index.js"
220
+ },
215
221
  "./resend": {
216
222
  "bun": "./src/resend/index.ts",
217
223
  "types": "./dist/resend/index.d.ts",
@@ -0,0 +1,183 @@
1
+ import * as z from "zod"
2
+
3
+ const REDDIT_API_ORIGIN = "https://oauth.reddit.com/"
4
+ const REDDIT_API_URL = new URL(REDDIT_API_ORIGIN)
5
+
6
+ export const REDDIT_OAUTH_SECRET_SCHEMA = z.object({
7
+ accessToken: z.string().min(1),
8
+ expiresAt: z.number().int().positive(),
9
+ refreshToken: z.string().min(1),
10
+ tokenType: z.string().min(1),
11
+ userAgent: z.string().min(1),
12
+ })
13
+
14
+ const REDDIT_API_ERROR_SCHEMA = z.looseObject({
15
+ error: z.number().int().optional(),
16
+ message: z.string().optional(),
17
+ reason: z.string().optional(),
18
+ })
19
+ export const REDDIT_ERROR_TUPLE_SCHEMA = z.tuple([
20
+ z.string(),
21
+ z.string(),
22
+ z.string().nullable(),
23
+ ])
24
+ const REDDIT_JSON_ERRORS_SCHEMA = z.looseObject({
25
+ json: z.object({
26
+ errors: REDDIT_ERROR_TUPLE_SCHEMA.array(),
27
+ }),
28
+ })
29
+ export const REDDIT_JSON_STATUS_SCHEMA = z.object({
30
+ json: z.object({ errors: REDDIT_ERROR_TUPLE_SCHEMA.array() }),
31
+ })
32
+
33
+ export interface RedditRequestOptions<TSchema extends z.ZodType> {
34
+ body?: Record<string, boolean | number | string | undefined>
35
+ method?: "DELETE" | "GET" | "POST" | "PUT"
36
+ query?: Record<
37
+ string,
38
+ boolean | number | string | readonly string[] | null | undefined
39
+ >
40
+ responseSchema: TSchema
41
+ }
42
+
43
+ /** Error returned by a rejected Reddit Data API request. */
44
+ export class RedditApiError extends Error {
45
+ readonly details:
46
+ | { error?: number; message?: string; reason?: string }
47
+ | { errors: [string, string, null | string][] }
48
+ | { response: string }
49
+ readonly retryAfter?: string
50
+ readonly status: number
51
+
52
+ /**
53
+ * Creates an error from a rejected Reddit response.
54
+ *
55
+ * @param status - HTTP response status.
56
+ * @param details - Parsed Reddit failure details.
57
+ * @param retryAfter - Provider retry delay in seconds, when present.
58
+ */
59
+ constructor(
60
+ status: number,
61
+ details: RedditApiError["details"],
62
+ retryAfter?: string,
63
+ ) {
64
+ const jsonError = "errors" in details ? details.errors[0]?.[1] : undefined
65
+ super(
66
+ ("message" in details
67
+ ? (details.message ?? details.reason)
68
+ : undefined) ??
69
+ jsonError ??
70
+ `Reddit API request failed (${status}).`,
71
+ )
72
+ this.name = "RedditApiError"
73
+ this.details = details
74
+ this.retryAfter = retryAfter
75
+ this.status = status
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Creates an authenticated Reddit Data API client.
81
+ *
82
+ * @param secret - Stored Reddit OAuth credentials and registered User-Agent.
83
+ */
84
+ export function getRedditApi(secret: unknown) {
85
+ const credentials = REDDIT_OAUTH_SECRET_SCHEMA.parse(secret)
86
+
87
+ return {
88
+ /**
89
+ * Sends one request below Reddit's OAuth API origin.
90
+ *
91
+ * @param path - Relative Reddit API path.
92
+ * @param options - Request method, parameters, and response contract.
93
+ */
94
+ async request<TSchema extends z.ZodType>(
95
+ path: string,
96
+ options: RedditRequestOptions<TSchema>,
97
+ ): Promise<z.output<TSchema>> {
98
+ const normalizedPath = path.replace(/^\/+/, "")
99
+ if (
100
+ !normalizedPath ||
101
+ normalizedPath.includes("://") ||
102
+ normalizedPath.includes("\\")
103
+ ) {
104
+ throw new TypeError("Reddit API paths must be relative.")
105
+ }
106
+
107
+ const url = new URL(normalizedPath, REDDIT_API_ORIGIN)
108
+ if (url.origin !== REDDIT_API_URL.origin) {
109
+ throw new TypeError("Reddit API paths must remain on oauth.reddit.com.")
110
+ }
111
+ url.searchParams.set("raw_json", "1")
112
+ for (const [key, value] of Object.entries(options.query ?? {})) {
113
+ if (value == null) continue
114
+ url.searchParams.set(
115
+ key,
116
+ Array.isArray(value) ? value.join(",") : String(value),
117
+ )
118
+ }
119
+
120
+ const headers = new Headers({
121
+ Accept: "application/json",
122
+ Authorization: `${credentials.tokenType} ${credentials.accessToken}`,
123
+ "User-Agent": credentials.userAgent,
124
+ })
125
+ const body = options.body
126
+ ? new URLSearchParams(
127
+ Object.entries(options.body).flatMap(([key, value]) =>
128
+ value === undefined
129
+ ? []
130
+ : ([[key, String(value)]] satisfies [string, string][]),
131
+ ),
132
+ )
133
+ : undefined
134
+ if (body) {
135
+ headers.set("Content-Type", "application/x-www-form-urlencoded")
136
+ }
137
+
138
+ const response = await fetch(url, {
139
+ body,
140
+ headers,
141
+ method: options.method ?? "GET",
142
+ })
143
+ const text = await response.text()
144
+ const parsed = parseJson(text)
145
+ const jsonErrors = REDDIT_JSON_ERRORS_SCHEMA.safeParse(parsed)
146
+ const retryAfter =
147
+ response.headers.get("Retry-After") ??
148
+ response.headers.get("X-Ratelimit-Reset") ??
149
+ undefined
150
+ if (jsonErrors.success && jsonErrors.data.json.errors.length > 0) {
151
+ throw new RedditApiError(
152
+ response.ok ? 400 : response.status,
153
+ { errors: jsonErrors.data.json.errors },
154
+ retryAfter,
155
+ )
156
+ }
157
+ if (!response.ok) {
158
+ const error = REDDIT_API_ERROR_SCHEMA.safeParse(parsed)
159
+ throw new RedditApiError(
160
+ response.status,
161
+ error.success ? error.data : { response: text },
162
+ retryAfter,
163
+ )
164
+ }
165
+ const result = options.responseSchema.safeParse(parsed)
166
+ if (!result.success) {
167
+ throw new RedditApiError(response.status, { response: text })
168
+ }
169
+ return result.data
170
+ },
171
+ }
172
+ }
173
+
174
+ /** Parses a provider body while preserving non-JSON failure text. */
175
+ /** @param value - Raw provider response body. */
176
+ function parseJson(value: string): unknown {
177
+ if (!value) return undefined
178
+ try {
179
+ return JSON.parse(value)
180
+ } catch {
181
+ return value
182
+ }
183
+ }