reddit-mcp-server 1.4.8 → 1.5.1
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 +14 -13
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/index.js +762 -242
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -3,6 +3,93 @@ 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
|
|
6
93
|
//#region src/client/response-cache.ts
|
|
7
94
|
const SECOND = 1e3;
|
|
8
95
|
var ResponseCache = class {
|
|
@@ -65,8 +152,13 @@ var ResponseCache = class {
|
|
|
65
152
|
};
|
|
66
153
|
//#endregion
|
|
67
154
|
//#region src/client/reddit-client.ts
|
|
68
|
-
function
|
|
69
|
-
|
|
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
|
+
};
|
|
70
162
|
}
|
|
71
163
|
function parsePostData(post) {
|
|
72
164
|
return {
|
|
@@ -100,6 +192,7 @@ var RedditClient = class {
|
|
|
100
192
|
safeMode;
|
|
101
193
|
botDisclosure;
|
|
102
194
|
cache;
|
|
195
|
+
retry;
|
|
103
196
|
accessToken;
|
|
104
197
|
tokenExpiry = 0;
|
|
105
198
|
authenticated = false;
|
|
@@ -127,6 +220,11 @@ var RedditClient = class {
|
|
|
127
220
|
footer: ""
|
|
128
221
|
};
|
|
129
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
|
+
};
|
|
130
228
|
}
|
|
131
229
|
determineBaseUrl() {
|
|
132
230
|
switch (this.authMode) {
|
|
@@ -136,13 +234,13 @@ var RedditClient = class {
|
|
|
136
234
|
}
|
|
137
235
|
}
|
|
138
236
|
async makeRequest(path, options = {}) {
|
|
139
|
-
|
|
237
|
+
return (await Try.async(async () => {
|
|
140
238
|
const url = `${this.baseUrl}${path}`;
|
|
141
239
|
const method = (options.method ?? "GET").toUpperCase();
|
|
142
240
|
const cacheable = this.cache !== void 0 && method === "GET";
|
|
143
241
|
if (cacheable) {
|
|
144
242
|
const cached = this.cache.get(url);
|
|
145
|
-
if (cached !== void 0) return
|
|
243
|
+
if (cached !== void 0) return new Response(cached.body, { status: cached.status });
|
|
146
244
|
}
|
|
147
245
|
const requiresAuth = this.authMode === "authenticated" || this.authMode === "auto" && this.hasCredentials;
|
|
148
246
|
if (requiresAuth && (Date.now() >= this.tokenExpiry || !this.authenticated)) (await this.authenticate()).orThrow();
|
|
@@ -151,30 +249,18 @@ var RedditClient = class {
|
|
|
151
249
|
...options.headers
|
|
152
250
|
};
|
|
153
251
|
if (requiresAuth && this.accessToken !== void 0) headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
headers
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
(await this.authenticate()).orThrow();
|
|
160
|
-
const retryHeaders = {
|
|
161
|
-
...headers,
|
|
162
|
-
Authorization: `Bearer ${this.accessToken}`
|
|
163
|
-
};
|
|
164
|
-
return Right(await fetch(url, {
|
|
165
|
-
...options,
|
|
166
|
-
headers: retryHeaders
|
|
167
|
-
}));
|
|
168
|
-
}
|
|
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;
|
|
169
257
|
if (cacheable && response.ok) {
|
|
170
258
|
const text = await response.text();
|
|
171
259
|
this.cache.set(url, text, response.status);
|
|
172
|
-
return
|
|
260
|
+
return new Response(text, { status: response.status });
|
|
173
261
|
}
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
return Left(toError(error));
|
|
177
|
-
}
|
|
262
|
+
return response;
|
|
263
|
+
})).toEither((error) => error);
|
|
178
264
|
}
|
|
179
265
|
async authenticate() {
|
|
180
266
|
if (this.authMode === "anonymous") {
|
|
@@ -186,9 +272,9 @@ var RedditClient = class {
|
|
|
186
272
|
this.authenticated = false;
|
|
187
273
|
return Right(void 0);
|
|
188
274
|
}
|
|
189
|
-
|
|
275
|
+
return (await Try.async(async () => {
|
|
190
276
|
const now = Date.now();
|
|
191
|
-
if (this.accessToken !== void 0 && now < this.tokenExpiry) return
|
|
277
|
+
if (this.accessToken !== void 0 && now < this.tokenExpiry) return;
|
|
192
278
|
const authUrl = "https://www.reddit.com/api/v1/access_token";
|
|
193
279
|
const authData = new URLSearchParams();
|
|
194
280
|
const { username } = this;
|
|
@@ -210,16 +296,13 @@ var RedditClient = class {
|
|
|
210
296
|
});
|
|
211
297
|
if (!response.ok) {
|
|
212
298
|
const statusText = response.statusText !== "" ? response.statusText : "Unknown Error";
|
|
213
|
-
|
|
299
|
+
throw new Error(`Authentication failed: ${response.status} ${statusText}`);
|
|
214
300
|
}
|
|
215
301
|
const data = await response.json();
|
|
216
302
|
this.accessToken = data.access_token;
|
|
217
303
|
this.tokenExpiry = now + data.expires_in * 1e3;
|
|
218
304
|
this.authenticated = true;
|
|
219
|
-
|
|
220
|
-
} catch (error) {
|
|
221
|
-
return Left(toError(error));
|
|
222
|
-
}
|
|
305
|
+
})).toEither((error) => error);
|
|
223
306
|
}
|
|
224
307
|
async checkAuthentication() {
|
|
225
308
|
if (!this.authenticated) return (await this.authenticate()).isRight();
|
|
@@ -227,8 +310,8 @@ var RedditClient = class {
|
|
|
227
310
|
}
|
|
228
311
|
validateWriteAccess() {
|
|
229
312
|
if (this.username === void 0 || this.password === void 0) {
|
|
230
|
-
if (this.authMode === "anonymous") throw new
|
|
231
|
-
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");
|
|
232
315
|
}
|
|
233
316
|
}
|
|
234
317
|
async enforceWriteRateLimit() {
|
|
@@ -249,8 +332,8 @@ var RedditClient = class {
|
|
|
249
332
|
const hash = this.hashContent(content);
|
|
250
333
|
const duplicate = this.recentContentRecords.find((record) => record.hash === hash);
|
|
251
334
|
if (duplicate !== void 0) {
|
|
252
|
-
if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new
|
|
253
|
-
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.");
|
|
254
337
|
}
|
|
255
338
|
this.recentContentRecords.push({
|
|
256
339
|
hash,
|
|
@@ -259,16 +342,49 @@ var RedditClient = class {
|
|
|
259
342
|
});
|
|
260
343
|
this.recentContentRecords = this.recentContentRecords.slice(-this.safeMode.maxRecentHashes);
|
|
261
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
|
+
}
|
|
262
377
|
appendBotDisclosure(content) {
|
|
263
378
|
if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
|
|
264
379
|
return `${content}${this.botDisclosure.footer}`;
|
|
265
380
|
}
|
|
266
381
|
async getUser(username) {
|
|
267
|
-
|
|
382
|
+
const context = `Failed to get user info for ${username}`;
|
|
383
|
+
return (await Try.async(async () => {
|
|
268
384
|
const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
|
|
269
|
-
if (!response.ok)
|
|
385
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
270
386
|
const { data } = await response.json();
|
|
271
|
-
return
|
|
387
|
+
return {
|
|
272
388
|
name: data.name,
|
|
273
389
|
id: data.id,
|
|
274
390
|
commentKarma: data.comment_karma,
|
|
@@ -279,17 +395,79 @@ var RedditClient = class {
|
|
|
279
395
|
isEmployee: data.is_employee,
|
|
280
396
|
createdUtc: data.created_utc,
|
|
281
397
|
profileUrl: `https://reddit.com/user/${data.name}`
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
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));
|
|
286
463
|
}
|
|
287
464
|
async getSubredditInfo(subredditName) {
|
|
288
|
-
|
|
465
|
+
const context = `Failed to get subreddit info for ${subredditName}`;
|
|
466
|
+
return (await Try.async(async () => {
|
|
289
467
|
const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
|
|
290
|
-
if (!response.ok)
|
|
468
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
291
469
|
const { data } = await response.json();
|
|
292
|
-
return
|
|
470
|
+
return {
|
|
293
471
|
displayName: data.display_name,
|
|
294
472
|
title: data.title,
|
|
295
473
|
description: data.description,
|
|
@@ -300,26 +478,56 @@ var RedditClient = class {
|
|
|
300
478
|
over18: data.over18,
|
|
301
479
|
subredditType: data.subreddit_type,
|
|
302
480
|
url: data.url
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get subreddit info for ${subredditName}: ${toError(error).message}`));
|
|
306
|
-
}
|
|
481
|
+
};
|
|
482
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
307
483
|
}
|
|
308
|
-
async
|
|
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));
|
|
511
|
+
}
|
|
512
|
+
async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
|
|
309
513
|
const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
|
|
310
514
|
const params = new URLSearchParams({
|
|
311
515
|
t: timeFilter,
|
|
312
516
|
limit: limit.toString()
|
|
313
517
|
});
|
|
314
|
-
|
|
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 () => {
|
|
315
521
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
316
|
-
if (!response.ok)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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));
|
|
321
529
|
}
|
|
322
|
-
async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10) {
|
|
530
|
+
async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10, after) {
|
|
323
531
|
const validSorts = [
|
|
324
532
|
"hot",
|
|
325
533
|
"new",
|
|
@@ -327,43 +535,46 @@ var RedditClient = class {
|
|
|
327
535
|
"rising",
|
|
328
536
|
"controversial"
|
|
329
537
|
];
|
|
330
|
-
if (!validSorts.includes(sort)) return Left(
|
|
538
|
+
if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
|
|
331
539
|
const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
|
|
332
540
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
333
541
|
if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
|
|
334
|
-
|
|
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 () => {
|
|
335
546
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
336
|
-
if (!response.ok)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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));
|
|
341
554
|
}
|
|
342
555
|
async getPost(postId, subreddit) {
|
|
343
556
|
const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
|
|
344
|
-
|
|
557
|
+
const context = `Failed to get post with ID ${postId}`;
|
|
558
|
+
return (await Try.async(async () => {
|
|
345
559
|
const response = (await this.makeRequest(endpoint)).orThrow();
|
|
346
|
-
if (!response.ok)
|
|
347
|
-
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);
|
|
348
562
|
const json = await response.json();
|
|
349
|
-
if (json.data.children.length === 0)
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: ${toError(error).message}`));
|
|
353
|
-
}
|
|
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));
|
|
354
566
|
}
|
|
355
567
|
async getTrendingSubreddits(limit = 5) {
|
|
356
568
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
357
|
-
|
|
569
|
+
const context = `Failed to get trending subreddits`;
|
|
570
|
+
return (await Try.async(async () => {
|
|
358
571
|
const response = (await this.makeRequest(`/subreddits/popular.json?${params}`)).orThrow();
|
|
359
|
-
if (!response.ok)
|
|
360
|
-
return
|
|
361
|
-
}
|
|
362
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: ${toError(error).message}`));
|
|
363
|
-
}
|
|
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));
|
|
364
575
|
}
|
|
365
|
-
async createPost(subreddit, title, content, isSelf = true) {
|
|
366
|
-
|
|
576
|
+
async createPost(subreddit, title, content, isSelf = true, flairId, flairText) {
|
|
577
|
+
return (await Try.async(async () => {
|
|
367
578
|
var _json$json$data, _json$json$data2;
|
|
368
579
|
this.validateWriteAccess();
|
|
369
580
|
await this.enforceWriteRateLimit();
|
|
@@ -376,35 +587,30 @@ var RedditClient = class {
|
|
|
376
587
|
params.append("title", title);
|
|
377
588
|
params.append(isSelf ? "text" : "url", finalContent);
|
|
378
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);
|
|
379
592
|
const response = (await this.makeRequest("/api/submit", {
|
|
380
593
|
method: "POST",
|
|
381
594
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
382
595
|
body: params.toString()
|
|
383
596
|
})).orThrow();
|
|
384
|
-
if (!response.ok)
|
|
597
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to create post: HTTP ${response.status}`);
|
|
385
598
|
const json = await response.json();
|
|
386
|
-
if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
387
|
-
const errors = json.json.errors.map((e) => e[1]).join(", ");
|
|
388
|
-
return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
|
|
389
|
-
}
|
|
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(", ")}`);
|
|
390
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_", ""));
|
|
391
|
-
if (postId === void 0)
|
|
392
|
-
return this.getPost(postId, subreddit);
|
|
393
|
-
}
|
|
394
|
-
return Left(toError(error));
|
|
395
|
-
}
|
|
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));
|
|
396
604
|
}
|
|
397
605
|
async checkPostExists(postId) {
|
|
398
|
-
|
|
606
|
+
return (await Try.async(async () => {
|
|
399
607
|
const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
|
|
400
608
|
if (!response.ok) return false;
|
|
401
609
|
return (await response.json()).data.children.length > 0;
|
|
402
|
-
}
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
610
|
+
})).orElse(false);
|
|
405
611
|
}
|
|
406
612
|
async replyToPost(postId, content) {
|
|
407
|
-
|
|
613
|
+
return (await Try.async(async () => {
|
|
408
614
|
var _json$json$data3;
|
|
409
615
|
this.validateWriteAccess();
|
|
410
616
|
await this.enforceWriteRateLimit();
|
|
@@ -412,7 +618,7 @@ var RedditClient = class {
|
|
|
412
618
|
const finalContent = this.appendBotDisclosure(content);
|
|
413
619
|
const fullThingId = postId.startsWith("t3_") || postId.startsWith("t1_") ? postId : `t3_${postId}`;
|
|
414
620
|
if (!postId.startsWith("t1_")) {
|
|
415
|
-
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`);
|
|
416
622
|
}
|
|
417
623
|
const params = new URLSearchParams();
|
|
418
624
|
params.append("thing_id", fullThingId);
|
|
@@ -423,12 +629,12 @@ var RedditClient = class {
|
|
|
423
629
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
424
630
|
body: params.toString()
|
|
425
631
|
})).orThrow();
|
|
426
|
-
if (!response.ok)
|
|
632
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to reply: HTTP ${response.status}`);
|
|
427
633
|
const json = await response.json();
|
|
428
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) {
|
|
429
635
|
const commentData = json.json.data.things[0].data;
|
|
430
636
|
const author = this.username ?? "[unknown]";
|
|
431
|
-
return
|
|
637
|
+
return {
|
|
432
638
|
id: commentData.id,
|
|
433
639
|
author,
|
|
434
640
|
body: content,
|
|
@@ -440,17 +646,13 @@ var RedditClient = class {
|
|
|
440
646
|
edited: false,
|
|
441
647
|
isSubmitter: false,
|
|
442
648
|
permalink: commentData.permalink
|
|
443
|
-
}
|
|
444
|
-
} else if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
} else return Left(/* @__PURE__ */ new Error("Failed to parse reply response"));
|
|
448
|
-
} catch (error) {
|
|
449
|
-
return Left(toError(error));
|
|
450
|
-
}
|
|
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));
|
|
451
653
|
}
|
|
452
654
|
async deletePost(thingId) {
|
|
453
|
-
|
|
655
|
+
return (await Try.async(async () => {
|
|
454
656
|
this.validateWriteAccess();
|
|
455
657
|
const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
|
|
456
658
|
const params = new URLSearchParams();
|
|
@@ -464,21 +666,21 @@ var RedditClient = class {
|
|
|
464
666
|
const errorText = await response.text();
|
|
465
667
|
console.error(`[Reddit API] Delete failed: ${response.status} ${response.statusText}`);
|
|
466
668
|
console.error(`[Reddit API] Error response: ${errorText}`);
|
|
467
|
-
|
|
669
|
+
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`);
|
|
468
670
|
}
|
|
469
671
|
console.error(`[Reddit API] Successfully deleted ${fullThingId}`);
|
|
470
|
-
return
|
|
471
|
-
}
|
|
472
|
-
console.error(`[Reddit API] Delete exception:`, error);
|
|
473
|
-
return
|
|
474
|
-
}
|
|
672
|
+
return true;
|
|
673
|
+
})).toEither((error) => {
|
|
674
|
+
if (!isRedditError(error)) console.error(`[Reddit API] Delete exception:`, error);
|
|
675
|
+
return classifyRedditError(error);
|
|
676
|
+
});
|
|
475
677
|
}
|
|
476
678
|
async deleteComment(thingId) {
|
|
477
679
|
const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
|
|
478
680
|
return this.deletePost(fullThingId);
|
|
479
681
|
}
|
|
480
682
|
async editPost(thingId, newText) {
|
|
481
|
-
|
|
683
|
+
return (await Try.async(async () => {
|
|
482
684
|
this.validateWriteAccess();
|
|
483
685
|
await this.enforceWriteRateLimit();
|
|
484
686
|
this.checkDuplicateContent(newText);
|
|
@@ -493,23 +695,18 @@ var RedditClient = class {
|
|
|
493
695
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
494
696
|
body: params.toString()
|
|
495
697
|
})).orThrow();
|
|
496
|
-
if (!response.ok)
|
|
698
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to edit: HTTP ${response.status}`);
|
|
497
699
|
const json = await response.json();
|
|
498
|
-
if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
return Right(true);
|
|
503
|
-
} catch (error) {
|
|
504
|
-
return Left(toError(error));
|
|
505
|
-
}
|
|
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));
|
|
506
703
|
}
|
|
507
704
|
async editComment(thingId, newText) {
|
|
508
705
|
const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
|
|
509
706
|
return this.editPost(fullThingId, newText);
|
|
510
707
|
}
|
|
511
708
|
async searchReddit(query, options = {}) {
|
|
512
|
-
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;
|
|
513
710
|
const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
|
|
514
711
|
const params = new URLSearchParams({
|
|
515
712
|
q: query,
|
|
@@ -517,15 +714,20 @@ var RedditClient = class {
|
|
|
517
714
|
t: timeFilter,
|
|
518
715
|
limit: limit.toString(),
|
|
519
716
|
type,
|
|
520
|
-
...subreddit !== void 0 ? { restrict_sr: "true" } : {}
|
|
717
|
+
...subreddit !== void 0 ? { restrict_sr: "true" } : {},
|
|
718
|
+
...after !== void 0 ? { after } : {},
|
|
719
|
+
...before !== void 0 ? { before } : {}
|
|
521
720
|
});
|
|
522
|
-
|
|
721
|
+
const context = `Failed to search Reddit for: ${query}`;
|
|
722
|
+
return (await Try.async(async () => {
|
|
523
723
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
524
|
-
if (!response.ok)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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));
|
|
529
731
|
}
|
|
530
732
|
async getPostComments(postId, subreddit, options = {}) {
|
|
531
733
|
const { sort = "best", limit = 100 } = options;
|
|
@@ -533,9 +735,10 @@ var RedditClient = class {
|
|
|
533
735
|
sort,
|
|
534
736
|
limit: limit.toString()
|
|
535
737
|
});
|
|
536
|
-
|
|
738
|
+
const context = `Failed to get comments for post ${postId}`;
|
|
739
|
+
return (await Try.async(async () => {
|
|
537
740
|
const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
|
|
538
|
-
if (!response.ok)
|
|
741
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to get comments: HTTP ${response.status}`);
|
|
539
742
|
const json = await response.json();
|
|
540
743
|
const postData = json[0].data.children[0].data;
|
|
541
744
|
const post = parsePostData(postData);
|
|
@@ -559,58 +762,95 @@ var RedditClient = class {
|
|
|
559
762
|
const { replies } = item.data;
|
|
560
763
|
return [comment, ...replies !== void 0 && typeof replies !== "string" ? parseComments(replies.data.children, depth + 1) : []];
|
|
561
764
|
});
|
|
562
|
-
return
|
|
765
|
+
return {
|
|
563
766
|
post,
|
|
564
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
|
+
};
|
|
565
799
|
});
|
|
566
|
-
}
|
|
567
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get comments for post ${postId}: ${toError(error).message}`));
|
|
568
|
-
}
|
|
800
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
569
801
|
}
|
|
570
802
|
async getUserPosts(username, options = {}) {
|
|
571
|
-
const { sort = "new", timeFilter = "all", limit = 25 } = options;
|
|
803
|
+
const { sort = "new", timeFilter = "all", limit = 25, after } = options;
|
|
572
804
|
const params = new URLSearchParams({
|
|
573
805
|
sort,
|
|
574
806
|
t: timeFilter,
|
|
575
807
|
limit: limit.toString()
|
|
576
808
|
});
|
|
577
|
-
|
|
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 () => {
|
|
578
812
|
const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
|
|
579
|
-
if (!response.ok)
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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));
|
|
584
820
|
}
|
|
585
821
|
async getUserComments(username, options = {}) {
|
|
586
|
-
const { sort = "new", timeFilter = "all", limit = 25 } = options;
|
|
822
|
+
const { sort = "new", timeFilter = "all", limit = 25, after } = options;
|
|
587
823
|
const params = new URLSearchParams({
|
|
588
824
|
sort,
|
|
589
825
|
t: timeFilter,
|
|
590
826
|
limit: limit.toString()
|
|
591
827
|
});
|
|
592
|
-
|
|
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 () => {
|
|
593
831
|
const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
|
|
594
|
-
if (!response.ok)
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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));
|
|
614
854
|
}
|
|
615
855
|
};
|
|
616
856
|
const clientHolder = { instance: Option.none() };
|
|
@@ -752,7 +992,7 @@ function formatSubredditInfo(subreddit) {
|
|
|
752
992
|
//#endregion
|
|
753
993
|
//#region src/index.ts
|
|
754
994
|
dotenv.config({ quiet: true });
|
|
755
|
-
const VERSION = "1.
|
|
995
|
+
const VERSION = "1.5.1";
|
|
756
996
|
function validateUserAgent(userAgent, username) {
|
|
757
997
|
if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
|
|
758
998
|
console.error("[Warning] User-Agent does not follow Reddit's recommended format");
|
|
@@ -803,6 +1043,15 @@ function buildSafeModeConfig(safeMode) {
|
|
|
803
1043
|
function unwrapClient() {
|
|
804
1044
|
return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
|
|
805
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
|
+
}
|
|
806
1055
|
async function setupRedditClient() {
|
|
807
1056
|
const clientId = process.env.REDDIT_CLIENT_ID;
|
|
808
1057
|
const clientSecret = process.env.REDDIT_CLIENT_SECRET;
|
|
@@ -847,6 +1096,7 @@ async function setupRedditClient() {
|
|
|
847
1096
|
enabled: cacheEnabled,
|
|
848
1097
|
maxBytes: (Number.isFinite(cacheMaxMb) && cacheMaxMb > 0 ? cacheMaxMb : 50) * 1024 * 1024
|
|
849
1098
|
};
|
|
1099
|
+
const maxRetriesRaw = Number(process.env.REDDIT_MAX_RETRIES ?? "3");
|
|
850
1100
|
const client = initializeRedditClient({
|
|
851
1101
|
clientId: clientId ?? "",
|
|
852
1102
|
clientSecret: clientSecret ?? "",
|
|
@@ -856,7 +1106,12 @@ async function setupRedditClient() {
|
|
|
856
1106
|
authMode,
|
|
857
1107
|
safeMode: safeModeConfig,
|
|
858
1108
|
botDisclosure: botDisclosureConfig,
|
|
859
|
-
cache: cacheConfig
|
|
1109
|
+
cache: cacheConfig,
|
|
1110
|
+
retry: {
|
|
1111
|
+
maxRetries: Number.isFinite(maxRetriesRaw) && maxRetriesRaw >= 0 ? Math.floor(maxRetriesRaw) : 3,
|
|
1112
|
+
baseDelayMs: 1e3,
|
|
1113
|
+
maxDelayMs: 6e4
|
|
1114
|
+
}
|
|
860
1115
|
});
|
|
861
1116
|
console.error("[Setup] Reddit client initialized");
|
|
862
1117
|
console.error(`[Setup] Authentication mode: ${authMode}`);
|
|
@@ -938,7 +1193,12 @@ For details: https://support.reddithelp.com/hc/en-us/articles/42728983564564-Res
|
|
|
938
1193
|
});
|
|
939
1194
|
server.addTool({
|
|
940
1195
|
name: "test_reddit_mcp_server",
|
|
941
|
-
description: "
|
|
1196
|
+
description: "Health check for the Reddit MCP server. Read-only and side-effect-free — inspects local configuration only and makes no Reddit API calls. Returns the server version, whether the Reddit client is initialized, whether OAuth credentials are present, and whether write access (REDDIT_USERNAME/REDDIT_PASSWORD) is configured. Use this first to diagnose setup/auth problems. Do NOT use it to check Reddit's own status or connectivity — it never contacts Reddit. A \"✗ Write Access\" result means the write tools (create_post, reply_to_post, edit_*, delete_*) will fail.",
|
|
1197
|
+
annotations: {
|
|
1198
|
+
title: "Test Reddit MCP Server",
|
|
1199
|
+
readOnlyHint: true,
|
|
1200
|
+
openWorldHint: false
|
|
1201
|
+
},
|
|
942
1202
|
parameters: z.object({}),
|
|
943
1203
|
execute: () => {
|
|
944
1204
|
const client = getRedditClient();
|
|
@@ -955,8 +1215,13 @@ Ready to handle Reddit API requests!`);
|
|
|
955
1215
|
});
|
|
956
1216
|
server.addTool({
|
|
957
1217
|
name: "get_user_info",
|
|
958
|
-
description: "Get
|
|
959
|
-
|
|
1218
|
+
description: "Get a public profile for any Reddit user: comment/post/total karma, account age and status flags, plus a short activity analysis and engagement tips. Read-only; works in anonymous mode. Returns profile stats only — use get_user_posts / get_user_comments for their actual content. Use get_me instead for your own authenticated account; do NOT expect private fields here, as only public data is returned.",
|
|
1219
|
+
annotations: {
|
|
1220
|
+
title: "Get User Info",
|
|
1221
|
+
readOnlyHint: true,
|
|
1222
|
+
openWorldHint: true
|
|
1223
|
+
},
|
|
1224
|
+
parameters: z.object({ username: z.string().describe("The target user's Reddit username, without the u/ prefix (e.g. 'spez', not 'u/spez').") }),
|
|
960
1225
|
execute: async (args) => {
|
|
961
1226
|
return (await unwrapClient().getUser(args.username)).fold((err) => {
|
|
962
1227
|
throw new Error(`Failed to get user info: ${err.message}`);
|
|
@@ -982,16 +1247,91 @@ server.addTool({
|
|
|
982
1247
|
});
|
|
983
1248
|
}
|
|
984
1249
|
});
|
|
1250
|
+
server.addTool({
|
|
1251
|
+
name: "get_me",
|
|
1252
|
+
description: "Get the authenticated user's own profile (karma, account age, status flags). Read-only, but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD) and fails in anonymous mode. Use this instead of get_user_info when you need the current account rather than an arbitrary user. Do NOT use it to look up other users — it always returns the logged-in account.",
|
|
1253
|
+
annotations: {
|
|
1254
|
+
title: "Get My Account",
|
|
1255
|
+
readOnlyHint: true,
|
|
1256
|
+
openWorldHint: true
|
|
1257
|
+
},
|
|
1258
|
+
parameters: z.object({}),
|
|
1259
|
+
execute: async () => {
|
|
1260
|
+
return (await unwrapClient().getMe()).fold((err) => {
|
|
1261
|
+
throw new Error(`Failed to get authenticated user: ${err.message}`);
|
|
1262
|
+
}, (user) => {
|
|
1263
|
+
const formattedUser = formatUserInfo(user);
|
|
1264
|
+
return `# Your Account: u/${formattedUser.username}
|
|
1265
|
+
|
|
1266
|
+
## Profile Overview
|
|
1267
|
+
- Username: u/${formattedUser.username}
|
|
1268
|
+
- Karma:
|
|
1269
|
+
- Comment Karma: ${formattedUser.karma.commentKarma.toLocaleString()}
|
|
1270
|
+
- Post Karma: ${formattedUser.karma.postKarma.toLocaleString()}
|
|
1271
|
+
- Total Karma: ${formattedUser.karma.totalKarma.toLocaleString()}
|
|
1272
|
+
- Account Status: ${formattedUser.accountStatus.join(", ")}
|
|
1273
|
+
- Account Created: ${formattedUser.accountCreated}
|
|
1274
|
+
- Profile URL: ${formattedUser.profileUrl}`;
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
server.addTool({
|
|
1279
|
+
name: "get_my_overview",
|
|
1280
|
+
description: "Get the authenticated user's own recent activity — posts and comments interleaved, newest first. Read-only but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD). Returns up to `limit` items plus an `after` cursor for the next page. Use get_my_saved for saved items, or get_user_posts / get_user_comments for another user. Do NOT use this to fetch a specific post's thread — use get_post_comments.",
|
|
1281
|
+
annotations: {
|
|
1282
|
+
title: "Get My Overview",
|
|
1283
|
+
readOnlyHint: true,
|
|
1284
|
+
openWorldHint: true
|
|
1285
|
+
},
|
|
1286
|
+
parameters: z.object({
|
|
1287
|
+
limit: z.number().min(1).max(100).default(25).describe("How many activity items to return, 1–100 (default 25)."),
|
|
1288
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
|
|
1289
|
+
}),
|
|
1290
|
+
execute: async (args) => {
|
|
1291
|
+
return (await unwrapClient().getMyOverview({
|
|
1292
|
+
limit: args.limit,
|
|
1293
|
+
after: args.after
|
|
1294
|
+
})).fold((err) => {
|
|
1295
|
+
throw new Error(`Failed to get your overview: ${err.message}`);
|
|
1296
|
+
}, (content) => formatUserContent("Your Overview", content));
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
server.addTool({
|
|
1300
|
+
name: "get_my_saved",
|
|
1301
|
+
description: "Get the authenticated user's saved posts and comments (private to the account). Read-only but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD). Returns up to `limit` items plus an `after` pagination cursor. Use get_my_overview for your authored activity. Do NOT use this for another user — saved items are private and have no cross-user equivalent.",
|
|
1302
|
+
annotations: {
|
|
1303
|
+
title: "Get My Saved",
|
|
1304
|
+
readOnlyHint: true,
|
|
1305
|
+
openWorldHint: true
|
|
1306
|
+
},
|
|
1307
|
+
parameters: z.object({
|
|
1308
|
+
limit: z.number().min(1).max(100).default(25).describe("How many saved items to return, 1–100 (default 25)."),
|
|
1309
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
|
|
1310
|
+
}),
|
|
1311
|
+
execute: async (args) => {
|
|
1312
|
+
return (await unwrapClient().getMySaved({
|
|
1313
|
+
limit: args.limit,
|
|
1314
|
+
after: args.after
|
|
1315
|
+
})).fold((err) => {
|
|
1316
|
+
throw new Error(`Failed to get saved content: ${err.message}`);
|
|
1317
|
+
}, (content) => formatUserContent("Your Saved Content", content));
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
985
1320
|
server.addTool({
|
|
986
1321
|
name: "get_user_posts",
|
|
987
|
-
description: "Get
|
|
1322
|
+
description: "Get posts submitted by a specific user, with sort (new/hot/top) and time filter. Read-only; works anonymously. Returns a page of posts (title, subreddit, score, upvote ratio, comment count, permalink) plus an `after` cursor for paging. Use get_user_comments for their comments, or get_user_info for karma/profile stats. Do NOT use this to search a subreddit — use search_reddit or browse_subreddit.",
|
|
1323
|
+
annotations: {
|
|
1324
|
+
title: "Get User Posts",
|
|
1325
|
+
readOnlyHint: true,
|
|
1326
|
+
openWorldHint: true
|
|
1327
|
+
},
|
|
988
1328
|
parameters: z.object({
|
|
989
|
-
username: z.string().describe("The Reddit username
|
|
1329
|
+
username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
|
|
990
1330
|
sort: z.enum([
|
|
991
1331
|
"new",
|
|
992
1332
|
"hot",
|
|
993
1333
|
"top"
|
|
994
|
-
]).default("new").describe("
|
|
1334
|
+
]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
|
|
995
1335
|
time_filter: z.enum([
|
|
996
1336
|
"hour",
|
|
997
1337
|
"day",
|
|
@@ -999,17 +1339,20 @@ server.addTool({
|
|
|
999
1339
|
"month",
|
|
1000
1340
|
"year",
|
|
1001
1341
|
"all"
|
|
1002
|
-
]).default("all").describe("Time
|
|
1003
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1342
|
+
]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
|
|
1343
|
+
limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
|
|
1344
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1004
1345
|
}),
|
|
1005
1346
|
execute: async (args) => {
|
|
1006
1347
|
return (await unwrapClient().getUserPosts(args.username, {
|
|
1007
1348
|
sort: args.sort,
|
|
1008
1349
|
timeFilter: args.time_filter,
|
|
1009
|
-
limit: args.limit
|
|
1350
|
+
limit: args.limit,
|
|
1351
|
+
after: args.after
|
|
1010
1352
|
})).fold((err) => {
|
|
1011
1353
|
throw new Error(`Failed to get user posts: ${err.message}`);
|
|
1012
|
-
}, (
|
|
1354
|
+
}, (page) => {
|
|
1355
|
+
const posts = page.items;
|
|
1013
1356
|
if (posts.length === 0) return `No posts found for u/${args.username} with the specified filters.`;
|
|
1014
1357
|
const postSummaries = posts.map((post, index) => {
|
|
1015
1358
|
const flags = [...post.over18 ? ["**NSFW**"] : [], ...post.spoiler === true ? ["**Spoiler**"] : []];
|
|
@@ -1022,20 +1365,25 @@ server.addTool({
|
|
|
1022
1365
|
}).join("\n\n");
|
|
1023
1366
|
return `# Posts by u/${args.username} (${args.sort} - ${args.time_filter})
|
|
1024
1367
|
|
|
1025
|
-
${postSummaries}`;
|
|
1368
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1026
1369
|
});
|
|
1027
1370
|
}
|
|
1028
1371
|
});
|
|
1029
1372
|
server.addTool({
|
|
1030
1373
|
name: "get_user_comments",
|
|
1031
|
-
description: "Get
|
|
1374
|
+
description: "Get comments made by a specific user, with sort (new/hot/top) and time filter. Read-only; works anonymously. Returns a page of comments (subreddit, parent post title, body excerpt, score, permalink) plus an `after` cursor. Use get_user_posts for their submissions, or get_user_info for karma/profile stats. Do NOT use this to read one post's thread — use get_post_comments.",
|
|
1375
|
+
annotations: {
|
|
1376
|
+
title: "Get User Comments",
|
|
1377
|
+
readOnlyHint: true,
|
|
1378
|
+
openWorldHint: true
|
|
1379
|
+
},
|
|
1032
1380
|
parameters: z.object({
|
|
1033
|
-
username: z.string().describe("The Reddit username
|
|
1381
|
+
username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
|
|
1034
1382
|
sort: z.enum([
|
|
1035
1383
|
"new",
|
|
1036
1384
|
"hot",
|
|
1037
1385
|
"top"
|
|
1038
|
-
]).default("new").describe("
|
|
1386
|
+
]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
|
|
1039
1387
|
time_filter: z.enum([
|
|
1040
1388
|
"hour",
|
|
1041
1389
|
"day",
|
|
@@ -1043,17 +1391,20 @@ server.addTool({
|
|
|
1043
1391
|
"month",
|
|
1044
1392
|
"year",
|
|
1045
1393
|
"all"
|
|
1046
|
-
]).default("all").describe("Time
|
|
1047
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1394
|
+
]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
|
|
1395
|
+
limit: z.number().min(1).max(100).default(10).describe("How many comments to return, 1–100 (default 10)."),
|
|
1396
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1048
1397
|
}),
|
|
1049
1398
|
execute: async (args) => {
|
|
1050
1399
|
return (await unwrapClient().getUserComments(args.username, {
|
|
1051
1400
|
sort: args.sort,
|
|
1052
1401
|
timeFilter: args.time_filter,
|
|
1053
|
-
limit: args.limit
|
|
1402
|
+
limit: args.limit,
|
|
1403
|
+
after: args.after
|
|
1054
1404
|
})).fold((err) => {
|
|
1055
1405
|
throw new Error(`Failed to get user comments: ${err.message}`);
|
|
1056
|
-
}, (
|
|
1406
|
+
}, (page) => {
|
|
1407
|
+
const comments = page.items;
|
|
1057
1408
|
if (comments.length === 0) return `No comments found for u/${args.username} with the specified filters.`;
|
|
1058
1409
|
const commentSummaries = comments.map((comment, index) => {
|
|
1059
1410
|
const truncatedBody = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
|
|
@@ -1069,16 +1420,21 @@ In r/${comment.subreddit} on "${comment.submissionTitle}"
|
|
|
1069
1420
|
}).join("\n\n");
|
|
1070
1421
|
return `# Comments by u/${args.username} (${args.sort} - ${args.time_filter})
|
|
1071
1422
|
|
|
1072
|
-
${commentSummaries}`;
|
|
1423
|
+
${commentSummaries}${nextPageHint(page.after)}`;
|
|
1073
1424
|
});
|
|
1074
1425
|
}
|
|
1075
1426
|
});
|
|
1076
1427
|
server.addTool({
|
|
1077
1428
|
name: "get_reddit_post",
|
|
1078
|
-
description: "Get
|
|
1429
|
+
description: "Get a single post by subreddit + post id: title, author, self-text or link content, score, upvote ratio, comment count, flair/flags, and an engagement analysis. Read-only; works anonymously. Returns the post only — use get_post_comments for its comment thread. Do NOT use this to list a subreddit's posts (use browse_subreddit / get_top_posts) or to find posts by keyword (use search_reddit).",
|
|
1430
|
+
annotations: {
|
|
1431
|
+
title: "Get Reddit Post",
|
|
1432
|
+
readOnlyHint: true,
|
|
1433
|
+
openWorldHint: true
|
|
1434
|
+
},
|
|
1079
1435
|
parameters: z.object({
|
|
1080
|
-
subreddit: z.string().describe("The subreddit
|
|
1081
|
-
post_id: z.string().describe("
|
|
1436
|
+
subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'programming')."),
|
|
1437
|
+
post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink like reddit.com/r/<sub>/comments/<post_id>/... (e.g. '1abc23'). With or without a t3_ prefix.")
|
|
1082
1438
|
}),
|
|
1083
1439
|
execute: async (args) => {
|
|
1084
1440
|
return (await unwrapClient().getPost(args.post_id, args.subreddit)).fold((err) => {
|
|
@@ -1119,9 +1475,14 @@ ${formattedPost.bestTimeToEngage}`;
|
|
|
1119
1475
|
});
|
|
1120
1476
|
server.addTool({
|
|
1121
1477
|
name: "get_top_posts",
|
|
1122
|
-
description: "Get top posts from a subreddit or from the
|
|
1478
|
+
description: "Get the top-scoring posts from a subreddit — or from the authenticated home feed if no subreddit is given — within a time window (hour…all). Read-only; works anonymously. Returns a page of posts (title, author, score, upvote ratio, comments, link) plus an `after` cursor. This is a shortcut for the 'top' sort; use browse_subreddit for hot/new/rising/controversial, or search_reddit to find posts by keyword.",
|
|
1479
|
+
annotations: {
|
|
1480
|
+
title: "Get Top Posts",
|
|
1481
|
+
readOnlyHint: true,
|
|
1482
|
+
openWorldHint: true
|
|
1483
|
+
},
|
|
1123
1484
|
parameters: z.object({
|
|
1124
|
-
subreddit: z.string().optional().describe("
|
|
1485
|
+
subreddit: z.string().optional().describe("Subreddit to read, without the r/ prefix (e.g. 'science'). Omit to use the authenticated home feed (requires credentials)."),
|
|
1125
1486
|
time_filter: z.enum([
|
|
1126
1487
|
"hour",
|
|
1127
1488
|
"day",
|
|
@@ -1129,13 +1490,15 @@ server.addTool({
|
|
|
1129
1490
|
"month",
|
|
1130
1491
|
"year",
|
|
1131
1492
|
"all"
|
|
1132
|
-
]).default("week").describe("Time
|
|
1133
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1493
|
+
]).default("week").describe("Time window the 'top' ranking is computed over (e.g. 'day' = top today). Default 'week'."),
|
|
1494
|
+
limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
|
|
1495
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1134
1496
|
}),
|
|
1135
1497
|
execute: async (args) => {
|
|
1136
|
-
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit)).fold((err) => {
|
|
1498
|
+
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
|
|
1137
1499
|
throw new Error(`Failed to get top posts: ${err.message}`);
|
|
1138
|
-
}, (
|
|
1500
|
+
}, (page) => {
|
|
1501
|
+
const posts = page.items;
|
|
1139
1502
|
if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
|
|
1140
1503
|
const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
|
|
1141
1504
|
- Author: u/${post.author}
|
|
@@ -1145,22 +1508,27 @@ server.addTool({
|
|
|
1145
1508
|
- Link: ${post.links.shortLink}`).join("\n\n");
|
|
1146
1509
|
return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
|
|
1147
1510
|
|
|
1148
|
-
${postSummaries}`;
|
|
1511
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1149
1512
|
});
|
|
1150
1513
|
}
|
|
1151
1514
|
});
|
|
1152
1515
|
server.addTool({
|
|
1153
1516
|
name: "browse_subreddit",
|
|
1154
|
-
description: "Browse
|
|
1517
|
+
description: "Browse a subreddit — or the authenticated home feed when no subreddit is given — by sort order: hot, new, top, rising, or controversial. Read-only; works anonymously. `time_filter` applies only to the top and controversial sorts. Returns a page of posts (title, author, score, upvote ratio, comments, link) plus an `after` cursor. Use get_top_posts as a shortcut for the top sort, or search_reddit to find posts by keyword rather than by feed order.",
|
|
1518
|
+
annotations: {
|
|
1519
|
+
title: "Browse Subreddit",
|
|
1520
|
+
readOnlyHint: true,
|
|
1521
|
+
openWorldHint: true
|
|
1522
|
+
},
|
|
1155
1523
|
parameters: z.object({
|
|
1156
|
-
subreddit: z.string().optional().describe("
|
|
1524
|
+
subreddit: z.string().optional().describe("Subreddit to browse, without the r/ prefix (e.g. 'news'). Omit to use the authenticated home feed (requires credentials)."),
|
|
1157
1525
|
sort: z.enum([
|
|
1158
1526
|
"hot",
|
|
1159
1527
|
"new",
|
|
1160
1528
|
"top",
|
|
1161
1529
|
"rising",
|
|
1162
1530
|
"controversial"
|
|
1163
|
-
]).default("hot").describe("
|
|
1531
|
+
]).default("hot").describe("Feed ordering: 'hot' (default), 'new', 'rising', 'top', or 'controversial'. 'top'/'controversial' honor `time_filter`."),
|
|
1164
1532
|
time_filter: z.enum([
|
|
1165
1533
|
"hour",
|
|
1166
1534
|
"day",
|
|
@@ -1168,13 +1536,15 @@ server.addTool({
|
|
|
1168
1536
|
"month",
|
|
1169
1537
|
"year",
|
|
1170
1538
|
"all"
|
|
1171
|
-
]).default("week").describe("Time
|
|
1172
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1539
|
+
]).default("week").describe("Time window; only applies to sort='top' or 'controversial'. Ignored otherwise. Default 'week'."),
|
|
1540
|
+
limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
|
|
1541
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1173
1542
|
}),
|
|
1174
1543
|
execute: async (args) => {
|
|
1175
|
-
return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit)).fold((err) => {
|
|
1544
|
+
return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
|
|
1176
1545
|
throw new Error(`Failed to browse subreddit: ${err.message}`);
|
|
1177
|
-
}, (
|
|
1546
|
+
}, (page) => {
|
|
1547
|
+
const posts = page.items;
|
|
1178
1548
|
const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
|
|
1179
1549
|
if (posts.length === 0) return `No posts found in ${location}.`;
|
|
1180
1550
|
const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
|
|
@@ -1187,14 +1557,19 @@ server.addTool({
|
|
|
1187
1557
|
const heading = location === "home feed" ? "Home Feed" : location;
|
|
1188
1558
|
return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
|
|
1189
1559
|
|
|
1190
|
-
${postSummaries}`;
|
|
1560
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1191
1561
|
});
|
|
1192
1562
|
}
|
|
1193
1563
|
});
|
|
1194
1564
|
server.addTool({
|
|
1195
1565
|
name: "get_subreddit_info",
|
|
1196
|
-
description: "Get
|
|
1197
|
-
|
|
1566
|
+
description: "Get a subreddit's profile: title, description, subscriber and active-user counts, creation date, flags, wiki/link URLs, plus a community analysis and posting tips. Read-only; works anonymously. Returns metadata about the community itself — use browse_subreddit / get_top_posts for its posts, or get_subreddit_rules for its posting rules. Do NOT use this to find subreddits by topic — use search_reddit with type='sr'.",
|
|
1567
|
+
annotations: {
|
|
1568
|
+
title: "Get Subreddit Info",
|
|
1569
|
+
readOnlyHint: true,
|
|
1570
|
+
openWorldHint: true
|
|
1571
|
+
},
|
|
1572
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'askscience').") }),
|
|
1198
1573
|
execute: async (args) => {
|
|
1199
1574
|
return (await unwrapClient().getSubredditInfo(args.subreddit_name)).fold((err) => {
|
|
1200
1575
|
throw new Error(`Failed to get subreddit info: ${err.message}`);
|
|
@@ -1230,9 +1605,65 @@ ${formattedSubreddit.description.full}
|
|
|
1230
1605
|
});
|
|
1231
1606
|
}
|
|
1232
1607
|
});
|
|
1608
|
+
server.addTool({
|
|
1609
|
+
name: "get_subreddit_rules",
|
|
1610
|
+
description: "Get a subreddit's posting rules (each rule's name, what it applies to, and its description). Read-only; works anonymously. Returns the rules list, or a note when the subreddit lists none. Call this before create_post to check requirements and avoid auto-removal. For available post flairs use get_post_flairs instead.",
|
|
1611
|
+
annotations: {
|
|
1612
|
+
title: "Get Subreddit Rules",
|
|
1613
|
+
readOnlyHint: true,
|
|
1614
|
+
openWorldHint: true
|
|
1615
|
+
},
|
|
1616
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'AskReddit').") }),
|
|
1617
|
+
execute: async (args) => {
|
|
1618
|
+
return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
|
|
1619
|
+
throw new Error(`Failed to get subreddit rules: ${err.message}`);
|
|
1620
|
+
}, (rules) => {
|
|
1621
|
+
if (rules.length === 0) return `r/${args.subreddit_name} has no listed subreddit-specific rules.`;
|
|
1622
|
+
const ruleList = rules.map((rule, index) => {
|
|
1623
|
+
const applies = rule.kind === "all" ? "posts & comments" : `${rule.kind}s`;
|
|
1624
|
+
const detail = rule.description.trim() === "" ? "" : `\n${rule.description.trim()}`;
|
|
1625
|
+
return `### ${index + 1}. ${rule.shortName} _(applies to ${applies})_${detail}`;
|
|
1626
|
+
}).join("\n\n");
|
|
1627
|
+
return `# Posting Rules for r/${args.subreddit_name}
|
|
1628
|
+
|
|
1629
|
+
${ruleList}`;
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
server.addTool({
|
|
1634
|
+
name: "get_post_flairs",
|
|
1635
|
+
description: "List a subreddit's selectable link flairs (flair text + flair_id) for use with create_post. Read-only, but requires user credentials; many subreddits expose flairs only to members, so this can 403 or return empty anonymously. Pass a returned flair_id (and flair_text for text-editable flairs) to create_post. For the subreddit's posting rules use get_subreddit_rules instead.",
|
|
1636
|
+
annotations: {
|
|
1637
|
+
title: "Get Post Flairs",
|
|
1638
|
+
readOnlyHint: true,
|
|
1639
|
+
openWorldHint: true
|
|
1640
|
+
},
|
|
1641
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'gadgets').") }),
|
|
1642
|
+
execute: async (args) => {
|
|
1643
|
+
return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
|
|
1644
|
+
throw new Error(`Failed to get post flairs: ${err.message}`);
|
|
1645
|
+
}, (flairs) => {
|
|
1646
|
+
if (flairs.length === 0) return `r/${args.subreddit_name} has no selectable link flairs (or none are visible to this account).`;
|
|
1647
|
+
const flairList = flairs.map((flair) => {
|
|
1648
|
+
const editable = flair.textEditable === true ? " _(text editable)_" : "";
|
|
1649
|
+
return `- ${flair.text}${editable} — \`flair_id: ${flair.id}\``;
|
|
1650
|
+
}).join("\n");
|
|
1651
|
+
return `# Available Link Flairs for r/${args.subreddit_name}
|
|
1652
|
+
|
|
1653
|
+
${flairList}
|
|
1654
|
+
|
|
1655
|
+
Pass the desired \`flair_id\` to \`create_post\`.`;
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1233
1659
|
server.addTool({
|
|
1234
1660
|
name: "get_trending_subreddits",
|
|
1235
|
-
description: "Get a list of
|
|
1661
|
+
description: "Get the subreddits Reddit is currently featuring as trending/popular. Read-only, no parameters; works anonymously. Returns a list of subreddit names that changes through the day (cached briefly server-side). To find subreddits by keyword instead of by trend, use search_reddit with type='sr'.",
|
|
1662
|
+
annotations: {
|
|
1663
|
+
title: "Get Trending Subreddits",
|
|
1664
|
+
readOnlyHint: true,
|
|
1665
|
+
openWorldHint: true
|
|
1666
|
+
},
|
|
1236
1667
|
parameters: z.object({}),
|
|
1237
1668
|
execute: async () => {
|
|
1238
1669
|
return (await unwrapClient().getTrendingSubreddits()).fold((err) => {
|
|
@@ -1244,17 +1675,22 @@ ${trendingSubreddits.map((subreddit, index) => `${index + 1}. r/${subreddit}`).j
|
|
|
1244
1675
|
});
|
|
1245
1676
|
server.addTool({
|
|
1246
1677
|
name: "search_reddit",
|
|
1247
|
-
description: "Search Reddit for posts and content
|
|
1678
|
+
description: "Search Reddit for posts — or subreddits/users via `type` — optionally scoped to one subreddit, with sort and time filters. Read-only; works anonymously. Returns a page of results (title, subreddit, author, score, comments, link) plus an `after` cursor for paging. Use this to find content by keyword; use browse_subreddit / get_top_posts to list a known subreddit's feed instead.",
|
|
1679
|
+
annotations: {
|
|
1680
|
+
title: "Search Reddit",
|
|
1681
|
+
readOnlyHint: true,
|
|
1682
|
+
openWorldHint: true
|
|
1683
|
+
},
|
|
1248
1684
|
parameters: z.object({
|
|
1249
|
-
query: z.string().describe("Search
|
|
1250
|
-
subreddit: z.string().optional().describe("
|
|
1685
|
+
query: z.string().describe("Search terms; supports Reddit operators (quotes for exact phrases, author:name, self:yes). Must be non-empty."),
|
|
1686
|
+
subreddit: z.string().optional().describe("Restrict results to this subreddit, without the r/ prefix (e.g. 'python'). Omit to search all of Reddit."),
|
|
1251
1687
|
sort: z.enum([
|
|
1252
1688
|
"relevance",
|
|
1253
1689
|
"hot",
|
|
1254
1690
|
"top",
|
|
1255
1691
|
"new",
|
|
1256
1692
|
"comments"
|
|
1257
|
-
]).default("relevance").describe("Sort order"),
|
|
1693
|
+
]).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."),
|
|
1258
1694
|
time_filter: z.enum([
|
|
1259
1695
|
"hour",
|
|
1260
1696
|
"day",
|
|
@@ -1262,13 +1698,14 @@ server.addTool({
|
|
|
1262
1698
|
"month",
|
|
1263
1699
|
"year",
|
|
1264
1700
|
"all"
|
|
1265
|
-
]).default("all").describe("
|
|
1266
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1701
|
+
]).default("all").describe("Restrict to results from this recent window (e.g. 'week'). Default 'all' (no time limit)."),
|
|
1702
|
+
limit: z.number().min(1).max(100).default(10).describe("How many results to return, 1–100 (default 10)."),
|
|
1267
1703
|
type: z.enum([
|
|
1268
1704
|
"link",
|
|
1269
1705
|
"sr",
|
|
1270
1706
|
"user"
|
|
1271
|
-
]).default("link").describe("
|
|
1707
|
+
]).default("link").describe("What to search for: 'link' = posts (default), 'sr' = subreddits, 'user' = users."),
|
|
1708
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1272
1709
|
}),
|
|
1273
1710
|
execute: async (args) => {
|
|
1274
1711
|
const client = unwrapClient();
|
|
@@ -1278,10 +1715,12 @@ server.addTool({
|
|
|
1278
1715
|
sort: args.sort,
|
|
1279
1716
|
timeFilter: args.time_filter,
|
|
1280
1717
|
limit: args.limit,
|
|
1281
|
-
type: args.type
|
|
1718
|
+
type: args.type,
|
|
1719
|
+
after: args.after
|
|
1282
1720
|
})).fold((err) => {
|
|
1283
1721
|
throw new Error(`Failed to search: ${err.message}`);
|
|
1284
|
-
}, (
|
|
1722
|
+
}, (page) => {
|
|
1723
|
+
const posts = page.items;
|
|
1285
1724
|
if (posts.length === 0) {
|
|
1286
1725
|
const searchLocation = Option(args.subreddit).fold(() => "", (sr) => ` in r/${sr}`);
|
|
1287
1726
|
return `No results found for "${args.query}"${searchLocation}.`;
|
|
@@ -1301,23 +1740,32 @@ server.addTool({
|
|
|
1301
1740
|
|
|
1302
1741
|
Sorted by: ${args.sort} | Time: ${args.time_filter} | Type: ${args.type}
|
|
1303
1742
|
|
|
1304
|
-
${searchResults}`;
|
|
1743
|
+
${searchResults}${nextPageHint(page.after)}`;
|
|
1305
1744
|
});
|
|
1306
1745
|
}
|
|
1307
1746
|
});
|
|
1308
1747
|
server.addTool({
|
|
1309
1748
|
name: "create_post",
|
|
1310
|
-
description: "Create a new post in a subreddit
|
|
1749
|
+
description: "Create a new text or link post in a subreddit. Mutating and NOT idempotent — each call publishes a separate post. Requires REDDIT_USERNAME and REDDIT_PASSWORD; fails without them. Returns the new post's id and URL. Check get_subreddit_rules and get_post_flairs first, since many subreddits require a flair or reject certain content. WARNING: rapid posting or duplicate content may trigger Reddit's spam detection and account bans — enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
|
|
1750
|
+
annotations: {
|
|
1751
|
+
title: "Create Post",
|
|
1752
|
+
readOnlyHint: false,
|
|
1753
|
+
destructiveHint: false,
|
|
1754
|
+
idempotentHint: false,
|
|
1755
|
+
openWorldHint: true
|
|
1756
|
+
},
|
|
1311
1757
|
parameters: z.object({
|
|
1312
|
-
subreddit: z.string().describe("
|
|
1313
|
-
title: z.string().describe("
|
|
1314
|
-
content: z.string().describe("
|
|
1315
|
-
is_self: z.boolean().default(true).describe("
|
|
1758
|
+
subreddit: z.string().describe("Target subreddit, without the r/ prefix (e.g. 'test')."),
|
|
1759
|
+
title: z.string().describe("Post title (cannot be edited after creation)."),
|
|
1760
|
+
content: z.string().describe("For a self post (is_self=true): the body text, Reddit markdown supported. For a link post (is_self=false): the destination URL."),
|
|
1761
|
+
is_self: z.boolean().default(true).describe("true = text/self post using `content` as the body (default); false = link post using `content` as the URL."),
|
|
1762
|
+
flair_id: z.string().optional().describe("Link flair template id from get_post_flairs; many subreddits require one or the post is auto-removed."),
|
|
1763
|
+
flair_text: z.string().optional().describe("Custom flair text, allowed only for flairs whose template is text-editable.")
|
|
1316
1764
|
}),
|
|
1317
1765
|
execute: async (args) => {
|
|
1318
1766
|
const client = unwrapClient();
|
|
1319
1767
|
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.");
|
|
1320
|
-
return (await client.createPost(args.subreddit, args.title, args.content, args.is_self)).fold((err) => {
|
|
1768
|
+
return (await client.createPost(args.subreddit, args.title, args.content, args.is_self, args.flair_id, args.flair_text)).fold((err) => {
|
|
1321
1769
|
throw new Error(`Failed to create post: ${err.message}`);
|
|
1322
1770
|
}, (post) => {
|
|
1323
1771
|
const formattedPost = formatPostInfo(post);
|
|
@@ -1335,10 +1783,17 @@ Your post has been successfully submitted to r/${formattedPost.subreddit}.`;
|
|
|
1335
1783
|
});
|
|
1336
1784
|
server.addTool({
|
|
1337
1785
|
name: "reply_to_post",
|
|
1338
|
-
description: "Post a reply to an existing
|
|
1786
|
+
description: "Post a reply to an existing post or comment. Mutating and NOT idempotent — each call adds a new comment. Requires REDDIT_USERNAME and REDDIT_PASSWORD. The parent is identified by its thing id — t3_ for a post, t1_ for a comment — so this creates both top-level and nested replies. Returns the new comment's id. Use edit_comment to change a reply you already posted. WARNING: rapid or duplicate replies may trigger Reddit's spam detection; enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
|
|
1787
|
+
annotations: {
|
|
1788
|
+
title: "Reply to Post or Comment",
|
|
1789
|
+
readOnlyHint: false,
|
|
1790
|
+
destructiveHint: false,
|
|
1791
|
+
idempotentHint: false,
|
|
1792
|
+
openWorldHint: true
|
|
1793
|
+
},
|
|
1339
1794
|
parameters: z.object({
|
|
1340
|
-
post_id: z.string().describe("
|
|
1341
|
-
content: z.string().describe("
|
|
1795
|
+
post_id: z.string().describe("Parent thing id to reply under: t3_<id> for a post (creates a top-level comment) or t1_<id> for a comment (creates a nested reply)."),
|
|
1796
|
+
content: z.string().describe("Reply body text; Reddit markdown supported.")
|
|
1342
1797
|
}),
|
|
1343
1798
|
execute: async (args) => {
|
|
1344
1799
|
const client = unwrapClient();
|
|
@@ -1357,8 +1812,15 @@ Your reply has been successfully posted.`);
|
|
|
1357
1812
|
});
|
|
1358
1813
|
server.addTool({
|
|
1359
1814
|
name: "delete_post",
|
|
1360
|
-
description: "
|
|
1361
|
-
|
|
1815
|
+
description: "Permanently delete one of your own posts. Mutating and destructive but idempotent — deleting an already-deleted post is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and only works on posts authored by the authenticated account. Only affects the post you name; use delete_comment for comments. WARNING: this cannot be undone — the content is removed, though the post id remains.",
|
|
1816
|
+
annotations: {
|
|
1817
|
+
title: "Delete Post",
|
|
1818
|
+
readOnlyHint: false,
|
|
1819
|
+
destructiveHint: true,
|
|
1820
|
+
idempotentHint: true,
|
|
1821
|
+
openWorldHint: true
|
|
1822
|
+
},
|
|
1823
|
+
parameters: z.object({ thing_id: z.string().describe("The post to delete: a full thing id 't3_<id>' or just the base36 post id '<id>' (the 't3_' prefix is added automatically). Must be a post you authored.") }),
|
|
1362
1824
|
execute: async (args) => {
|
|
1363
1825
|
const client = unwrapClient();
|
|
1364
1826
|
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.");
|
|
@@ -1373,8 +1835,15 @@ The post ${args.thing_id} has been permanently deleted from Reddit.
|
|
|
1373
1835
|
});
|
|
1374
1836
|
server.addTool({
|
|
1375
1837
|
name: "delete_comment",
|
|
1376
|
-
description: "
|
|
1377
|
-
|
|
1838
|
+
description: "Permanently delete one of your own comments. Mutating and destructive but idempotent — deleting an already-deleted comment is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and only works on comments authored by the authenticated account. Only affects the comment you name; use delete_post for posts. WARNING: this cannot be undone — the content is removed, though the comment id remains.",
|
|
1839
|
+
annotations: {
|
|
1840
|
+
title: "Delete Comment",
|
|
1841
|
+
readOnlyHint: false,
|
|
1842
|
+
destructiveHint: true,
|
|
1843
|
+
idempotentHint: true,
|
|
1844
|
+
openWorldHint: true
|
|
1845
|
+
},
|
|
1846
|
+
parameters: z.object({ thing_id: z.string().describe("The comment to delete: a full thing id 't1_<id>' or just the base36 comment id '<id>' (the 't1_' prefix is added automatically). Must be a comment you authored.") }),
|
|
1378
1847
|
execute: async (args) => {
|
|
1379
1848
|
const client = unwrapClient();
|
|
1380
1849
|
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.");
|
|
@@ -1389,10 +1858,17 @@ The comment ${args.thing_id} has been permanently deleted from Reddit.
|
|
|
1389
1858
|
});
|
|
1390
1859
|
server.addTool({
|
|
1391
1860
|
name: "edit_post",
|
|
1392
|
-
description: "
|
|
1861
|
+
description: "Replace the body text of one of your own self-text posts. Mutating and idempotent (same text → same result); it overwrites the previous body. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and works only on self posts you authored — titles and link posts cannot be edited. Adds an \"edited\" marker. Use create_post to make a new post, or edit_comment for comments. WARNING: rapid edits may trigger spam detection; enable REDDIT_SAFE_MODE for protection.",
|
|
1862
|
+
annotations: {
|
|
1863
|
+
title: "Edit Post",
|
|
1864
|
+
readOnlyHint: false,
|
|
1865
|
+
destructiveHint: true,
|
|
1866
|
+
idempotentHint: true,
|
|
1867
|
+
openWorldHint: true
|
|
1868
|
+
},
|
|
1393
1869
|
parameters: z.object({
|
|
1394
|
-
thing_id: z.string().describe("The full
|
|
1395
|
-
new_text: z.string().describe("
|
|
1870
|
+
thing_id: z.string().describe("The post to edit: a full thing id 't3_<id>' or just the base36 post id '<id>' (the 't3_' prefix is added automatically). Must be a self-text post you authored."),
|
|
1871
|
+
new_text: z.string().describe("Replacement body text; fully overwrites the current body. Reddit markdown supported.")
|
|
1396
1872
|
}),
|
|
1397
1873
|
execute: async (args) => {
|
|
1398
1874
|
const client = unwrapClient();
|
|
@@ -1412,10 +1888,17 @@ The post ${args.thing_id} has been updated with your new content.
|
|
|
1412
1888
|
});
|
|
1413
1889
|
server.addTool({
|
|
1414
1890
|
name: "edit_comment",
|
|
1415
|
-
description: "
|
|
1891
|
+
description: "Replace the text of one of your own comments. Mutating and idempotent (same text → same result); it overwrites the previous content. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and works only on comments you authored. Adds an \"edited\" marker. Use reply_to_post to add a new comment, or edit_post for posts. WARNING: rapid edits may trigger spam detection; enable REDDIT_SAFE_MODE for protection.",
|
|
1892
|
+
annotations: {
|
|
1893
|
+
title: "Edit Comment",
|
|
1894
|
+
readOnlyHint: false,
|
|
1895
|
+
destructiveHint: true,
|
|
1896
|
+
idempotentHint: true,
|
|
1897
|
+
openWorldHint: true
|
|
1898
|
+
},
|
|
1416
1899
|
parameters: z.object({
|
|
1417
|
-
thing_id: z.string().describe("The full
|
|
1418
|
-
new_text: z.string().describe("
|
|
1900
|
+
thing_id: z.string().describe("The comment to edit: a full thing id 't1_<id>' or just the base36 comment id '<id>' (the 't1_' prefix is added automatically). Must be a comment you authored."),
|
|
1901
|
+
new_text: z.string().describe("Replacement comment text; fully overwrites the current content. Reddit markdown supported.")
|
|
1419
1902
|
}),
|
|
1420
1903
|
execute: async (args) => {
|
|
1421
1904
|
const client = unwrapClient();
|
|
@@ -1431,10 +1914,15 @@ The comment ${args.thing_id} has been updated with your new content.
|
|
|
1431
1914
|
});
|
|
1432
1915
|
server.addTool({
|
|
1433
1916
|
name: "get_post_comments",
|
|
1434
|
-
description: "Get
|
|
1917
|
+
description: "Get the comment thread for a post (by post id + subreddit), sorted best/top/new/controversial/old/qa. Read-only; works anonymously. Returns the post header plus threaded comments (author, OP/edited badges, score, body, nesting depth) up to `limit`. Long threads are truncated with 'load more' stubs — expand those with get_more_comments. Use get_reddit_post for just the post body, not the thread.",
|
|
1918
|
+
annotations: {
|
|
1919
|
+
title: "Get Post Comments",
|
|
1920
|
+
readOnlyHint: true,
|
|
1921
|
+
openWorldHint: true
|
|
1922
|
+
},
|
|
1435
1923
|
parameters: z.object({
|
|
1436
|
-
post_id: z.string().describe("
|
|
1437
|
-
subreddit: z.string().describe("The subreddit
|
|
1924
|
+
post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink (e.g. '1abc23'). With or without a t3_ prefix."),
|
|
1925
|
+
subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'movies')."),
|
|
1438
1926
|
sort: z.enum([
|
|
1439
1927
|
"best",
|
|
1440
1928
|
"top",
|
|
@@ -1442,8 +1930,8 @@ server.addTool({
|
|
|
1442
1930
|
"controversial",
|
|
1443
1931
|
"old",
|
|
1444
1932
|
"qa"
|
|
1445
|
-
]).default("best").describe("Comment
|
|
1446
|
-
limit: z.number().min(1).max(500).default(100).describe("Maximum
|
|
1933
|
+
]).default("best").describe("Comment ordering: 'best' (default), 'top', 'new', 'controversial', 'old', or 'qa' (Q&A)."),
|
|
1934
|
+
limit: z.number().min(1).max(500).default(100).describe("Maximum comments to return, 1–500 (default 100). Deeply nested replies may still be truncated as 'load more' stubs.")
|
|
1447
1935
|
}),
|
|
1448
1936
|
execute: async (args) => {
|
|
1449
1937
|
const client = unwrapClient();
|
|
@@ -1477,6 +1965,38 @@ ${comment.body}
|
|
|
1477
1965
|
});
|
|
1478
1966
|
}
|
|
1479
1967
|
});
|
|
1968
|
+
server.addTool({
|
|
1969
|
+
name: "get_more_comments",
|
|
1970
|
+
description: "Expand truncated 'load more comments' stubs in a thread. Read-only; works anonymously. Pass the post's link id and the comment ids from a 'more' node (surfaced by get_post_comments) to fetch those hidden comments; returns the expanded comments (author, body excerpt, score, link). Call get_post_comments first to obtain the thread and its 'more' node ids — do NOT invent ids.",
|
|
1971
|
+
annotations: {
|
|
1972
|
+
title: "Get More Comments",
|
|
1973
|
+
readOnlyHint: true,
|
|
1974
|
+
openWorldHint: true
|
|
1975
|
+
},
|
|
1976
|
+
parameters: z.object({
|
|
1977
|
+
link_id: z.string().describe("The parent post's link id (base36, with or without the t3_ prefix) that the stub belongs to."),
|
|
1978
|
+
comment_ids: z.array(z.string()).min(1).describe("Base36 comment ids to expand, taken from a 'more' node returned by get_post_comments (not arbitrary ids).")
|
|
1979
|
+
}),
|
|
1980
|
+
execute: async (args) => {
|
|
1981
|
+
return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {
|
|
1982
|
+
throw new Error(`Failed to expand comments: ${err.message}`);
|
|
1983
|
+
}, (comments) => {
|
|
1984
|
+
if (comments.length === 0) return "No additional comments were returned for those ids.";
|
|
1985
|
+
const commentList = comments.map((comment, index) => {
|
|
1986
|
+
const truncated = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
|
|
1987
|
+
const flags = [...comment.edited ? ["*(edited)*"] : [], ...comment.isSubmitter ? ["**OP**"] : []];
|
|
1988
|
+
return `### ${index + 1}. u/${comment.author} ${flags.join(" ")}
|
|
1989
|
+
> ${truncated}
|
|
1990
|
+
|
|
1991
|
+
- Score: ${comment.score.toLocaleString()}
|
|
1992
|
+
- Link: https://reddit.com${comment.permalink}`;
|
|
1993
|
+
}).join("\n\n");
|
|
1994
|
+
return `# Expanded Comments (${comments.length})
|
|
1995
|
+
|
|
1996
|
+
${commentList}`;
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
1480
2000
|
async function main() {
|
|
1481
2001
|
await setupRedditClient();
|
|
1482
2002
|
const useHttp = process.env.TRANSPORT_TYPE === "httpStream" || process.env.TRANSPORT_TYPE === "http";
|