reddit-mcp-server 1.4.7 → 1.5.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 +21 -0
- package/README.md +26 -22
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/index.js +714 -177
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -3,9 +3,162 @@ import dotenv from "dotenv";
|
|
|
3
3
|
import { FastMCP } from "fastmcp";
|
|
4
4
|
import { Left, Option, Right, Try } from "functype";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
//#region src/client/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* Typed error channel for the Reddit client.
|
|
9
|
+
*
|
|
10
|
+
* The client's methods are the imperative-to-functional boundary: each captures throws
|
|
11
|
+
* (network, HTTP, JSON parsing, validation, deliberate domain errors) inside a `Try` and
|
|
12
|
+
* converts to a typed `Either<RedditError, T>`. Modelling the error as a discriminated ADT
|
|
13
|
+
* — rather than a bare `Error` — makes the failure contract explicit at the type level, so
|
|
14
|
+
* callers can reason about (and branch on) what actually went wrong without string-matching.
|
|
15
|
+
*
|
|
16
|
+
* `classifyRedditError` is TOTAL and never re-throws — every captured `Error` maps to a
|
|
17
|
+
* variant — which is what makes the migration behavior-preserving: an error that was a
|
|
18
|
+
* graceful `Left` before is still a graceful `Left` after, just with a richer type.
|
|
19
|
+
*
|
|
20
|
+
* Two-tier behavior, to preserve the exact messages of the original try/catch code:
|
|
21
|
+
* - A *deliberate* typed throw (HttpError, NotFoundError, …) already carries its final
|
|
22
|
+
* message, so it passes through unchanged.
|
|
23
|
+
* - An *unexpected* generic error (fetch/JSON/orThrow) is wrapped as UnknownError, with the
|
|
24
|
+
* optional `context` prefix — present for read methods (which prefixed in their catch),
|
|
25
|
+
* absent for write methods (which returned the raw message).
|
|
26
|
+
*/
|
|
27
|
+
var RedditErrorBase = class extends Error {};
|
|
28
|
+
/** A non-ok HTTP response from the Reddit API. Carries the status for caller branching. */
|
|
29
|
+
var HttpError = class extends RedditErrorBase {
|
|
30
|
+
status;
|
|
31
|
+
_tag = "HttpError";
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.name = "HttpError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** A write operation was attempted without the required user credentials / in a wrong mode. */
|
|
39
|
+
var NotAuthenticatedError = class extends RedditErrorBase {
|
|
40
|
+
_tag = "NotAuthenticatedError";
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "NotAuthenticatedError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/** Reddit accepted the request but returned errors in its JSON envelope (or an unusable body). */
|
|
47
|
+
var ApiError = class extends RedditErrorBase {
|
|
48
|
+
_tag = "ApiError";
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "ApiError";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** The requested post/entity does not exist or is not accessible. */
|
|
55
|
+
var NotFoundError = class extends RedditErrorBase {
|
|
56
|
+
_tag = "NotFoundError";
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "NotFoundError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** Client-side input or safety-policy rejection (invalid sort, duplicate-content guard). */
|
|
63
|
+
var ValidationError = class extends RedditErrorBase {
|
|
64
|
+
_tag = "ValidationError";
|
|
65
|
+
constructor(message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "ValidationError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/** Any failure that is not a recognized category: network, JSON parsing, unexpected throws. */
|
|
71
|
+
var UnknownError = class extends RedditErrorBase {
|
|
72
|
+
cause;
|
|
73
|
+
_tag = "UnknownError";
|
|
74
|
+
constructor(message, cause) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.cause = cause;
|
|
77
|
+
this.name = "UnknownError";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
function isRedditError(error) {
|
|
81
|
+
return error instanceof RedditErrorBase;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Total classifier from a captured `Error` to a `RedditError`. Deliberate typed throws pass
|
|
85
|
+
* through unchanged; everything else becomes an `UnknownError`, prefixed with `context` when
|
|
86
|
+
* provided so the observable message text matches the previous try/catch-based wrapping.
|
|
87
|
+
*/
|
|
88
|
+
function classifyRedditError(error, context) {
|
|
89
|
+
if (isRedditError(error)) return error;
|
|
90
|
+
return new UnknownError(Option(context).fold(() => error.message, (ctx) => `${ctx}: ${error.message}`), error);
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/client/response-cache.ts
|
|
94
|
+
const SECOND = 1e3;
|
|
95
|
+
var ResponseCache = class {
|
|
96
|
+
maxBytes;
|
|
97
|
+
now;
|
|
98
|
+
entries = /* @__PURE__ */ new Map();
|
|
99
|
+
currentBytes = 0;
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.maxBytes = options.maxBytes;
|
|
102
|
+
this.now = options.now ?? Date.now;
|
|
103
|
+
}
|
|
104
|
+
/** Adaptive TTL (in milliseconds) for a given request URL. */
|
|
105
|
+
ttlFor(url) {
|
|
106
|
+
if (/\/(hot|new|rising)\.json/.test(url)) return 60 * SECOND;
|
|
107
|
+
if (/\/(top|controversial)\.json/.test(url) || /\/search\.json/.test(url) || /\/about\.json/.test(url)) return 300 * SECOND;
|
|
108
|
+
if (/\/comments\//.test(url)) return 60 * SECOND;
|
|
109
|
+
return 120 * SECOND;
|
|
110
|
+
}
|
|
111
|
+
get(url) {
|
|
112
|
+
const entry = this.entries.get(url);
|
|
113
|
+
if (entry === void 0) return;
|
|
114
|
+
if (this.now() >= entry.expiresAt) {
|
|
115
|
+
this.entries.delete(url);
|
|
116
|
+
this.currentBytes -= entry.bytes;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.entries.delete(url);
|
|
120
|
+
this.entries.set(url, entry);
|
|
121
|
+
return {
|
|
122
|
+
body: entry.body,
|
|
123
|
+
status: entry.status
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
set(url, body, status) {
|
|
127
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
128
|
+
if (bytes > this.maxBytes) return;
|
|
129
|
+
const existing = this.entries.get(url);
|
|
130
|
+
if (existing !== void 0) {
|
|
131
|
+
this.entries.delete(url);
|
|
132
|
+
this.currentBytes -= existing.bytes;
|
|
133
|
+
}
|
|
134
|
+
this.entries.set(url, {
|
|
135
|
+
body,
|
|
136
|
+
status,
|
|
137
|
+
expiresAt: this.now() + this.ttlFor(url),
|
|
138
|
+
bytes
|
|
139
|
+
});
|
|
140
|
+
this.currentBytes += bytes;
|
|
141
|
+
this.evictUntilWithinBudget();
|
|
142
|
+
}
|
|
143
|
+
evictUntilWithinBudget() {
|
|
144
|
+
while (this.currentBytes > this.maxBytes) {
|
|
145
|
+
const oldestKey = this.entries.keys().next().value;
|
|
146
|
+
if (oldestKey === void 0) return;
|
|
147
|
+
const oldest = this.entries.get(oldestKey);
|
|
148
|
+
this.entries.delete(oldestKey);
|
|
149
|
+
if (oldest !== void 0) this.currentBytes -= oldest.bytes;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
//#endregion
|
|
6
154
|
//#region src/client/reddit-client.ts
|
|
7
|
-
function
|
|
8
|
-
|
|
155
|
+
function listingCursor(data) {
|
|
156
|
+
const after = typeof data.after === "string" ? { after: data.after } : {};
|
|
157
|
+
const before = typeof data.before === "string" ? { before: data.before } : {};
|
|
158
|
+
return {
|
|
159
|
+
...after,
|
|
160
|
+
...before
|
|
161
|
+
};
|
|
9
162
|
}
|
|
10
163
|
function parsePostData(post) {
|
|
11
164
|
return {
|
|
@@ -38,12 +191,15 @@ var RedditClient = class {
|
|
|
38
191
|
hasCredentials;
|
|
39
192
|
safeMode;
|
|
40
193
|
botDisclosure;
|
|
194
|
+
cache;
|
|
195
|
+
retry;
|
|
41
196
|
accessToken;
|
|
42
197
|
tokenExpiry = 0;
|
|
43
198
|
authenticated = false;
|
|
44
199
|
lastWriteTime = 0;
|
|
45
200
|
recentContentRecords = [];
|
|
46
201
|
constructor(config) {
|
|
202
|
+
var _config$cache;
|
|
47
203
|
this.clientId = config.clientId;
|
|
48
204
|
this.clientSecret = config.clientSecret;
|
|
49
205
|
this.userAgent = config.userAgent;
|
|
@@ -63,6 +219,12 @@ var RedditClient = class {
|
|
|
63
219
|
enabled: false,
|
|
64
220
|
footer: ""
|
|
65
221
|
};
|
|
222
|
+
this.cache = ((_config$cache = config.cache) === null || _config$cache === void 0 ? void 0 : _config$cache.enabled) === true ? new ResponseCache({ maxBytes: config.cache.maxBytes }) : void 0;
|
|
223
|
+
this.retry = config.retry ?? {
|
|
224
|
+
maxRetries: 3,
|
|
225
|
+
baseDelayMs: 1e3,
|
|
226
|
+
maxDelayMs: 6e4
|
|
227
|
+
};
|
|
66
228
|
}
|
|
67
229
|
determineBaseUrl() {
|
|
68
230
|
switch (this.authMode) {
|
|
@@ -72,34 +234,33 @@ var RedditClient = class {
|
|
|
72
234
|
}
|
|
73
235
|
}
|
|
74
236
|
async makeRequest(path, options = {}) {
|
|
75
|
-
|
|
237
|
+
return (await Try.async(async () => {
|
|
238
|
+
const url = `${this.baseUrl}${path}`;
|
|
239
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
240
|
+
const cacheable = this.cache !== void 0 && method === "GET";
|
|
241
|
+
if (cacheable) {
|
|
242
|
+
const cached = this.cache.get(url);
|
|
243
|
+
if (cached !== void 0) return new Response(cached.body, { status: cached.status });
|
|
244
|
+
}
|
|
76
245
|
const requiresAuth = this.authMode === "authenticated" || this.authMode === "auto" && this.hasCredentials;
|
|
77
246
|
if (requiresAuth && (Date.now() >= this.tokenExpiry || !this.authenticated)) (await this.authenticate()).orThrow();
|
|
78
|
-
const url = `${this.baseUrl}${path}`;
|
|
79
247
|
const headers = {
|
|
80
248
|
"User-Agent": this.userAgent,
|
|
81
249
|
...options.headers
|
|
82
250
|
};
|
|
83
251
|
if (requiresAuth && this.accessToken !== void 0) headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
headers
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
};
|
|
94
|
-
return Right(await fetch(url, {
|
|
95
|
-
...options,
|
|
96
|
-
headers: retryHeaders
|
|
97
|
-
}));
|
|
252
|
+
const first = await this.fetchWithRetry(url, options, headers, path, 0);
|
|
253
|
+
const response = first.status === 401 && this.authenticated ? await this.fetchWithRetry(url, options, {
|
|
254
|
+
...headers,
|
|
255
|
+
Authorization: await this.reauthorize()
|
|
256
|
+
}, path, 0) : first;
|
|
257
|
+
if (cacheable && response.ok) {
|
|
258
|
+
const text = await response.text();
|
|
259
|
+
this.cache.set(url, text, response.status);
|
|
260
|
+
return new Response(text, { status: response.status });
|
|
98
261
|
}
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
return Left(toError(error));
|
|
102
|
-
}
|
|
262
|
+
return response;
|
|
263
|
+
})).toEither((error) => error);
|
|
103
264
|
}
|
|
104
265
|
async authenticate() {
|
|
105
266
|
if (this.authMode === "anonymous") {
|
|
@@ -111,9 +272,9 @@ var RedditClient = class {
|
|
|
111
272
|
this.authenticated = false;
|
|
112
273
|
return Right(void 0);
|
|
113
274
|
}
|
|
114
|
-
|
|
275
|
+
return (await Try.async(async () => {
|
|
115
276
|
const now = Date.now();
|
|
116
|
-
if (this.accessToken !== void 0 && now < this.tokenExpiry) return
|
|
277
|
+
if (this.accessToken !== void 0 && now < this.tokenExpiry) return;
|
|
117
278
|
const authUrl = "https://www.reddit.com/api/v1/access_token";
|
|
118
279
|
const authData = new URLSearchParams();
|
|
119
280
|
const { username } = this;
|
|
@@ -135,16 +296,13 @@ var RedditClient = class {
|
|
|
135
296
|
});
|
|
136
297
|
if (!response.ok) {
|
|
137
298
|
const statusText = response.statusText !== "" ? response.statusText : "Unknown Error";
|
|
138
|
-
|
|
299
|
+
throw new Error(`Authentication failed: ${response.status} ${statusText}`);
|
|
139
300
|
}
|
|
140
301
|
const data = await response.json();
|
|
141
302
|
this.accessToken = data.access_token;
|
|
142
303
|
this.tokenExpiry = now + data.expires_in * 1e3;
|
|
143
304
|
this.authenticated = true;
|
|
144
|
-
|
|
145
|
-
} catch (error) {
|
|
146
|
-
return Left(toError(error));
|
|
147
|
-
}
|
|
305
|
+
})).toEither((error) => error);
|
|
148
306
|
}
|
|
149
307
|
async checkAuthentication() {
|
|
150
308
|
if (!this.authenticated) return (await this.authenticate()).isRight();
|
|
@@ -152,8 +310,8 @@ var RedditClient = class {
|
|
|
152
310
|
}
|
|
153
311
|
validateWriteAccess() {
|
|
154
312
|
if (this.username === void 0 || this.password === void 0) {
|
|
155
|
-
if (this.authMode === "anonymous") throw new
|
|
156
|
-
throw new
|
|
313
|
+
if (this.authMode === "anonymous") throw new NotAuthenticatedError("Write operations not available in anonymous mode. Set REDDIT_USERNAME, REDDIT_PASSWORD and use 'auto' or 'authenticated' mode.");
|
|
314
|
+
throw new NotAuthenticatedError("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
|
|
157
315
|
}
|
|
158
316
|
}
|
|
159
317
|
async enforceWriteRateLimit() {
|
|
@@ -174,8 +332,8 @@ var RedditClient = class {
|
|
|
174
332
|
const hash = this.hashContent(content);
|
|
175
333
|
const duplicate = this.recentContentRecords.find((record) => record.hash === hash);
|
|
176
334
|
if (duplicate !== void 0) {
|
|
177
|
-
if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new
|
|
178
|
-
throw new
|
|
335
|
+
if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new ValidationError("Cross-subreddit duplicate detected. Reddit's Responsible Builder Policy prohibits posting identical or substantially similar content across multiple subreddits. Please create unique content for each subreddit.");
|
|
336
|
+
throw new ValidationError("Duplicate content detected. Reddit's spam filter may ban your account for posting identical content. Please modify your content and try again.");
|
|
179
337
|
}
|
|
180
338
|
this.recentContentRecords.push({
|
|
181
339
|
hash,
|
|
@@ -184,16 +342,49 @@ var RedditClient = class {
|
|
|
184
342
|
});
|
|
185
343
|
this.recentContentRecords = this.recentContentRecords.slice(-this.safeMode.maxRecentHashes);
|
|
186
344
|
}
|
|
345
|
+
async reauthorize() {
|
|
346
|
+
(await this.authenticate()).orThrow();
|
|
347
|
+
return `Bearer ${this.accessToken}`;
|
|
348
|
+
}
|
|
349
|
+
async fetchWithRetry(url, options, headers, path, attempt) {
|
|
350
|
+
const response = await fetch(url, {
|
|
351
|
+
...options,
|
|
352
|
+
headers
|
|
353
|
+
});
|
|
354
|
+
if (response.status !== 429 || attempt >= this.retry.maxRetries) return response;
|
|
355
|
+
const wait = this.retryAfterMs(response).fold(() => Math.min(this.retry.baseDelayMs * 2 ** attempt, this.retry.maxDelayMs), (ms) => ms);
|
|
356
|
+
if (wait > this.retry.maxDelayMs) return response;
|
|
357
|
+
console.error(`[RateLimit] 429 from ${path} — retry ${attempt + 1}/${this.retry.maxRetries} in ${wait}ms`);
|
|
358
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
359
|
+
return this.fetchWithRetry(url, options, headers, path, attempt + 1);
|
|
360
|
+
}
|
|
361
|
+
retryAfterMs(response) {
|
|
362
|
+
const { headers } = response;
|
|
363
|
+
const retryAfter = headers.get("retry-after");
|
|
364
|
+
if (retryAfter !== null && retryAfter !== "") {
|
|
365
|
+
const seconds = Number(retryAfter);
|
|
366
|
+
if (!Number.isNaN(seconds)) return Option(seconds * 1e3);
|
|
367
|
+
const when = Date.parse(retryAfter);
|
|
368
|
+
if (!Number.isNaN(when)) return Option(Math.max(0, when - Date.now()));
|
|
369
|
+
}
|
|
370
|
+
const reset = headers.get("x-ratelimit-reset");
|
|
371
|
+
if (reset !== null && reset !== "") {
|
|
372
|
+
const seconds = Number(reset);
|
|
373
|
+
if (!Number.isNaN(seconds)) return Option(seconds * 1e3);
|
|
374
|
+
}
|
|
375
|
+
return Option.none();
|
|
376
|
+
}
|
|
187
377
|
appendBotDisclosure(content) {
|
|
188
378
|
if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
|
|
189
379
|
return `${content}${this.botDisclosure.footer}`;
|
|
190
380
|
}
|
|
191
381
|
async getUser(username) {
|
|
192
|
-
|
|
382
|
+
const context = `Failed to get user info for ${username}`;
|
|
383
|
+
return (await Try.async(async () => {
|
|
193
384
|
const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
|
|
194
|
-
if (!response.ok)
|
|
385
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
195
386
|
const { data } = await response.json();
|
|
196
|
-
return
|
|
387
|
+
return {
|
|
197
388
|
name: data.name,
|
|
198
389
|
id: data.id,
|
|
199
390
|
commentKarma: data.comment_karma,
|
|
@@ -204,17 +395,79 @@ var RedditClient = class {
|
|
|
204
395
|
isEmployee: data.is_employee,
|
|
205
396
|
createdUtc: data.created_utc,
|
|
206
397
|
profileUrl: `https://reddit.com/user/${data.name}`
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
398
|
+
};
|
|
399
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
400
|
+
}
|
|
401
|
+
async getUserContent(path, context) {
|
|
402
|
+
return (await Try.async(async () => {
|
|
403
|
+
const response = (await this.makeRequest(path)).orThrow();
|
|
404
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
405
|
+
const json = await response.json();
|
|
406
|
+
return {
|
|
407
|
+
posts: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
|
|
408
|
+
comments: json.data.children.filter((child) => child.kind === "t1").map((child) => {
|
|
409
|
+
const comment = child.data;
|
|
410
|
+
return {
|
|
411
|
+
id: comment.id,
|
|
412
|
+
author: comment.author,
|
|
413
|
+
body: comment.body ?? "",
|
|
414
|
+
score: comment.score,
|
|
415
|
+
controversiality: comment.controversiality,
|
|
416
|
+
subreddit: comment.subreddit,
|
|
417
|
+
submissionTitle: comment.link_title ?? "",
|
|
418
|
+
createdUtc: comment.created_utc,
|
|
419
|
+
edited: Boolean(comment.edited),
|
|
420
|
+
isSubmitter: comment.is_submitter,
|
|
421
|
+
permalink: comment.permalink,
|
|
422
|
+
parentId: comment.parent_id
|
|
423
|
+
};
|
|
424
|
+
}),
|
|
425
|
+
...listingCursor(json.data)
|
|
426
|
+
};
|
|
427
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
428
|
+
}
|
|
429
|
+
async getMyOverview(options = {}) {
|
|
430
|
+
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your overview requires REDDIT_USERNAME"));
|
|
431
|
+
const { limit = 25, after } = options;
|
|
432
|
+
const params = new URLSearchParams({ limit: limit.toString() });
|
|
433
|
+
if (after !== void 0) params.set("after", after);
|
|
434
|
+
return this.getUserContent(`/user/${this.username}/overview.json?${params}`, "Failed to get your overview");
|
|
435
|
+
}
|
|
436
|
+
async getMySaved(options = {}) {
|
|
437
|
+
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
|
|
438
|
+
const { limit = 25, after } = options;
|
|
439
|
+
const params = new URLSearchParams({ limit: limit.toString() });
|
|
440
|
+
if (after !== void 0) params.set("after", after);
|
|
441
|
+
return this.getUserContent(`/user/${this.username}/saved.json?${params}`, "Failed to get saved content");
|
|
442
|
+
}
|
|
443
|
+
async getMe() {
|
|
444
|
+
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
|
|
445
|
+
const context = "Failed to get authenticated user info";
|
|
446
|
+
return (await Try.async(async () => {
|
|
447
|
+
const response = (await this.makeRequest("/api/v1/me")).orThrow();
|
|
448
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
449
|
+
const data = await response.json();
|
|
450
|
+
return {
|
|
451
|
+
name: data.name,
|
|
452
|
+
id: data.id,
|
|
453
|
+
commentKarma: data.comment_karma,
|
|
454
|
+
linkKarma: data.link_karma,
|
|
455
|
+
totalKarma: data.total_karma ?? data.comment_karma + data.link_karma,
|
|
456
|
+
isMod: data.is_mod,
|
|
457
|
+
isGold: data.is_gold,
|
|
458
|
+
isEmployee: data.is_employee,
|
|
459
|
+
createdUtc: data.created_utc,
|
|
460
|
+
profileUrl: `https://reddit.com/user/${data.name}`
|
|
461
|
+
};
|
|
462
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
211
463
|
}
|
|
212
464
|
async getSubredditInfo(subredditName) {
|
|
213
|
-
|
|
465
|
+
const context = `Failed to get subreddit info for ${subredditName}`;
|
|
466
|
+
return (await Try.async(async () => {
|
|
214
467
|
const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
|
|
215
|
-
if (!response.ok)
|
|
468
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
216
469
|
const { data } = await response.json();
|
|
217
|
-
return
|
|
470
|
+
return {
|
|
218
471
|
displayName: data.display_name,
|
|
219
472
|
title: data.title,
|
|
220
473
|
description: data.description,
|
|
@@ -225,50 +478,103 @@ var RedditClient = class {
|
|
|
225
478
|
over18: data.over18,
|
|
226
479
|
subredditType: data.subreddit_type,
|
|
227
480
|
url: data.url
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
481
|
+
};
|
|
482
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
483
|
+
}
|
|
484
|
+
async getSubredditRules(subreddit) {
|
|
485
|
+
const context = `Failed to get rules for r/${subreddit}`;
|
|
486
|
+
return (await Try.async(async () => {
|
|
487
|
+
const response = (await this.makeRequest(`/r/${subreddit}/about/rules.json`)).orThrow();
|
|
488
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
489
|
+
return (await response.json()).rules.map((rule) => ({
|
|
490
|
+
shortName: rule.short_name,
|
|
491
|
+
description: rule.description,
|
|
492
|
+
kind: rule.kind,
|
|
493
|
+
violationReason: rule.violation_reason,
|
|
494
|
+
priority: rule.priority,
|
|
495
|
+
createdUtc: rule.created_utc
|
|
496
|
+
}));
|
|
497
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
498
|
+
}
|
|
499
|
+
async getPostFlairs(subreddit) {
|
|
500
|
+
const context = `Failed to get post flairs for r/${subreddit}`;
|
|
501
|
+
return (await Try.async(async () => {
|
|
502
|
+
const response = (await this.makeRequest(`/r/${subreddit}/api/link_flair_v2.json`)).orThrow();
|
|
503
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
504
|
+
return (await response.json()).map((flair) => ({
|
|
505
|
+
id: flair.id,
|
|
506
|
+
text: flair.text,
|
|
507
|
+
type: flair.type,
|
|
508
|
+
textEditable: flair.text_editable
|
|
509
|
+
}));
|
|
510
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
232
511
|
}
|
|
233
|
-
async getTopPosts(subreddit, timeFilter = "week", limit = 10) {
|
|
512
|
+
async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
|
|
234
513
|
const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
|
|
235
514
|
const params = new URLSearchParams({
|
|
236
515
|
t: timeFilter,
|
|
237
516
|
limit: limit.toString()
|
|
238
517
|
});
|
|
239
|
-
|
|
518
|
+
if (after !== void 0) params.set("after", after);
|
|
519
|
+
const context = `Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}`;
|
|
520
|
+
return (await Try.async(async () => {
|
|
240
521
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
241
|
-
if (!response.ok)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
522
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to get top posts: HTTP ${response.status}`);
|
|
523
|
+
const json = await response.json();
|
|
524
|
+
return {
|
|
525
|
+
items: json.data.children.map((child) => parsePostData(child.data)),
|
|
526
|
+
...listingCursor(json.data)
|
|
527
|
+
};
|
|
528
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
529
|
+
}
|
|
530
|
+
async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10, after) {
|
|
531
|
+
const validSorts = [
|
|
532
|
+
"hot",
|
|
533
|
+
"new",
|
|
534
|
+
"top",
|
|
535
|
+
"rising",
|
|
536
|
+
"controversial"
|
|
537
|
+
];
|
|
538
|
+
if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
|
|
539
|
+
const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
|
|
540
|
+
const params = new URLSearchParams({ limit: limit.toString() });
|
|
541
|
+
if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
|
|
542
|
+
if (after !== void 0) params.set("after", after);
|
|
543
|
+
const home = subreddit !== "" ? subreddit : "home";
|
|
544
|
+
const context = `Failed to browse r/${home} (${sort})`;
|
|
545
|
+
return (await Try.async(async () => {
|
|
546
|
+
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
547
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to browse r/${home}: HTTP ${response.status}`);
|
|
548
|
+
const json = await response.json();
|
|
549
|
+
return {
|
|
550
|
+
items: json.data.children.map((child) => parsePostData(child.data)),
|
|
551
|
+
...listingCursor(json.data)
|
|
552
|
+
};
|
|
553
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
246
554
|
}
|
|
247
555
|
async getPost(postId, subreddit) {
|
|
248
556
|
const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
|
|
249
|
-
|
|
557
|
+
const context = `Failed to get post with ID ${postId}`;
|
|
558
|
+
return (await Try.async(async () => {
|
|
250
559
|
const response = (await this.makeRequest(endpoint)).orThrow();
|
|
251
|
-
if (!response.ok)
|
|
252
|
-
if (subreddit !== void 0) return
|
|
560
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
561
|
+
if (subreddit !== void 0) return parsePostData((await response.json())[0].data.children[0].data);
|
|
253
562
|
const json = await response.json();
|
|
254
|
-
if (json.data.children.length === 0)
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: ${toError(error).message}`));
|
|
258
|
-
}
|
|
563
|
+
if (json.data.children.length === 0) throw new NotFoundError(`Post with ID ${postId} not found`);
|
|
564
|
+
return parsePostData(json.data.children[0].data);
|
|
565
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
259
566
|
}
|
|
260
567
|
async getTrendingSubreddits(limit = 5) {
|
|
261
568
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
262
|
-
|
|
569
|
+
const context = `Failed to get trending subreddits`;
|
|
570
|
+
return (await Try.async(async () => {
|
|
263
571
|
const response = (await this.makeRequest(`/subreddits/popular.json?${params}`)).orThrow();
|
|
264
|
-
if (!response.ok)
|
|
265
|
-
return
|
|
266
|
-
}
|
|
267
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: ${toError(error).message}`));
|
|
268
|
-
}
|
|
572
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
573
|
+
return (await response.json()).data.children.map((child) => child.data.display_name);
|
|
574
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
269
575
|
}
|
|
270
|
-
async createPost(subreddit, title, content, isSelf = true) {
|
|
271
|
-
|
|
576
|
+
async createPost(subreddit, title, content, isSelf = true, flairId, flairText) {
|
|
577
|
+
return (await Try.async(async () => {
|
|
272
578
|
var _json$json$data, _json$json$data2;
|
|
273
579
|
this.validateWriteAccess();
|
|
274
580
|
await this.enforceWriteRateLimit();
|
|
@@ -281,35 +587,30 @@ var RedditClient = class {
|
|
|
281
587
|
params.append("title", title);
|
|
282
588
|
params.append(isSelf ? "text" : "url", finalContent);
|
|
283
589
|
params.append("api_type", "json");
|
|
590
|
+
if (flairId !== void 0) params.append("flair_id", flairId);
|
|
591
|
+
if (flairText !== void 0) params.append("flair_text", flairText);
|
|
284
592
|
const response = (await this.makeRequest("/api/submit", {
|
|
285
593
|
method: "POST",
|
|
286
594
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
287
595
|
body: params.toString()
|
|
288
596
|
})).orThrow();
|
|
289
|
-
if (!response.ok)
|
|
597
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to create post: HTTP ${response.status}`);
|
|
290
598
|
const json = await response.json();
|
|
291
|
-
if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
292
|
-
const errors = json.json.errors.map((e) => e[1]).join(", ");
|
|
293
|
-
return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
|
|
294
|
-
}
|
|
599
|
+
if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
295
600
|
const postId = ((_json$json$data = json.json.data) === null || _json$json$data === void 0 ? void 0 : _json$json$data.id) ?? ((_json$json$data2 = json.json.data) === null || _json$json$data2 === void 0 || (_json$json$data2 = _json$json$data2.name) === null || _json$json$data2 === void 0 ? void 0 : _json$json$data2.replace("t3_", ""));
|
|
296
|
-
if (postId === void 0)
|
|
297
|
-
return this.getPost(postId, subreddit);
|
|
298
|
-
}
|
|
299
|
-
return Left(toError(error));
|
|
300
|
-
}
|
|
601
|
+
if (postId === void 0) throw new ApiError("No post ID returned from Reddit");
|
|
602
|
+
return (await this.getPost(postId, subreddit)).orThrow();
|
|
603
|
+
})).toEither((error) => classifyRedditError(error));
|
|
301
604
|
}
|
|
302
605
|
async checkPostExists(postId) {
|
|
303
|
-
|
|
606
|
+
return (await Try.async(async () => {
|
|
304
607
|
const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
|
|
305
608
|
if (!response.ok) return false;
|
|
306
609
|
return (await response.json()).data.children.length > 0;
|
|
307
|
-
}
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
610
|
+
})).orElse(false);
|
|
310
611
|
}
|
|
311
612
|
async replyToPost(postId, content) {
|
|
312
|
-
|
|
613
|
+
return (await Try.async(async () => {
|
|
313
614
|
var _json$json$data3;
|
|
314
615
|
this.validateWriteAccess();
|
|
315
616
|
await this.enforceWriteRateLimit();
|
|
@@ -317,7 +618,7 @@ var RedditClient = class {
|
|
|
317
618
|
const finalContent = this.appendBotDisclosure(content);
|
|
318
619
|
const fullThingId = postId.startsWith("t3_") || postId.startsWith("t1_") ? postId : `t3_${postId}`;
|
|
319
620
|
if (!postId.startsWith("t1_")) {
|
|
320
|
-
if (!await this.checkPostExists(postId.replace(/^t3_/, "")))
|
|
621
|
+
if (!await this.checkPostExists(postId.replace(/^t3_/, ""))) throw new NotFoundError(`Post with ID ${postId} does not exist or is not accessible`);
|
|
321
622
|
}
|
|
322
623
|
const params = new URLSearchParams();
|
|
323
624
|
params.append("thing_id", fullThingId);
|
|
@@ -328,12 +629,12 @@ var RedditClient = class {
|
|
|
328
629
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
329
630
|
body: params.toString()
|
|
330
631
|
})).orThrow();
|
|
331
|
-
if (!response.ok)
|
|
632
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to reply: HTTP ${response.status}`);
|
|
332
633
|
const json = await response.json();
|
|
333
634
|
if (((_json$json$data3 = json.json.data) === null || _json$json$data3 === void 0 ? void 0 : _json$json$data3.things) !== void 0 && json.json.data.things.length > 0) {
|
|
334
635
|
const commentData = json.json.data.things[0].data;
|
|
335
636
|
const author = this.username ?? "[unknown]";
|
|
336
|
-
return
|
|
637
|
+
return {
|
|
337
638
|
id: commentData.id,
|
|
338
639
|
author,
|
|
339
640
|
body: content,
|
|
@@ -345,17 +646,13 @@ var RedditClient = class {
|
|
|
345
646
|
edited: false,
|
|
346
647
|
isSubmitter: false,
|
|
347
648
|
permalink: commentData.permalink
|
|
348
|
-
}
|
|
349
|
-
} else if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
} else return Left(/* @__PURE__ */ new Error("Failed to parse reply response"));
|
|
353
|
-
} catch (error) {
|
|
354
|
-
return Left(toError(error));
|
|
355
|
-
}
|
|
649
|
+
};
|
|
650
|
+
} else if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
651
|
+
else throw new ApiError("Failed to parse reply response");
|
|
652
|
+
})).toEither((error) => classifyRedditError(error));
|
|
356
653
|
}
|
|
357
654
|
async deletePost(thingId) {
|
|
358
|
-
|
|
655
|
+
return (await Try.async(async () => {
|
|
359
656
|
this.validateWriteAccess();
|
|
360
657
|
const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
|
|
361
658
|
const params = new URLSearchParams();
|
|
@@ -369,21 +666,21 @@ var RedditClient = class {
|
|
|
369
666
|
const errorText = await response.text();
|
|
370
667
|
console.error(`[Reddit API] Delete failed: ${response.status} ${response.statusText}`);
|
|
371
668
|
console.error(`[Reddit API] Error response: ${errorText}`);
|
|
372
|
-
|
|
669
|
+
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`);
|
|
373
670
|
}
|
|
374
671
|
console.error(`[Reddit API] Successfully deleted ${fullThingId}`);
|
|
375
|
-
return
|
|
376
|
-
}
|
|
377
|
-
console.error(`[Reddit API] Delete exception:`, error);
|
|
378
|
-
return
|
|
379
|
-
}
|
|
672
|
+
return true;
|
|
673
|
+
})).toEither((error) => {
|
|
674
|
+
if (!isRedditError(error)) console.error(`[Reddit API] Delete exception:`, error);
|
|
675
|
+
return classifyRedditError(error);
|
|
676
|
+
});
|
|
380
677
|
}
|
|
381
678
|
async deleteComment(thingId) {
|
|
382
679
|
const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
|
|
383
680
|
return this.deletePost(fullThingId);
|
|
384
681
|
}
|
|
385
682
|
async editPost(thingId, newText) {
|
|
386
|
-
|
|
683
|
+
return (await Try.async(async () => {
|
|
387
684
|
this.validateWriteAccess();
|
|
388
685
|
await this.enforceWriteRateLimit();
|
|
389
686
|
this.checkDuplicateContent(newText);
|
|
@@ -398,23 +695,18 @@ var RedditClient = class {
|
|
|
398
695
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
399
696
|
body: params.toString()
|
|
400
697
|
})).orThrow();
|
|
401
|
-
if (!response.ok)
|
|
698
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to edit: HTTP ${response.status}`);
|
|
402
699
|
const json = await response.json();
|
|
403
|
-
if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
return Right(true);
|
|
408
|
-
} catch (error) {
|
|
409
|
-
return Left(toError(error));
|
|
410
|
-
}
|
|
700
|
+
if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
701
|
+
return true;
|
|
702
|
+
})).toEither((error) => classifyRedditError(error));
|
|
411
703
|
}
|
|
412
704
|
async editComment(thingId, newText) {
|
|
413
705
|
const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
|
|
414
706
|
return this.editPost(fullThingId, newText);
|
|
415
707
|
}
|
|
416
708
|
async searchReddit(query, options = {}) {
|
|
417
|
-
const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link" } = options;
|
|
709
|
+
const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
|
|
418
710
|
const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
|
|
419
711
|
const params = new URLSearchParams({
|
|
420
712
|
q: query,
|
|
@@ -422,15 +714,20 @@ var RedditClient = class {
|
|
|
422
714
|
t: timeFilter,
|
|
423
715
|
limit: limit.toString(),
|
|
424
716
|
type,
|
|
425
|
-
...subreddit !== void 0 ? { restrict_sr: "true" } : {}
|
|
717
|
+
...subreddit !== void 0 ? { restrict_sr: "true" } : {},
|
|
718
|
+
...after !== void 0 ? { after } : {},
|
|
719
|
+
...before !== void 0 ? { before } : {}
|
|
426
720
|
});
|
|
427
|
-
|
|
721
|
+
const context = `Failed to search Reddit for: ${query}`;
|
|
722
|
+
return (await Try.async(async () => {
|
|
428
723
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
429
|
-
if (!response.ok)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
724
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to search Reddit: HTTP ${response.status}`);
|
|
725
|
+
const json = await response.json();
|
|
726
|
+
return {
|
|
727
|
+
items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
|
|
728
|
+
...listingCursor(json.data)
|
|
729
|
+
};
|
|
730
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
434
731
|
}
|
|
435
732
|
async getPostComments(postId, subreddit, options = {}) {
|
|
436
733
|
const { sort = "best", limit = 100 } = options;
|
|
@@ -438,9 +735,10 @@ var RedditClient = class {
|
|
|
438
735
|
sort,
|
|
439
736
|
limit: limit.toString()
|
|
440
737
|
});
|
|
441
|
-
|
|
738
|
+
const context = `Failed to get comments for post ${postId}`;
|
|
739
|
+
return (await Try.async(async () => {
|
|
442
740
|
const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
|
|
443
|
-
if (!response.ok)
|
|
741
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to get comments: HTTP ${response.status}`);
|
|
444
742
|
const json = await response.json();
|
|
445
743
|
const postData = json[0].data.children[0].data;
|
|
446
744
|
const post = parsePostData(postData);
|
|
@@ -464,58 +762,95 @@ var RedditClient = class {
|
|
|
464
762
|
const { replies } = item.data;
|
|
465
763
|
return [comment, ...replies !== void 0 && typeof replies !== "string" ? parseComments(replies.data.children, depth + 1) : []];
|
|
466
764
|
});
|
|
467
|
-
return
|
|
765
|
+
return {
|
|
468
766
|
post,
|
|
469
767
|
comments: parseComments(json[1].data.children)
|
|
768
|
+
};
|
|
769
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
770
|
+
}
|
|
771
|
+
async getMoreComments(linkId, commentIds) {
|
|
772
|
+
const fullLinkId = linkId.startsWith("t3_") ? linkId : `t3_${linkId}`;
|
|
773
|
+
const context = `Failed to expand comments for ${fullLinkId}`;
|
|
774
|
+
const params = new URLSearchParams({
|
|
775
|
+
api_type: "json",
|
|
776
|
+
link_id: fullLinkId,
|
|
777
|
+
children: commentIds.join(",")
|
|
778
|
+
});
|
|
779
|
+
return (await Try.async(async () => {
|
|
780
|
+
var _json$json$data4;
|
|
781
|
+
const response = (await this.makeRequest(`/api/morechildren?${params}`)).orThrow();
|
|
782
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
783
|
+
return (((_json$json$data4 = (await response.json()).json.data) === null || _json$json$data4 === void 0 ? void 0 : _json$json$data4.things) ?? []).filter((thing) => thing.kind === "t1" && thing.data.body !== void 0).map((thing) => {
|
|
784
|
+
const comment = thing.data;
|
|
785
|
+
return {
|
|
786
|
+
id: comment.id,
|
|
787
|
+
author: comment.author,
|
|
788
|
+
body: comment.body ?? "",
|
|
789
|
+
score: comment.score,
|
|
790
|
+
controversiality: comment.controversiality,
|
|
791
|
+
subreddit: comment.subreddit,
|
|
792
|
+
submissionTitle: comment.link_title ?? "",
|
|
793
|
+
createdUtc: comment.created_utc,
|
|
794
|
+
edited: Boolean(comment.edited),
|
|
795
|
+
isSubmitter: comment.is_submitter,
|
|
796
|
+
permalink: comment.permalink,
|
|
797
|
+
parentId: comment.parent_id
|
|
798
|
+
};
|
|
470
799
|
});
|
|
471
|
-
}
|
|
472
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get comments for post ${postId}: ${toError(error).message}`));
|
|
473
|
-
}
|
|
800
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
474
801
|
}
|
|
475
802
|
async getUserPosts(username, options = {}) {
|
|
476
|
-
const { sort = "new", timeFilter = "all", limit = 25 } = options;
|
|
803
|
+
const { sort = "new", timeFilter = "all", limit = 25, after } = options;
|
|
477
804
|
const params = new URLSearchParams({
|
|
478
805
|
sort,
|
|
479
806
|
t: timeFilter,
|
|
480
807
|
limit: limit.toString()
|
|
481
808
|
});
|
|
482
|
-
|
|
809
|
+
if (after !== void 0) params.set("after", after);
|
|
810
|
+
const context = `Failed to get posts for user ${username}`;
|
|
811
|
+
return (await Try.async(async () => {
|
|
483
812
|
const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
|
|
484
|
-
if (!response.ok)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
813
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
814
|
+
const json = await response.json();
|
|
815
|
+
return {
|
|
816
|
+
items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
|
|
817
|
+
...listingCursor(json.data)
|
|
818
|
+
};
|
|
819
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
489
820
|
}
|
|
490
821
|
async getUserComments(username, options = {}) {
|
|
491
|
-
const { sort = "new", timeFilter = "all", limit = 25 } = options;
|
|
822
|
+
const { sort = "new", timeFilter = "all", limit = 25, after } = options;
|
|
492
823
|
const params = new URLSearchParams({
|
|
493
824
|
sort,
|
|
494
825
|
t: timeFilter,
|
|
495
826
|
limit: limit.toString()
|
|
496
827
|
});
|
|
497
|
-
|
|
828
|
+
if (after !== void 0) params.set("after", after);
|
|
829
|
+
const context = `Failed to get comments for user ${username}`;
|
|
830
|
+
return (await Try.async(async () => {
|
|
498
831
|
const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
|
|
499
|
-
if (!response.ok)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
832
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
833
|
+
const json = await response.json();
|
|
834
|
+
return {
|
|
835
|
+
items: json.data.children.filter((child) => child.kind === "t1").map((child) => {
|
|
836
|
+
const comment = child.data;
|
|
837
|
+
return {
|
|
838
|
+
id: comment.id,
|
|
839
|
+
author: comment.author,
|
|
840
|
+
body: comment.body ?? "",
|
|
841
|
+
score: comment.score,
|
|
842
|
+
controversiality: comment.controversiality,
|
|
843
|
+
subreddit: comment.subreddit,
|
|
844
|
+
submissionTitle: comment.link_title ?? "",
|
|
845
|
+
createdUtc: comment.created_utc,
|
|
846
|
+
edited: Boolean(comment.edited),
|
|
847
|
+
isSubmitter: comment.is_submitter,
|
|
848
|
+
permalink: comment.permalink
|
|
849
|
+
};
|
|
850
|
+
}),
|
|
851
|
+
...listingCursor(json.data)
|
|
852
|
+
};
|
|
853
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
519
854
|
}
|
|
520
855
|
};
|
|
521
856
|
const clientHolder = { instance: Option.none() };
|
|
@@ -657,7 +992,7 @@ function formatSubredditInfo(subreddit) {
|
|
|
657
992
|
//#endregion
|
|
658
993
|
//#region src/index.ts
|
|
659
994
|
dotenv.config({ quiet: true });
|
|
660
|
-
const VERSION = "1.
|
|
995
|
+
const VERSION = "1.5.0";
|
|
661
996
|
function validateUserAgent(userAgent, username) {
|
|
662
997
|
if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
|
|
663
998
|
console.error("[Warning] User-Agent does not follow Reddit's recommended format");
|
|
@@ -708,6 +1043,15 @@ function buildSafeModeConfig(safeMode) {
|
|
|
708
1043
|
function unwrapClient() {
|
|
709
1044
|
return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
|
|
710
1045
|
}
|
|
1046
|
+
function nextPageHint(after) {
|
|
1047
|
+
return Option(after).fold(() => "", (cursor) => `\n\n---\nMore results available — call again with after="${cursor}" for the next page.`);
|
|
1048
|
+
}
|
|
1049
|
+
function formatUserContent(heading, content) {
|
|
1050
|
+
return `# ${heading}\n\n${content.posts.length === 0 ? "" : `## Posts (${content.posts.length})\n${content.posts.map((post, index) => `${index + 1}. ${post.title} — r/${post.subreddit}, score ${post.score.toLocaleString()} — https://reddit.com${post.permalink}`).join("\n")}\n\n`}${content.comments.length === 0 ? "" : `## Comments (${content.comments.length})\n${content.comments.map((comment, index) => {
|
|
1051
|
+
const body = comment.body.length > 200 ? `${comment.body.substring(0, 200)}...` : comment.body;
|
|
1052
|
+
return `${index + 1}. in r/${comment.subreddit}: ${body} — https://reddit.com${comment.permalink}`;
|
|
1053
|
+
}).join("\n")}\n\n`}${content.posts.length === 0 && content.comments.length === 0 ? "No items found.\n\n" : ""}`.trimEnd() + nextPageHint(content.after);
|
|
1054
|
+
}
|
|
711
1055
|
async function setupRedditClient() {
|
|
712
1056
|
const clientId = process.env.REDDIT_CLIENT_ID;
|
|
713
1057
|
const clientSecret = process.env.REDDIT_CLIENT_SECRET;
|
|
@@ -746,6 +1090,13 @@ async function setupRedditClient() {
|
|
|
746
1090
|
enabled: botDisclosureMode === "auto",
|
|
747
1091
|
footer: botDisclosureMode === "auto" ? process.env.REDDIT_BOT_FOOTER ?? "\n\n---\n^(🤖 I am a bot | Built with) [^reddit-mcp-server](https://github.com/jordanburke/reddit-mcp-server)" : ""
|
|
748
1092
|
};
|
|
1093
|
+
const cacheEnabled = (process.env.REDDIT_CACHE ?? "on") !== "off";
|
|
1094
|
+
const cacheMaxMb = Number(process.env.REDDIT_CACHE_MAX_MB ?? "50");
|
|
1095
|
+
const cacheConfig = {
|
|
1096
|
+
enabled: cacheEnabled,
|
|
1097
|
+
maxBytes: (Number.isFinite(cacheMaxMb) && cacheMaxMb > 0 ? cacheMaxMb : 50) * 1024 * 1024
|
|
1098
|
+
};
|
|
1099
|
+
const maxRetriesRaw = Number(process.env.REDDIT_MAX_RETRIES ?? "3");
|
|
749
1100
|
const client = initializeRedditClient({
|
|
750
1101
|
clientId: clientId ?? "",
|
|
751
1102
|
clientSecret: clientSecret ?? "",
|
|
@@ -754,7 +1105,13 @@ async function setupRedditClient() {
|
|
|
754
1105
|
password,
|
|
755
1106
|
authMode,
|
|
756
1107
|
safeMode: safeModeConfig,
|
|
757
|
-
botDisclosure: botDisclosureConfig
|
|
1108
|
+
botDisclosure: botDisclosureConfig,
|
|
1109
|
+
cache: cacheConfig,
|
|
1110
|
+
retry: {
|
|
1111
|
+
maxRetries: Number.isFinite(maxRetriesRaw) && maxRetriesRaw >= 0 ? Math.floor(maxRetriesRaw) : 3,
|
|
1112
|
+
baseDelayMs: 1e3,
|
|
1113
|
+
maxDelayMs: 6e4
|
|
1114
|
+
}
|
|
758
1115
|
});
|
|
759
1116
|
console.error("[Setup] Reddit client initialized");
|
|
760
1117
|
console.error(`[Setup] Authentication mode: ${authMode}`);
|
|
@@ -880,6 +1237,61 @@ server.addTool({
|
|
|
880
1237
|
});
|
|
881
1238
|
}
|
|
882
1239
|
});
|
|
1240
|
+
server.addTool({
|
|
1241
|
+
name: "get_me",
|
|
1242
|
+
description: "Get the authenticated user's own account info (karma, account status). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); fails in anonymous mode.",
|
|
1243
|
+
parameters: z.object({}),
|
|
1244
|
+
execute: async () => {
|
|
1245
|
+
return (await unwrapClient().getMe()).fold((err) => {
|
|
1246
|
+
throw new Error(`Failed to get authenticated user: ${err.message}`);
|
|
1247
|
+
}, (user) => {
|
|
1248
|
+
const formattedUser = formatUserInfo(user);
|
|
1249
|
+
return `# Your Account: u/${formattedUser.username}
|
|
1250
|
+
|
|
1251
|
+
## Profile Overview
|
|
1252
|
+
- Username: u/${formattedUser.username}
|
|
1253
|
+
- Karma:
|
|
1254
|
+
- Comment Karma: ${formattedUser.karma.commentKarma.toLocaleString()}
|
|
1255
|
+
- Post Karma: ${formattedUser.karma.postKarma.toLocaleString()}
|
|
1256
|
+
- Total Karma: ${formattedUser.karma.totalKarma.toLocaleString()}
|
|
1257
|
+
- Account Status: ${formattedUser.accountStatus.join(", ")}
|
|
1258
|
+
- Account Created: ${formattedUser.accountCreated}
|
|
1259
|
+
- Profile URL: ${formattedUser.profileUrl}`;
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
server.addTool({
|
|
1264
|
+
name: "get_my_overview",
|
|
1265
|
+
description: "Get your own recent activity (posts and comments combined). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD).",
|
|
1266
|
+
parameters: z.object({
|
|
1267
|
+
limit: z.number().min(1).max(100).default(25).describe("Number of items to retrieve"),
|
|
1268
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page")
|
|
1269
|
+
}),
|
|
1270
|
+
execute: async (args) => {
|
|
1271
|
+
return (await unwrapClient().getMyOverview({
|
|
1272
|
+
limit: args.limit,
|
|
1273
|
+
after: args.after
|
|
1274
|
+
})).fold((err) => {
|
|
1275
|
+
throw new Error(`Failed to get your overview: ${err.message}`);
|
|
1276
|
+
}, (content) => formatUserContent("Your Overview", content));
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
server.addTool({
|
|
1280
|
+
name: "get_my_saved",
|
|
1281
|
+
description: "Get your saved posts and comments. Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); saved content is private.",
|
|
1282
|
+
parameters: z.object({
|
|
1283
|
+
limit: z.number().min(1).max(100).default(25).describe("Number of items to retrieve"),
|
|
1284
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page")
|
|
1285
|
+
}),
|
|
1286
|
+
execute: async (args) => {
|
|
1287
|
+
return (await unwrapClient().getMySaved({
|
|
1288
|
+
limit: args.limit,
|
|
1289
|
+
after: args.after
|
|
1290
|
+
})).fold((err) => {
|
|
1291
|
+
throw new Error(`Failed to get saved content: ${err.message}`);
|
|
1292
|
+
}, (content) => formatUserContent("Your Saved Content", content));
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
883
1295
|
server.addTool({
|
|
884
1296
|
name: "get_user_posts",
|
|
885
1297
|
description: "Get recent posts by a Reddit user with sorting and filtering options",
|
|
@@ -898,16 +1310,19 @@ server.addTool({
|
|
|
898
1310
|
"year",
|
|
899
1311
|
"all"
|
|
900
1312
|
]).default("all").describe("Time filter for top posts"),
|
|
901
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
|
|
1313
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
|
|
1314
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
902
1315
|
}),
|
|
903
1316
|
execute: async (args) => {
|
|
904
1317
|
return (await unwrapClient().getUserPosts(args.username, {
|
|
905
1318
|
sort: args.sort,
|
|
906
1319
|
timeFilter: args.time_filter,
|
|
907
|
-
limit: args.limit
|
|
1320
|
+
limit: args.limit,
|
|
1321
|
+
after: args.after
|
|
908
1322
|
})).fold((err) => {
|
|
909
1323
|
throw new Error(`Failed to get user posts: ${err.message}`);
|
|
910
|
-
}, (
|
|
1324
|
+
}, (page) => {
|
|
1325
|
+
const posts = page.items;
|
|
911
1326
|
if (posts.length === 0) return `No posts found for u/${args.username} with the specified filters.`;
|
|
912
1327
|
const postSummaries = posts.map((post, index) => {
|
|
913
1328
|
const flags = [...post.over18 ? ["**NSFW**"] : [], ...post.spoiler === true ? ["**Spoiler**"] : []];
|
|
@@ -920,7 +1335,7 @@ server.addTool({
|
|
|
920
1335
|
}).join("\n\n");
|
|
921
1336
|
return `# Posts by u/${args.username} (${args.sort} - ${args.time_filter})
|
|
922
1337
|
|
|
923
|
-
${postSummaries}`;
|
|
1338
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
924
1339
|
});
|
|
925
1340
|
}
|
|
926
1341
|
});
|
|
@@ -942,16 +1357,19 @@ server.addTool({
|
|
|
942
1357
|
"year",
|
|
943
1358
|
"all"
|
|
944
1359
|
]).default("all").describe("Time filter for top comments"),
|
|
945
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve")
|
|
1360
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve"),
|
|
1361
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
946
1362
|
}),
|
|
947
1363
|
execute: async (args) => {
|
|
948
1364
|
return (await unwrapClient().getUserComments(args.username, {
|
|
949
1365
|
sort: args.sort,
|
|
950
1366
|
timeFilter: args.time_filter,
|
|
951
|
-
limit: args.limit
|
|
1367
|
+
limit: args.limit,
|
|
1368
|
+
after: args.after
|
|
952
1369
|
})).fold((err) => {
|
|
953
1370
|
throw new Error(`Failed to get user comments: ${err.message}`);
|
|
954
|
-
}, (
|
|
1371
|
+
}, (page) => {
|
|
1372
|
+
const comments = page.items;
|
|
955
1373
|
if (comments.length === 0) return `No comments found for u/${args.username} with the specified filters.`;
|
|
956
1374
|
const commentSummaries = comments.map((comment, index) => {
|
|
957
1375
|
const truncatedBody = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
|
|
@@ -967,7 +1385,7 @@ In r/${comment.subreddit} on "${comment.submissionTitle}"
|
|
|
967
1385
|
}).join("\n\n");
|
|
968
1386
|
return `# Comments by u/${args.username} (${args.sort} - ${args.time_filter})
|
|
969
1387
|
|
|
970
|
-
${commentSummaries}`;
|
|
1388
|
+
${commentSummaries}${nextPageHint(page.after)}`;
|
|
971
1389
|
});
|
|
972
1390
|
}
|
|
973
1391
|
});
|
|
@@ -1028,12 +1446,14 @@ server.addTool({
|
|
|
1028
1446
|
"year",
|
|
1029
1447
|
"all"
|
|
1030
1448
|
]).default("week").describe("Time period for top posts"),
|
|
1031
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
|
|
1449
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
|
|
1450
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1032
1451
|
}),
|
|
1033
1452
|
execute: async (args) => {
|
|
1034
|
-
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit)).fold((err) => {
|
|
1453
|
+
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
|
|
1035
1454
|
throw new Error(`Failed to get top posts: ${err.message}`);
|
|
1036
|
-
}, (
|
|
1455
|
+
}, (page) => {
|
|
1456
|
+
const posts = page.items;
|
|
1037
1457
|
if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
|
|
1038
1458
|
const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
|
|
1039
1459
|
- Author: u/${post.author}
|
|
@@ -1043,7 +1463,51 @@ server.addTool({
|
|
|
1043
1463
|
- Link: ${post.links.shortLink}`).join("\n\n");
|
|
1044
1464
|
return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
|
|
1045
1465
|
|
|
1046
|
-
${postSummaries}`;
|
|
1466
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
server.addTool({
|
|
1471
|
+
name: "browse_subreddit",
|
|
1472
|
+
description: "Browse posts from a subreddit or the Reddit home feed with a sort order (hot, new, top, rising, controversial). The time_filter only applies to top and controversial sorts.",
|
|
1473
|
+
parameters: z.object({
|
|
1474
|
+
subreddit: z.string().optional().describe("The subreddit name (without r/ prefix). Leave empty for home feed"),
|
|
1475
|
+
sort: z.enum([
|
|
1476
|
+
"hot",
|
|
1477
|
+
"new",
|
|
1478
|
+
"top",
|
|
1479
|
+
"rising",
|
|
1480
|
+
"controversial"
|
|
1481
|
+
]).default("hot").describe("Sort order for posts"),
|
|
1482
|
+
time_filter: z.enum([
|
|
1483
|
+
"hour",
|
|
1484
|
+
"day",
|
|
1485
|
+
"week",
|
|
1486
|
+
"month",
|
|
1487
|
+
"year",
|
|
1488
|
+
"all"
|
|
1489
|
+
]).default("week").describe("Time period (only applies to top and controversial sorts)"),
|
|
1490
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
|
|
1491
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1492
|
+
}),
|
|
1493
|
+
execute: async (args) => {
|
|
1494
|
+
return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
|
|
1495
|
+
throw new Error(`Failed to browse subreddit: ${err.message}`);
|
|
1496
|
+
}, (page) => {
|
|
1497
|
+
const posts = page.items;
|
|
1498
|
+
const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
|
|
1499
|
+
if (posts.length === 0) return `No posts found in ${location}.`;
|
|
1500
|
+
const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
|
|
1501
|
+
- Author: u/${post.author}
|
|
1502
|
+
- Score: ${post.stats.score.toLocaleString()} (${(post.stats.upvoteRatio * 100).toFixed(1)}% upvoted)
|
|
1503
|
+
- Comments: ${post.stats.comments.toLocaleString()}
|
|
1504
|
+
- Posted: ${post.metadata.posted}
|
|
1505
|
+
- Link: ${post.links.shortLink}`).join("\n\n");
|
|
1506
|
+
const timeSuffix = args.sort === "top" || args.sort === "controversial" ? `, ${args.time_filter}` : "";
|
|
1507
|
+
const heading = location === "home feed" ? "Home Feed" : location;
|
|
1508
|
+
return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
|
|
1509
|
+
|
|
1510
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1047
1511
|
});
|
|
1048
1512
|
}
|
|
1049
1513
|
});
|
|
@@ -1086,6 +1550,47 @@ ${formattedSubreddit.description.full}
|
|
|
1086
1550
|
});
|
|
1087
1551
|
}
|
|
1088
1552
|
});
|
|
1553
|
+
server.addTool({
|
|
1554
|
+
name: "get_subreddit_rules",
|
|
1555
|
+
description: "Get a subreddit's posting rules. Useful to check requirements before creating a post to avoid auto-removal.",
|
|
1556
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
|
|
1557
|
+
execute: async (args) => {
|
|
1558
|
+
return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
|
|
1559
|
+
throw new Error(`Failed to get subreddit rules: ${err.message}`);
|
|
1560
|
+
}, (rules) => {
|
|
1561
|
+
if (rules.length === 0) return `r/${args.subreddit_name} has no listed subreddit-specific rules.`;
|
|
1562
|
+
const ruleList = rules.map((rule, index) => {
|
|
1563
|
+
const applies = rule.kind === "all" ? "posts & comments" : `${rule.kind}s`;
|
|
1564
|
+
const detail = rule.description.trim() === "" ? "" : `\n${rule.description.trim()}`;
|
|
1565
|
+
return `### ${index + 1}. ${rule.shortName} _(applies to ${applies})_${detail}`;
|
|
1566
|
+
}).join("\n\n");
|
|
1567
|
+
return `# Posting Rules for r/${args.subreddit_name}
|
|
1568
|
+
|
|
1569
|
+
${ruleList}`;
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
server.addTool({
|
|
1574
|
+
name: "get_post_flairs",
|
|
1575
|
+
description: "List the available link flairs for a subreddit (use a flair_id with create_post). Requires user credentials; many subreddits only expose flairs to members, so this may fail in anonymous mode.",
|
|
1576
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
|
|
1577
|
+
execute: async (args) => {
|
|
1578
|
+
return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
|
|
1579
|
+
throw new Error(`Failed to get post flairs: ${err.message}`);
|
|
1580
|
+
}, (flairs) => {
|
|
1581
|
+
if (flairs.length === 0) return `r/${args.subreddit_name} has no selectable link flairs (or none are visible to this account).`;
|
|
1582
|
+
const flairList = flairs.map((flair) => {
|
|
1583
|
+
const editable = flair.textEditable === true ? " _(text editable)_" : "";
|
|
1584
|
+
return `- ${flair.text}${editable} — \`flair_id: ${flair.id}\``;
|
|
1585
|
+
}).join("\n");
|
|
1586
|
+
return `# Available Link Flairs for r/${args.subreddit_name}
|
|
1587
|
+
|
|
1588
|
+
${flairList}
|
|
1589
|
+
|
|
1590
|
+
Pass the desired \`flair_id\` to \`create_post\`.`;
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1089
1594
|
server.addTool({
|
|
1090
1595
|
name: "get_trending_subreddits",
|
|
1091
1596
|
description: "Get a list of currently trending subreddits",
|
|
@@ -1110,7 +1615,7 @@ server.addTool({
|
|
|
1110
1615
|
"top",
|
|
1111
1616
|
"new",
|
|
1112
1617
|
"comments"
|
|
1113
|
-
]).default("relevance").describe("Sort order"),
|
|
1618
|
+
]).default("relevance").describe("Sort order. Prefer 'relevance' (default) for finding posts about a topic. Use 'top'/'hot' only for what's currently popular and 'new' for the latest — these rank by karma/recency and, especially combined with a narrow time_filter, can surface loosely-matching posts over the best topical results."),
|
|
1114
1619
|
time_filter: z.enum([
|
|
1115
1620
|
"hour",
|
|
1116
1621
|
"day",
|
|
@@ -1124,7 +1629,8 @@ server.addTool({
|
|
|
1124
1629
|
"link",
|
|
1125
1630
|
"sr",
|
|
1126
1631
|
"user"
|
|
1127
|
-
]).default("link").describe("Type of content to search")
|
|
1632
|
+
]).default("link").describe("Type of content to search"),
|
|
1633
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1128
1634
|
}),
|
|
1129
1635
|
execute: async (args) => {
|
|
1130
1636
|
const client = unwrapClient();
|
|
@@ -1134,10 +1640,12 @@ server.addTool({
|
|
|
1134
1640
|
sort: args.sort,
|
|
1135
1641
|
timeFilter: args.time_filter,
|
|
1136
1642
|
limit: args.limit,
|
|
1137
|
-
type: args.type
|
|
1643
|
+
type: args.type,
|
|
1644
|
+
after: args.after
|
|
1138
1645
|
})).fold((err) => {
|
|
1139
1646
|
throw new Error(`Failed to search: ${err.message}`);
|
|
1140
|
-
}, (
|
|
1647
|
+
}, (page) => {
|
|
1648
|
+
const posts = page.items;
|
|
1141
1649
|
if (posts.length === 0) {
|
|
1142
1650
|
const searchLocation = Option(args.subreddit).fold(() => "", (sr) => ` in r/${sr}`);
|
|
1143
1651
|
return `No results found for "${args.query}"${searchLocation}.`;
|
|
@@ -1157,7 +1665,7 @@ server.addTool({
|
|
|
1157
1665
|
|
|
1158
1666
|
Sorted by: ${args.sort} | Time: ${args.time_filter} | Type: ${args.type}
|
|
1159
1667
|
|
|
1160
|
-
${searchResults}`;
|
|
1668
|
+
${searchResults}${nextPageHint(page.after)}`;
|
|
1161
1669
|
});
|
|
1162
1670
|
}
|
|
1163
1671
|
});
|
|
@@ -1168,12 +1676,14 @@ server.addTool({
|
|
|
1168
1676
|
subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
|
|
1169
1677
|
title: z.string().describe("The post title"),
|
|
1170
1678
|
content: z.string().describe("The post content (text for self posts, URL for link posts)"),
|
|
1171
|
-
is_self: z.boolean().default(true).describe("Whether this is a self post (text) or link post")
|
|
1679
|
+
is_self: z.boolean().default(true).describe("Whether this is a self post (text) or link post"),
|
|
1680
|
+
flair_id: z.string().optional().describe("Link flair template id (from get_post_flairs); many subreddits require a flair"),
|
|
1681
|
+
flair_text: z.string().optional().describe("Custom flair text, only for flairs whose template is text-editable")
|
|
1172
1682
|
}),
|
|
1173
1683
|
execute: async (args) => {
|
|
1174
1684
|
const client = unwrapClient();
|
|
1175
1685
|
if (process.env.REDDIT_USERNAME === void 0 || process.env.REDDIT_PASSWORD === void 0) throw new Error("User authentication required. Please set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.");
|
|
1176
|
-
return (await client.createPost(args.subreddit, args.title, args.content, args.is_self)).fold((err) => {
|
|
1686
|
+
return (await client.createPost(args.subreddit, args.title, args.content, args.is_self, args.flair_id, args.flair_text)).fold((err) => {
|
|
1177
1687
|
throw new Error(`Failed to create post: ${err.message}`);
|
|
1178
1688
|
}, (post) => {
|
|
1179
1689
|
const formattedPost = formatPostInfo(post);
|
|
@@ -1333,6 +1843,33 @@ ${comment.body}
|
|
|
1333
1843
|
});
|
|
1334
1844
|
}
|
|
1335
1845
|
});
|
|
1846
|
+
server.addTool({
|
|
1847
|
+
name: "get_more_comments",
|
|
1848
|
+
description: "Expand truncated 'load more comments' stubs in a thread. Pass the post's link id and the comment ids from a 'more' node (returned by get_post_comments) to fetch those comments.",
|
|
1849
|
+
parameters: z.object({
|
|
1850
|
+
link_id: z.string().describe("The post (link) id, with or without the t3_ prefix"),
|
|
1851
|
+
comment_ids: z.array(z.string()).min(1).describe("Comment ids to expand (from a 'more' node)")
|
|
1852
|
+
}),
|
|
1853
|
+
execute: async (args) => {
|
|
1854
|
+
return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {
|
|
1855
|
+
throw new Error(`Failed to expand comments: ${err.message}`);
|
|
1856
|
+
}, (comments) => {
|
|
1857
|
+
if (comments.length === 0) return "No additional comments were returned for those ids.";
|
|
1858
|
+
const commentList = comments.map((comment, index) => {
|
|
1859
|
+
const truncated = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
|
|
1860
|
+
const flags = [...comment.edited ? ["*(edited)*"] : [], ...comment.isSubmitter ? ["**OP**"] : []];
|
|
1861
|
+
return `### ${index + 1}. u/${comment.author} ${flags.join(" ")}
|
|
1862
|
+
> ${truncated}
|
|
1863
|
+
|
|
1864
|
+
- Score: ${comment.score.toLocaleString()}
|
|
1865
|
+
- Link: https://reddit.com${comment.permalink}`;
|
|
1866
|
+
}).join("\n\n");
|
|
1867
|
+
return `# Expanded Comments (${comments.length})
|
|
1868
|
+
|
|
1869
|
+
${commentList}`;
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1336
1873
|
async function main() {
|
|
1337
1874
|
await setupRedditClient();
|
|
1338
1875
|
const useHttp = process.env.TRANSPORT_TYPE === "httpStream" || process.env.TRANSPORT_TYPE === "http";
|