dsh-plugin-discourse 0.0.0-stage → 1.0.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/index.js ADDED
@@ -0,0 +1,1238 @@
1
+ /**
2
+ * Discourse administration and agent tools for the DeepSeek Harness.
3
+ *
4
+ * One Host plugin registers model-facing tools over the Discourse REST API:
5
+ * reading topics and users, authoring posts, and running moderator/administrator
6
+ * operations. Every request carries the configured API key, and `readOnly`
7
+ * turns the plugin into an inspection-only surface.
8
+ *
9
+ * @module dsh-plugin-discourse
10
+ */
11
+
12
+ /** Loader row id used by this bundle. */
13
+ export const name = 'discourse'
14
+
15
+ /** The plugin needs the tool registry before `apply` runs. */
16
+ export const inject = ['tools']
17
+
18
+ /** Forum used when the deployment config names none. */
19
+ const DEFAULT_BASE_URL = 'https://meta.discourse.org'
20
+ /** Request deadline when the deployment config names none. */
21
+ const DEFAULT_TIMEOUT_MS = 20000
22
+ /** Default number of items in one listing. */
23
+ const DEFAULT_LIMIT = 12
24
+ /** Hard cap on items in one listing. */
25
+ const MAX_LIMIT = 50
26
+ /** Characters kept from one post body or search blurb. */
27
+ const MAX_BODY = 700
28
+ /** Hours a resolved category list stays trustworthy. */
29
+ const CATEGORY_TTL_MS = 5 * 60 * 1000
30
+
31
+ /** Per-tool output schema: an open object, because each tool projects its own shape. */
32
+ const OBJECT_OUTPUT = { type: 'object', additionalProperties: true }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Value helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Raise one operator-facing failure.
40
+ * @param {string} message - what the operator must fix or retry.
41
+ * @returns {never} always throws.
42
+ */
43
+ function fail(message) {
44
+ throw new Error(message)
45
+ }
46
+
47
+ /** Named HTML entities Discourse emits inside excerpts and post bodies. */
48
+ const NAMED_ENTITIES = {
49
+ hellip: '…',
50
+ mdash: '—',
51
+ ndash: '–',
52
+ rsquo: '’',
53
+ lsquo: '‘',
54
+ ldquo: '“',
55
+ rdquo: '”',
56
+ middot: '·',
57
+ bull: '•',
58
+ copy: '©',
59
+ reg: '®',
60
+ trade: '™',
61
+ }
62
+
63
+ /**
64
+ * Trim one value to a bounded string.
65
+ * @param {unknown} value - any model or API value.
66
+ * @param {number} max - maximum characters to keep.
67
+ * @returns {string} the clipped single-line-or-multiline string.
68
+ */
69
+ function clip(value, max = MAX_BODY) {
70
+ const text = String(value ?? '').replace(/\r\n/g, '\n').trim()
71
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
72
+ }
73
+
74
+ /**
75
+ * Read a required string argument.
76
+ * @param {unknown} value - candidate argument.
77
+ * @param {string} label - argument name for the diagnostic.
78
+ * @returns {string} the trimmed value.
79
+ */
80
+ function requiredString(value, label) {
81
+ if (typeof value !== 'string' || value.trim().length === 0) fail(`invalid arguments: \`${label}\` must be a non-empty string`)
82
+ return value.trim()
83
+ }
84
+
85
+ /**
86
+ * Read an optional string argument.
87
+ * @param {unknown} value - candidate argument.
88
+ * @returns {string | undefined} the trimmed value, or undefined when absent.
89
+ */
90
+ function optionalString(value) {
91
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
92
+ }
93
+
94
+ /**
95
+ * Read an integer argument, applying a default and a cap.
96
+ * @param {unknown} value - candidate argument.
97
+ * @param {number} fallback - value used when the argument is absent.
98
+ * @param {number} max - upper bound after defaults.
99
+ * @param {number} min - lower bound after defaults.
100
+ * @returns {number} the bounded integer.
101
+ */
102
+ function integer(value, fallback, max, min = 1) {
103
+ if (value === undefined || value === null) return fallback
104
+ if (typeof value !== 'number' || !Number.isInteger(value)) fail('invalid arguments: expected an integer')
105
+ return Math.min(max, Math.max(min, value))
106
+ }
107
+
108
+ /**
109
+ * Read a required integer argument.
110
+ * @param {unknown} value - candidate argument.
111
+ * @param {string} label - argument name for the diagnostic.
112
+ * @returns {number} the integer.
113
+ */
114
+ function requiredInteger(value, label) {
115
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) fail(`invalid arguments: \`${label}\` must be a positive integer`)
116
+ return value
117
+ }
118
+
119
+ /**
120
+ * Convert the rendered HTML Discourse returns into readable text.
121
+ * @param {unknown} html - cooked post HTML.
122
+ * @returns {string} plain text with paragraph breaks preserved.
123
+ */
124
+ function toPlainText(html) {
125
+ return String(html ?? '')
126
+ .replace(/<br\s*\/?>/gi, '\n')
127
+ .replace(/<\/(p|div|li|blockquote|h[1-6]|tr)>/gi, '\n')
128
+ .replace(/<[^>]+>/g, '')
129
+ .replace(/&nbsp;/g, ' ')
130
+ .replace(/&lt;/g, '<')
131
+ .replace(/&gt;/g, '>')
132
+ .replace(/&quot;/g, '"')
133
+ .replace(/&#39;/g, "'")
134
+ .replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
135
+ .replace(/&#x([0-9a-fA-F]+);/g, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)))
136
+ .replace(/&(hellip|mdash|ndash|rsquo|lsquo|ldquo|rdquo|middot|bull|copy|reg|trade);/g, (_match, name) => NAMED_ENTITIES[name] ?? _match)
137
+ .replace(/&amp;/g, '&')
138
+ .replace(/\n{3,}/g, '\n\n')
139
+ .trim()
140
+ }
141
+
142
+ /**
143
+ * Format one ISO timestamp as a calendar date.
144
+ * @param {unknown} value - ISO timestamp from the API.
145
+ * @returns {string} a `YYYY-MM-DD` prefix, or an empty string.
146
+ */
147
+ function day(value) {
148
+ const text = typeof value === 'string' ? value : ''
149
+ return text.length >= 10 ? text.slice(0, 10) : text
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Configuration
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Normalize the loader row config into one resolved client configuration.
158
+ *
159
+ * The API key may come from the row config or from `DISCOURSE_API_KEY`, so a
160
+ * shared profile file never needs to carry the secret itself.
161
+ *
162
+ * @param {unknown} raw - the loader row config object.
163
+ * @returns {{ baseUrl: string, apiKey: string, apiUsername: string, readOnly: boolean, timeoutMs: number, maxItems: number }} the resolved configuration.
164
+ */
165
+ function readConfig(raw) {
166
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
167
+ const declared = optionalString(source.baseUrl) ?? DEFAULT_BASE_URL
168
+ let baseUrl
169
+ try {
170
+ const parsed = new URL(declared)
171
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') fail('unsupported protocol')
172
+ baseUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
173
+ } catch {
174
+ fail(`invalid config: \`baseUrl\` must be an http(s) URL, got ${JSON.stringify(declared)}`)
175
+ }
176
+ const envKey = typeof process !== 'undefined' ? process.env?.DISCOURSE_API_KEY : undefined
177
+ const envUser = typeof process !== 'undefined' ? process.env?.DISCOURSE_API_USERNAME : undefined
178
+ const apiKey = optionalString(source.apiKey) ?? optionalString(envKey) ?? ''
179
+ const apiUsername = optionalString(source.apiUsername) ?? optionalString(envUser) ?? 'system'
180
+ const timeoutMs = typeof source.timeoutMs === 'number' && Number.isFinite(source.timeoutMs)
181
+ ? Math.min(120000, Math.max(1000, Math.round(source.timeoutMs)))
182
+ : DEFAULT_TIMEOUT_MS
183
+ const maxItems = typeof source.maxItems === 'number' && Number.isFinite(source.maxItems)
184
+ ? Math.min(MAX_LIMIT, Math.max(1, Math.round(source.maxItems)))
185
+ : DEFAULT_LIMIT
186
+ return {
187
+ baseUrl,
188
+ apiKey,
189
+ apiUsername,
190
+ readOnly: source.readOnly === true,
191
+ timeoutMs,
192
+ maxItems,
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // HTTP client
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * Build the request client shared by every tool of one activation.
202
+ * @param {ReturnType<typeof readConfig>} config - resolved configuration.
203
+ * @returns {{ call: Function, writable: Function, categories: Function, resolveCategory: Function, config: object }} the client.
204
+ */
205
+ function createClient(config) {
206
+ const cache = { at: 0, entries: [] }
207
+
208
+ /**
209
+ * Issue one Discourse request and decode its JSON body.
210
+ * @param {string} method - HTTP method.
211
+ * @param {string} path - absolute path such as `/latest.json`.
212
+ * @param {{ query?: Record<string, unknown>, body?: unknown, signal?: AbortSignal }} [options] - query values, JSON body, and caller cancellation.
213
+ * @returns {Promise<any>} the decoded body, or text for non-JSON responses.
214
+ */
215
+ async function call(method, path, options = {}) {
216
+ const query = options.query ?? {}
217
+ const search = new URLSearchParams()
218
+ for (const [key, value] of Object.entries(query)) {
219
+ if (value === undefined || value === null || value === '') continue
220
+ search.set(key, String(value))
221
+ }
222
+ const suffix = search.size > 0 ? `?${search.toString()}` : ''
223
+ const url = `${config.baseUrl}${path}${suffix}`
224
+
225
+ const headers = { Accept: 'application/json' }
226
+ if (config.apiKey.length > 0) {
227
+ headers['Api-Key'] = config.apiKey
228
+ headers['Api-Username'] = config.apiUsername
229
+ }
230
+ if (options.body !== undefined) headers['Content-Type'] = 'application/json'
231
+
232
+ const deadline = AbortSignal.timeout(config.timeoutMs)
233
+ const signal = options.signal && typeof AbortSignal.any === 'function'
234
+ ? AbortSignal.any([options.signal, deadline])
235
+ : deadline
236
+
237
+ let response
238
+ try {
239
+ response = await fetch(url, {
240
+ method,
241
+ headers,
242
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
243
+ signal,
244
+ redirect: 'follow',
245
+ })
246
+ } catch (error) {
247
+ const reason = error instanceof Error ? error.message : String(error)
248
+ if (options.signal?.aborted) fail(`Discourse request cancelled: ${method} ${path}`)
249
+ fail(`Discourse request failed: ${method} ${path} (${reason})`)
250
+ }
251
+
252
+ const text = await response.text()
253
+ let payload
254
+ try {
255
+ payload = text.length === 0 ? {} : JSON.parse(text)
256
+ } catch {
257
+ payload = text
258
+ }
259
+
260
+ if (!response.ok) {
261
+ const detail = describeError(payload)
262
+ const hint = response.status === 403
263
+ ? ' The API key needs an admin-scoped key for this endpoint.'
264
+ : response.status === 404
265
+ ? ' Check that the forum URL and the target id are correct.'
266
+ : ''
267
+ fail(`Discourse ${method} ${path} failed (HTTP ${response.status}): ${detail}${hint}`)
268
+ }
269
+ return payload
270
+ }
271
+
272
+ /**
273
+ * Extract the most useful message from a Discourse error body.
274
+ * @param {unknown} payload - decoded error body.
275
+ * @returns {string} a bounded diagnostic.
276
+ */
277
+ function describeError(payload) {
278
+ if (payload !== null && typeof payload === 'object') {
279
+ const errors = payload.errors
280
+ if (Array.isArray(errors) && errors.length > 0) return clip(errors.join('; '), 400)
281
+ if (typeof payload.error_type === 'string' && payload.error_type.length > 0) return `${payload.error_type}: ${clip(payload.error, 300)}`
282
+ if (typeof payload.error === 'string') return clip(payload.error, 400)
283
+ }
284
+ return clip(payload, 400) || 'no response body'
285
+ }
286
+
287
+ /**
288
+ * Refuse a mutating operation when the deployment is inspection-only.
289
+ * @param {string} operation - the human-facing operation name.
290
+ * @returns {void}
291
+ */
292
+ function writable(operation) {
293
+ if (config.readOnly) fail(`readOnly is enabled for this Discourse plugin, so ${operation} is unavailable. Disable readOnly in the plugin config to allow writes.`)
294
+ }
295
+
296
+ /**
297
+ * Load the forum category directory, reusing a short-lived cache.
298
+ * @param {AbortSignal} [signal] - caller cancellation.
299
+ * @returns {Promise<Array<Record<string, any>>>} flattened categories including subcategories.
300
+ */
301
+ async function categories(signal) {
302
+ if (cache.entries.length > 0 && Date.now() - cache.at < CATEGORY_TTL_MS) return cache.entries
303
+ const payload = await call('GET', '/categories.json', { query: { include_subcategories: 'true' }, signal })
304
+ const roots = payload?.category_list?.categories ?? payload?.categories ?? []
305
+ const entries = []
306
+ for (const root of Array.isArray(roots) ? roots : []) {
307
+ entries.push(root)
308
+ const children = root?.subcategory_list ?? root?.subcategories ?? []
309
+ for (const child of Array.isArray(children) ? children : []) entries.push(child)
310
+ }
311
+ cache.entries = entries
312
+ cache.at = Date.now()
313
+ return entries
314
+ }
315
+
316
+ /**
317
+ * Resolve a category id or slug to the pair used by the category listing route.
318
+ * @param {unknown} input - a numeric id or a slug/name.
319
+ * @param {AbortSignal} [signal] - caller cancellation.
320
+ * @returns {Promise<{ id: number, slug: string }>} the resolved category.
321
+ */
322
+ async function resolveCategory(input, signal) {
323
+ const raw = requiredString(input, 'category')
324
+ if (/^\d+$/.test(raw)) return { id: Number(raw), slug: 'category' }
325
+ const entries = await categories(signal)
326
+ const needle = raw.replace(/^\/+|\/+$/g, '').toLowerCase()
327
+ const found = entries.find((entry) => String(entry?.slug ?? '').toLowerCase() === needle)
328
+ ?? entries.find((entry) => String(entry?.name ?? '').toLowerCase() === needle)
329
+ if (found === undefined) {
330
+ const known = entries.slice(0, 25).map((entry) => entry?.slug).filter(Boolean).join(', ')
331
+ fail(`unknown category "${raw}"; known slugs include: ${known || '(none)'}`)
332
+ }
333
+ return { id: Number(found.id), slug: String(found.slug) }
334
+ }
335
+
336
+ return { call, writable, categories, resolveCategory, config }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Projections
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Project one topic listing row onto the fields a reader needs.
345
+ * @param {Record<string, any>} topic - raw `topic_list.topics` entry.
346
+ * @returns {Record<string, unknown>} the compact topic.
347
+ */
348
+ function projectTopic(topic) {
349
+ return {
350
+ id: topic?.id,
351
+ title: topic?.title,
352
+ slug: topic?.slug,
353
+ categoryId: topic?.category_id,
354
+ postsCount: topic?.posts_count,
355
+ replyCount: topic?.reply_count,
356
+ views: topic?.views,
357
+ likeCount: topic?.like_count,
358
+ createdAt: day(topic?.created_at),
359
+ lastPostedAt: day(topic?.last_posted_at),
360
+ pinned: topic?.pinned === true,
361
+ closed: topic?.closed === true,
362
+ tags: Array.isArray(topic?.tags) ? topic.tags.map((tag) => tag?.name ?? tag).filter(Boolean) : [],
363
+ excerpt: clip(toPlainText(topic?.excerpt), 280),
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Project one post onto the fields a reader needs, preferring the raw markup.
369
+ * @param {Record<string, any>} post - raw `post_stream.posts` entry.
370
+ * @param {number} maxBody - characters kept from the body.
371
+ * @returns {Record<string, unknown>} the compact post.
372
+ */
373
+ function projectPost(post, maxBody) {
374
+ const body = typeof post?.raw === 'string' && post.raw.length > 0 ? post.raw : toPlainText(post?.cooked)
375
+ return {
376
+ id: post?.id,
377
+ postNumber: post?.post_number,
378
+ username: post?.username,
379
+ createdAt: day(post?.created_at),
380
+ replyToPostNumber: post?.reply_to_post_number ?? null,
381
+ body: clip(body, maxBody),
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Project one user record onto the fields an operator or agent needs.
387
+ * @param {Record<string, any>} user - raw `user` object.
388
+ * @param {Record<string, any>} [summary] - optional summary statistics.
389
+ * @returns {Record<string, unknown>} the compact user.
390
+ */
391
+ function projectUser(user, summary) {
392
+ const stats = summary?.user_summary ?? summary ?? {}
393
+ return {
394
+ id: user?.id,
395
+ username: user?.username,
396
+ name: user?.name ?? null,
397
+ trustLevel: user?.trust_level,
398
+ admin: user?.admin === true,
399
+ moderator: user?.moderator === true,
400
+ suspended: user?.suspended === true || (typeof user?.suspended_till === 'string' && user.suspended_till.length > 0),
401
+ suspendedTill: user?.suspended_till ?? null,
402
+ silenced: user?.silenced === true || (typeof user?.silenced_till === 'string' && user.silenced_till.length > 0),
403
+ silencedTill: user?.silenced_till ?? null,
404
+ createdAt: day(user?.created_at),
405
+ lastSeenAt: day(user?.last_seen_at),
406
+ postCount: stats?.post_count ?? user?.post_count,
407
+ topicCount: stats?.topic_count ?? user?.topic_count,
408
+ likesGiven: stats?.likes_given ?? user?.likes_given,
409
+ likesReceived: stats?.likes_received ?? user?.likes_received,
410
+ daysVisited: stats?.days_visited ?? user?.days_visited,
411
+ bio: clip(toPlainText(user?.bio_cooked ?? user?.bio_raw), 300),
412
+ }
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Tool definitions
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Build every model-facing tool for one client.
421
+ * @param {ReturnType<typeof createClient>} client - the request client.
422
+ * @returns {Array<Record<string, any>>} registry-ready tool definitions.
423
+ */
424
+ function buildTools(client) {
425
+ const tools = []
426
+ const limit = (value) => integer(value, client.config.maxItems, MAX_LIMIT)
427
+ const page = (value) => integer(value, 1, 10000)
428
+
429
+ /**
430
+ * Register one tool through the shared output shape.
431
+ * @param {Record<string, any>} tool - name, description, parameters, execute, and render.
432
+ * @returns {void}
433
+ */
434
+ const add = (tool) => {
435
+ tools.push({
436
+ name: tool.name,
437
+ description: tool.description,
438
+ parameters: tool.parameters,
439
+ output: { schema: OBJECT_OUTPUT, render: tool.render },
440
+ ...(tool.presentCall === undefined ? {} : { presentCall: tool.presentCall }),
441
+ execute: tool.execute,
442
+ })
443
+ }
444
+
445
+ // --- Reading -------------------------------------------------------------
446
+
447
+ add({
448
+ name: 'discourse_search',
449
+ description: 'Search a Discourse forum for topics, posts, and users. Returns matching topics and post blurbs with ids for follow-up reads.',
450
+ parameters: {
451
+ type: 'object',
452
+ additionalProperties: false,
453
+ required: ['q'],
454
+ properties: {
455
+ q: { type: 'string', description: 'Discourse search terms. Supports operators such as `#category`, `@username`, `in:title`, and `after:2025-01-01`.' },
456
+ page: { type: 'integer', description: 'Result page, starting at 1.', default: 1 },
457
+ limit: { type: 'integer', description: 'Maximum topics and posts to return, 1-50.', default: 10 },
458
+ },
459
+ },
460
+ async execute(args, exec) {
461
+ const q = requiredString(args?.q, 'q')
462
+ const payload = await client.call('GET', '/search.json', {
463
+ query: { q, page: page(args?.page) },
464
+ signal: exec?.signal,
465
+ })
466
+ const topics = Array.isArray(payload?.topics) ? payload.topics.slice(0, limit(args?.limit)) : []
467
+ const posts = Array.isArray(payload?.posts) ? payload.posts.slice(0, limit(args?.limit)) : []
468
+ const users = Array.isArray(payload?.users) ? payload.users.slice(0, 10) : []
469
+ const grouped = payload?.grouped_search_result ?? {}
470
+ return {
471
+ term: q,
472
+ moreResults: grouped?.more_full_page_results === true || grouped?.more_posts === true,
473
+ topics: topics.map(projectTopic),
474
+ posts: posts.map((post) => ({
475
+ id: post?.id,
476
+ topicId: post?.topic_id,
477
+ postNumber: post?.post_number,
478
+ username: post?.username,
479
+ createdAt: day(post?.created_at),
480
+ blurb: clip(toPlainText(post?.blurb), 280),
481
+ })),
482
+ users: users.map((user) => ({ id: user?.id, username: user?.username, name: user?.name ?? null })),
483
+ }
484
+ },
485
+ render(_args, value) {
486
+ const lines = [`Search results for "${value.term}"`]
487
+ if (value.topics.length > 0) {
488
+ lines.push('', 'Topics:')
489
+ for (const topic of value.topics) {
490
+ lines.push(`- #${topic.id} ${topic.title} (${topic.replyCount ?? 0} replies, ${topic.views ?? 0} views, ${topic.createdAt})`)
491
+ if (topic.excerpt) lines.push(` ${topic.excerpt}`)
492
+ }
493
+ }
494
+ if (value.posts.length > 0) {
495
+ lines.push('', 'Posts:')
496
+ for (const post of value.posts) {
497
+ lines.push(`- @${post.username} in topic ${post.topicId} (post ${post.postNumber}, ${post.createdAt}): ${post.blurb}`)
498
+ }
499
+ }
500
+ if (value.users.length > 0) {
501
+ lines.push('', `Users: ${value.users.map((user) => `@${user.username}`).join(', ')}`)
502
+ }
503
+ if (value.topics.length === 0 && value.posts.length === 0) lines.push('', 'No matching topics or posts.')
504
+ if (value.moreResults) lines.push('', 'More results are available on a later page.')
505
+ return [{ type: 'text', text: lines.join('\n') }]
506
+ },
507
+ presentCall(args) {
508
+ return { card: 'generic', title: `Search Discourse: ${clip(args?.q, 60)}`, kind: 'search' }
509
+ },
510
+ })
511
+
512
+ add({
513
+ name: 'discourse_topics',
514
+ description: 'List forum topics by feed or category. Returns topic ids, titles, reply and view counts, tags, and excerpts.',
515
+ parameters: {
516
+ type: 'object',
517
+ additionalProperties: false,
518
+ properties: {
519
+ filter: { type: 'string', enum: ['latest', 'new', 'unread', 'top'], description: 'Which feed to read. Ignored when `category` is set.', default: 'latest' },
520
+ period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly', 'all'], description: 'Ranking window, used only with `filter: top`.', default: 'weekly' },
521
+ category: { type: 'string', description: 'Category slug or numeric id. Lists that category instead of a global feed.' },
522
+ page: { type: 'integer', description: 'Result page, starting at 1.', default: 1 },
523
+ limit: { type: 'integer', description: 'Topics to return, 1-50.', default: 15 },
524
+ },
525
+ },
526
+ async execute(args, exec) {
527
+ const feed = typeof args?.filter === 'string' ? args.filter : 'latest'
528
+ const category = optionalString(args?.category)
529
+ const pageNumber = page(args?.page)
530
+ const perPage = limit(args?.limit)
531
+ let path = '/latest.json'
532
+ let scope = feed
533
+ if (category !== undefined) {
534
+ const resolved = await client.resolveCategory(category, exec?.signal)
535
+ path = `/c/${resolved.slug}/${resolved.id}.json`
536
+ scope = `category ${resolved.slug}`
537
+ } else if (feed === 'top') {
538
+ path = '/top.json'
539
+ } else {
540
+ path = `/${feed}.json`
541
+ }
542
+ const query = { page: pageNumber, per_page: perPage }
543
+ if (category === undefined && feed === 'top') query.period = typeof args?.period === 'string' ? args.period : 'weekly'
544
+ const payload = await client.call('GET', path, { query, signal: exec?.signal })
545
+ const rows = payload?.topic_list?.topics ?? []
546
+ const topics = Array.isArray(rows) ? rows.map(projectTopic) : []
547
+ return {
548
+ scope,
549
+ page: pageNumber,
550
+ topics,
551
+ more: typeof payload?.topic_list?.more_topics_url === 'string' && payload.topic_list.more_topics_url.length > 0,
552
+ }
553
+ },
554
+ render(_args, value) {
555
+ const lines = [`${value.scope} (page ${value.page}, ${value.topics.length} topics)`]
556
+ for (const topic of value.topics) {
557
+ lines.push(`- #${topic.id} ${topic.title} [category ${topic.categoryId}] ${topic.replyCount ?? 0} replies, ${topic.views ?? 0} views, last post ${topic.lastPostedAt}`)
558
+ if (topic.excerpt) lines.push(` ${topic.excerpt}`)
559
+ }
560
+ if (value.topics.length === 0) lines.push('No topics on this page.')
561
+ if (value.more) lines.push('', 'A further page is available.')
562
+ return [{ type: 'text', text: lines.join('\n') }]
563
+ },
564
+ presentCall(args) {
565
+ return { card: 'generic', title: `Discourse topics: ${clip(args?.category ?? args?.filter ?? 'latest', 40)}`, kind: 'read' }
566
+ },
567
+ })
568
+
569
+ add({
570
+ name: 'discourse_topic',
571
+ description: 'Read one topic with its posts. Returns the title, category, tags, and the requested slice of posts; use `page` to read later posts.',
572
+ parameters: {
573
+ type: 'object',
574
+ additionalProperties: false,
575
+ required: ['topic'],
576
+ properties: {
577
+ topic: { type: 'string', description: 'Topic id, or any topic URL or `/t/slug/1234` path.' },
578
+ page: { type: 'integer', description: 'Post chunk page, starting at 1.', default: 1 },
579
+ limit: { type: 'integer', description: 'Posts to return, 1-40.', default: 12 },
580
+ },
581
+ },
582
+ async execute(args, exec) {
583
+ const id = extractTopicId(args?.topic)
584
+ const payload = await client.call('GET', `/t/${id}.json`, {
585
+ query: { page: page(args?.page) },
586
+ signal: exec?.signal,
587
+ })
588
+ const posts = Array.isArray(payload?.post_stream?.posts) ? payload.post_stream.posts : []
589
+ const maxBody = 900
590
+ return {
591
+ id: payload?.id ?? id,
592
+ title: payload?.title,
593
+ slug: payload?.slug,
594
+ categoryId: payload?.category_id,
595
+ tags: Array.isArray(payload?.tags) ? payload.tags.map((tag) => tag?.name ?? tag).filter(Boolean) : [],
596
+ postsCount: payload?.posts_count,
597
+ views: payload?.views,
598
+ likeCount: payload?.like_count,
599
+ createdAt: day(payload?.created_at),
600
+ lastPostedAt: day(payload?.last_posted_at),
601
+ closed: payload?.closed === true,
602
+ archived: payload?.archived === true,
603
+ posts: posts.slice(0, integer(args?.limit, 12, 40)).map((post) => projectPost(post, maxBody)),
604
+ totalPostsInChunk: posts.length,
605
+ }
606
+ },
607
+ render(_args, value) {
608
+ const lines = [`Topic #${value.id}: ${value.title}`]
609
+ const flags = [value.closed ? 'closed' : '', value.archived ? 'archived' : ''].filter(Boolean).join(', ')
610
+ lines.push(`category ${value.categoryId}, ${value.postsCount ?? '?'} posts, ${value.views ?? 0} views, created ${value.createdAt}${flags ? `, ${flags}` : ''}`)
611
+ if (value.tags.length > 0) lines.push(`tags: ${value.tags.join(', ')}`)
612
+ for (const post of value.posts) {
613
+ lines.push('', `#${post.postNumber} @${post.username} (${post.createdAt})${post.replyToPostNumber ? ` replying to #${post.replyToPostNumber}` : ''}`)
614
+ lines.push(post.body)
615
+ }
616
+ if (value.posts.length === 0) lines.push('', 'No posts on this page.')
617
+ return [{ type: 'text', text: lines.join('\n') }]
618
+ },
619
+ presentCall(args) {
620
+ return { card: 'generic', title: `Read Discourse topic ${clip(args?.topic, 40)}`, kind: 'read' }
621
+ },
622
+ })
623
+
624
+ add({
625
+ name: 'discourse_categories',
626
+ description: 'List forum categories with ids, slugs, colors, and topic counts, including subcategories.',
627
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
628
+ async execute(_args, exec) {
629
+ const entries = await client.categories(exec?.signal)
630
+ return {
631
+ categories: entries.slice(0, 100).map((category) => ({
632
+ id: category?.id,
633
+ name: category?.name,
634
+ slug: category?.slug,
635
+ parentId: category?.parent_category_id ?? null,
636
+ topicCount: category?.topic_count,
637
+ color: category?.color,
638
+ description: clip(toPlainText(category?.description_text ?? category?.description), 200),
639
+ })),
640
+ total: entries.length,
641
+ }
642
+ },
643
+ render(_args, value) {
644
+ const lines = [`${value.total} categories`]
645
+ for (const category of value.categories) {
646
+ lines.push(`- ${category.id} ${category.name} (/${category.slug})${category.parentId ? ` in parent ${category.parentId}` : ''}: ${category.topicCount ?? 0} topics`)
647
+ if (category.description) lines.push(` ${category.description}`)
648
+ }
649
+ return [{ type: 'text', text: lines.join('\n') }]
650
+ },
651
+ presentCall() {
652
+ return { card: 'generic', title: 'List Discourse categories', kind: 'read' }
653
+ },
654
+ })
655
+
656
+ add({
657
+ name: 'discourse_user',
658
+ description: 'Read one forum user profile with trust level, roles, suspension state, activity counts, and bio.',
659
+ parameters: {
660
+ type: 'object',
661
+ additionalProperties: false,
662
+ required: ['username'],
663
+ properties: {
664
+ username: { type: 'string', description: 'Forum username without the leading `@`.' },
665
+ },
666
+ },
667
+ async execute(args, exec) {
668
+ const username = requiredString(args?.username, 'username').replace(/^@/, '')
669
+ const payload = await client.call('GET', `/u/${encodeURIComponent(username)}.json`, { signal: exec?.signal })
670
+ const user = payload?.user ?? payload
671
+ let summary
672
+ try {
673
+ summary = await client.call('GET', `/u/${encodeURIComponent(username)}/summary.json`, { signal: exec?.signal })
674
+ } catch {
675
+ summary = undefined
676
+ }
677
+ return projectUser(user, summary)
678
+ },
679
+ render(_args, value) {
680
+ const roles = [value.admin ? 'admin' : '', value.moderator ? 'moderator' : ''].filter(Boolean).join(', ')
681
+ const lines = [`@${value.username}${value.name ? ` (${value.name})` : ''} — id ${value.id}, trust level ${value.trustLevel ?? '?'}${roles ? `, ${roles}` : ''}`]
682
+ lines.push(`joined ${value.createdAt}, last seen ${value.lastSeenAt}`)
683
+ lines.push(`posts ${value.postCount ?? '?'}, topics ${value.topicCount ?? '?'}, likes given ${value.likesGiven ?? '?'}, likes received ${value.likesReceived ?? '?'}`)
684
+ if (value.suspended) lines.push(`suspended until ${value.suspendedTill ?? 'unknown'}`)
685
+ if (value.silenced) lines.push(`silenced until ${value.silencedTill ?? 'unknown'}`)
686
+ if (value.bio) lines.push('', value.bio)
687
+ return [{ type: 'text', text: lines.join('\n') }]
688
+ },
689
+ presentCall(args) {
690
+ return { card: 'generic', title: `Discourse user @${clip(args?.username, 40)}`, kind: 'read' }
691
+ },
692
+ })
693
+
694
+ // --- Authoring -----------------------------------------------------------
695
+
696
+ add({
697
+ name: 'discourse_create_topic',
698
+ description: 'Create a new forum topic. Returns the new topic and post ids plus the topic URL.',
699
+ parameters: {
700
+ type: 'object',
701
+ additionalProperties: false,
702
+ required: ['title', 'raw'],
703
+ properties: {
704
+ title: { type: 'string', description: 'Topic title.' },
705
+ raw: { type: 'string', description: 'Topic body in Markdown.' },
706
+ category: { type: 'string', description: 'Category slug or numeric id. Defaults to the forum default when omitted.' },
707
+ tags: { type: 'array', description: 'Tag names to apply.', items: { type: 'string' } },
708
+ },
709
+ },
710
+ async execute(args, exec) {
711
+ client.writable('creating a topic')
712
+ const body = { title: requiredString(args?.title, 'title'), raw: requiredString(args?.raw, 'raw') }
713
+ if (optionalString(args?.category) !== undefined) {
714
+ const resolved = await client.resolveCategory(args.category, exec?.signal)
715
+ body.category = resolved.id
716
+ }
717
+ if (Array.isArray(args?.tags) && args.tags.length > 0) body.tags = args.tags.map((tag) => String(tag)).slice(0, 10)
718
+ const payload = await client.call('POST', '/posts.json', { body, signal: exec?.signal })
719
+ const topicId = payload?.topic_id
720
+ const slug = payload?.topic_slug
721
+ return {
722
+ topicId,
723
+ postId: payload?.id,
724
+ postNumber: payload?.post_number,
725
+ title: body.title,
726
+ url: topicId === undefined ? undefined : slug ? `${client.config.baseUrl}/t/${slug}/${topicId}` : `${client.config.baseUrl}/t/${topicId}`,
727
+ }
728
+ },
729
+ render(_args, value) {
730
+ return [{ type: 'text', text: `Created topic #${value.topicId} with post #${value.postId}.\n${value.url ?? ''}` }]
731
+ },
732
+ presentCall(args) {
733
+ return { card: 'generic', title: `Create topic: ${clip(args?.title, 60)}`, kind: 'edit' }
734
+ },
735
+ })
736
+
737
+ add({
738
+ name: 'discourse_reply',
739
+ description: 'Append a reply to an existing topic. Returns the new post id, post number, and URL.',
740
+ parameters: {
741
+ type: 'object',
742
+ additionalProperties: false,
743
+ required: ['topic', 'raw'],
744
+ properties: {
745
+ topic: { type: 'string', description: 'Topic id, or any topic URL or `/t/slug/1234` path.' },
746
+ raw: { type: 'string', description: 'Reply body in Markdown.' },
747
+ replyToPostNumber: { type: 'integer', description: 'Post number inside the topic to reply to directly.' },
748
+ },
749
+ },
750
+ async execute(args, exec) {
751
+ client.writable('replying to a topic')
752
+ const topicId = extractTopicId(args?.topic)
753
+ const body = { topic_id: topicId, raw: requiredString(args?.raw, 'raw') }
754
+ if (typeof args?.replyToPostNumber === 'number') body.reply_to_post_number = requiredInteger(args.replyToPostNumber, 'replyToPostNumber')
755
+ const payload = await client.call('POST', '/posts.json', { body, signal: exec?.signal })
756
+ return {
757
+ topicId,
758
+ postId: payload?.id,
759
+ postNumber: payload?.post_number,
760
+ url: `${client.config.baseUrl}/t/${topicId}/${payload?.post_number ?? 1}`,
761
+ }
762
+ },
763
+ render(_args, value) {
764
+ return [{ type: 'text', text: `Replied to topic #${value.topicId} as post #${value.postId} (#${value.postNumber}).\n${value.url}` }]
765
+ },
766
+ presentCall(args) {
767
+ return { card: 'generic', title: `Reply in topic ${clip(args?.topic, 30)}`, kind: 'edit' }
768
+ },
769
+ })
770
+
771
+ add({
772
+ name: 'discourse_update_post',
773
+ description: 'Replace the body of an existing post. Returns the post id and its new version.',
774
+ parameters: {
775
+ type: 'object',
776
+ additionalProperties: false,
777
+ required: ['postId', 'raw'],
778
+ properties: {
779
+ postId: { type: 'integer', description: 'Numeric post id.' },
780
+ raw: { type: 'string', description: 'Replacement body in Markdown.' },
781
+ editReason: { type: 'string', description: 'Optional edit reason shown in the post revision history.' },
782
+ },
783
+ },
784
+ async execute(args, exec) {
785
+ client.writable('editing a post')
786
+ const postId = requiredInteger(args?.postId, 'postId')
787
+ const post = { raw: requiredString(args?.raw, 'raw') }
788
+ if (optionalString(args?.editReason) !== undefined) post.edit_reason = optionalString(args.editReason)
789
+ const payload = await client.call('PUT', `/posts/${postId}.json`, { body: { post }, signal: exec?.signal })
790
+ return {
791
+ postId,
792
+ version: payload?.version,
793
+ updatedAt: day(payload?.updated_at),
794
+ }
795
+ },
796
+ render(_args, value) {
797
+ return [{ type: 'text', text: `Updated post #${value.postId} to version ${value.version ?? '?'} (${value.updatedAt}).` }]
798
+ },
799
+ presentCall(args) {
800
+ return { card: 'generic', title: `Edit post #${args?.postId ?? '?'}`, kind: 'edit' }
801
+ },
802
+ })
803
+
804
+ add({
805
+ name: 'discourse_flag_post',
806
+ description: 'Flag a post for moderator attention. Returns the flag type recorded against the post.',
807
+ parameters: {
808
+ type: 'object',
809
+ additionalProperties: false,
810
+ required: ['postId', 'flag'],
811
+ properties: {
812
+ postId: { type: 'integer', description: 'Numeric post id.' },
813
+ flag: { type: 'string', enum: ['spam', 'inappropriate', 'off_topic', 'notify_user', 'notify_moderators'], description: 'Reason to flag the post.' },
814
+ message: { type: 'string', description: 'Private note for moderators, required by `notify_user` and `notify_moderators`.' },
815
+ },
816
+ },
817
+ async execute(args, exec) {
818
+ client.writable('flagging a post')
819
+ const postId = requiredInteger(args?.postId, 'postId')
820
+ const flag = requiredString(args?.flag, 'flag')
821
+ const actionType = FLAG_TYPES[flag]
822
+ if (actionType === undefined) fail(`invalid arguments: \`flag\` must be one of ${Object.keys(FLAG_TYPES).join(', ')}`)
823
+ const body = { id: postId, post_action_type_id: actionType, flag_topic: false }
824
+ const message = optionalString(args?.message)
825
+ if (message !== undefined) body.message = message
826
+ await client.call('POST', '/post_actions.json', { body, signal: exec?.signal })
827
+ return { postId, flag, actionType }
828
+ },
829
+ render(_args, value) {
830
+ return [{ type: 'text', text: `Flagged post #${value.postId} as ${value.flag}.` }]
831
+ },
832
+ presentCall(args) {
833
+ return { card: 'generic', title: `Flag post #${args?.postId ?? '?'}`, kind: 'other' }
834
+ },
835
+ })
836
+
837
+ // --- Moderation ----------------------------------------------------------
838
+
839
+ add({
840
+ name: 'discourse_review_queue',
841
+ description: 'List the moderator review queue. Returns flagged posts and users awaiting a decision, with the ids used by review actions.',
842
+ parameters: {
843
+ type: 'object',
844
+ additionalProperties: false,
845
+ properties: {
846
+ status: { type: 'string', enum: ['pending', 'reviewed', 'all'], description: 'Which reviewables to list.', default: 'pending' },
847
+ limit: { type: 'integer', description: 'Items to return, 1-50.', default: 20 },
848
+ },
849
+ },
850
+ async execute(args, exec) {
851
+ const status = typeof args?.status === 'string' ? args.status : 'pending'
852
+ const payload = await client.call('GET', '/review.json', { query: { status }, signal: exec?.signal })
853
+ const rows = Array.isArray(payload?.reviewables) ? payload.reviewables : Array.isArray(payload) ? payload : []
854
+ const reviewables = rows.slice(0, limit(args?.limit)).map((item) => ({
855
+ id: item?.id,
856
+ type: item?.type,
857
+ status: item?.status,
858
+ createdAt: day(item?.created_at),
859
+ topicId: item?.topic?.id ?? item?.target?.topic_id ?? null,
860
+ title: item?.topic?.title ?? item?.payload?.title ?? null,
861
+ username: item?.target?.username ?? item?.payload?.username ?? null,
862
+ excerpt: clip(toPlainText(item?.payload?.cooked ?? item?.target?.excerpt), 240),
863
+ }))
864
+ return { status, reviewables, total: rows.length }
865
+ },
866
+ render(_args, value) {
867
+ const lines = [`Review queue (${value.status}): ${value.total} items`]
868
+ for (const item of value.reviewables) {
869
+ lines.push(`- #${item.id} ${item.type} (${item.status}, ${item.createdAt})${item.username ? ` by @${item.username}` : ''}${item.topicId ? ` in topic ${item.topicId}` : ''}`)
870
+ if (item.title) lines.push(` ${item.title}`)
871
+ if (item.excerpt) lines.push(` ${item.excerpt}`)
872
+ }
873
+ if (value.reviewables.length === 0) lines.push('Nothing is waiting for review.')
874
+ return [{ type: 'text', text: lines.join('\n') }]
875
+ },
876
+ presentCall() {
877
+ return { card: 'generic', title: 'Discourse review queue', kind: 'read' }
878
+ },
879
+ })
880
+
881
+ add({
882
+ name: 'discourse_delete_post',
883
+ description: 'Delete a post. Returns the post id and whether the deletion was permanent.',
884
+ parameters: {
885
+ type: 'object',
886
+ additionalProperties: false,
887
+ required: ['postId'],
888
+ properties: {
889
+ postId: { type: 'integer', description: 'Numeric post id.' },
890
+ permanent: { type: 'boolean', description: 'Permanently destroy instead of keeping a recoverable deletion.', default: false },
891
+ },
892
+ },
893
+ async execute(args, exec) {
894
+ client.writable('deleting a post')
895
+ const postId = requiredInteger(args?.postId, 'postId')
896
+ const permanent = args?.permanent === true
897
+ await client.call('DELETE', `/posts/${postId}.json`, {
898
+ query: permanent ? { force_destroy: 'true' } : {},
899
+ signal: exec?.signal,
900
+ })
901
+ return { postId, permanent, deleted: true }
902
+ },
903
+ render(_args, value) {
904
+ return [{ type: 'text', text: value.permanent ? `Permanently deleted post #${value.postId}.` : `Deleted post #${value.postId}; staff can restore it.` }]
905
+ },
906
+ presentCall(args) {
907
+ return { card: 'generic', title: `Delete post #${args?.postId ?? '?'}`, kind: 'delete' }
908
+ },
909
+ })
910
+
911
+ add({
912
+ name: 'discourse_restore_post',
913
+ description: 'Restore a previously deleted post. Returns the post id.',
914
+ parameters: {
915
+ type: 'object',
916
+ additionalProperties: false,
917
+ required: ['postId'],
918
+ properties: {
919
+ postId: { type: 'integer', description: 'Numeric post id.' },
920
+ },
921
+ },
922
+ async execute(args, exec) {
923
+ client.writable('restoring a post')
924
+ const postId = requiredInteger(args?.postId, 'postId')
925
+ await client.call('PUT', `/posts/${postId}/recover.json`, { signal: exec?.signal })
926
+ return { postId, restored: true }
927
+ },
928
+ render(_args, value) {
929
+ return [{ type: 'text', text: `Restored post #${value.postId}.` }]
930
+ },
931
+ presentCall(args) {
932
+ return { card: 'generic', title: `Restore post #${args?.postId ?? '?'}`, kind: 'edit' }
933
+ },
934
+ })
935
+
936
+ // --- Administration ------------------------------------------------------
937
+
938
+ add({
939
+ name: 'discourse_admin_users',
940
+ description: 'List or search forum users through the admin directory. Returns ids, roles, trust level, suspension state, and activity counts.',
941
+ parameters: {
942
+ type: 'object',
943
+ additionalProperties: false,
944
+ properties: {
945
+ query: { type: 'string', description: 'Matches username, name, or email.' },
946
+ status: { type: 'string', enum: ['active', 'new', 'suspended', 'silenced', 'staff', 'admins', 'moderators', 'all'], description: 'Directory filter.', default: 'active' },
947
+ order: { type: 'string', enum: ['username', 'email', 'created', 'last_seen', 'post_count'], description: 'Sort column.', default: 'created' },
948
+ page: { type: 'integer', description: 'Result page, starting at 1.', default: 1 },
949
+ limit: { type: 'integer', description: 'Users to return, 1-100.', default: 25 },
950
+ },
951
+ },
952
+ async execute(args, exec) {
953
+ const query = { page: page(args?.page), order: typeof args?.order === 'string' ? args.order : 'created' }
954
+ const status = typeof args?.status === 'string' ? args.status : 'active'
955
+ if (status !== 'all') query.status = status
956
+ if (optionalString(args?.query) !== undefined) query.query = optionalString(args.query)
957
+ const payload = await client.call('GET', '/admin/users.json', { query, signal: exec?.signal })
958
+ const rows = Array.isArray(payload) ? payload : payload?.users ?? []
959
+ const users = (Array.isArray(rows) ? rows : []).slice(0, integer(args?.limit, 25, 100)).map((user) => ({
960
+ id: user?.id,
961
+ username: user?.username,
962
+ name: user?.name ?? null,
963
+ email: user?.email ?? null,
964
+ trustLevel: user?.trust_level,
965
+ admin: user?.admin === true,
966
+ moderator: user?.moderator === true,
967
+ suspended: user?.suspended_till !== undefined && user?.suspended_till !== null,
968
+ suspendedTill: user?.suspended_till ?? null,
969
+ silencedTill: user?.silenced_till ?? null,
970
+ createdAt: day(user?.created_at),
971
+ lastSeenAt: day(user?.last_seen_at),
972
+ postCount: user?.post_count,
973
+ flagsReceived: user?.flags_received_count ?? null,
974
+ }))
975
+ return { status, page: query.page, users, total: payload?.meta?.total ?? payload?.total ?? users.length }
976
+ },
977
+ render(_args, value) {
978
+ const lines = [`Admin directory (${value.status}, page ${value.page}): ${value.total} matches`]
979
+ for (const user of value.users) {
980
+ const roles = [user.admin ? 'admin' : '', user.moderator ? 'moderator' : ''].filter(Boolean).join('/')
981
+ lines.push(`- #${user.id} @${user.username}${user.name ? ` (${user.name})` : ''} TL${user.trustLevel ?? '?'}${roles ? ` [${roles}]` : ''}, ${user.postCount ?? 0} posts, last seen ${user.lastSeenAt}`)
982
+ if (user.suspendedTill) lines.push(` suspended until ${day(user.suspendedTill)}`)
983
+ if (user.silencedTill) lines.push(` silenced until ${day(user.silencedTill)}`)
984
+ }
985
+ if (value.users.length === 0) lines.push('No users match this filter.')
986
+ return [{ type: 'text', text: lines.join('\n') }]
987
+ },
988
+ presentCall() {
989
+ return { card: 'generic', title: 'Discourse user directory', kind: 'search' }
990
+ },
991
+ })
992
+
993
+ add({
994
+ name: 'discourse_moderate_user',
995
+ description: 'Suspend, silence, lift a sanction, or activate a forum account. Returns the account state after the change.',
996
+ parameters: {
997
+ type: 'object',
998
+ additionalProperties: false,
999
+ required: ['username', 'action'],
1000
+ properties: {
1001
+ username: { type: 'string', description: 'Forum username without the leading `@`.' },
1002
+ action: { type: 'string', enum: ['suspend', 'silence', 'unsuspend', 'unsilence', 'activate'], description: 'Account change to apply.' },
1003
+ until: { type: 'string', description: 'End of the sanction as an ISO date such as `2026-12-31`. Required by `suspend` and `silence`.' },
1004
+ reason: { type: 'string', description: 'Staff-only reason recorded with the change.' },
1005
+ message: { type: 'string', description: 'Message shown to the user, when the action supports one.' },
1006
+ },
1007
+ },
1008
+ async execute(args, exec) {
1009
+ client.writable('moderating a user')
1010
+ const username = requiredString(args?.username, 'username').replace(/^@/, '')
1011
+ const action = requiredString(args?.action, 'action')
1012
+ const userId = await resolveUserId(client, username, exec?.signal)
1013
+ if (action === 'suspend' || action === 'silence') {
1014
+ const until = requiredString(args?.until, 'until')
1015
+ const body = action === 'suspend' ? { suspend_until: until } : { silenced_till: until }
1016
+ if (optionalString(args?.reason) !== undefined) body.reason = optionalString(args.reason)
1017
+ if (optionalString(args?.message) !== undefined) body.message = optionalString(args.message)
1018
+ await client.call('PUT', `/admin/users/${userId}/${action}.json`, { body, signal: exec?.signal })
1019
+ return { userId, username, action, until }
1020
+ }
1021
+ if (action === 'activate') {
1022
+ await client.call('PUT', `/admin/users/${userId}/activate.json`, { signal: exec?.signal })
1023
+ return { userId, username, action }
1024
+ }
1025
+ const lifted = action === 'unsuspend' ? 'suspend' : 'silence'
1026
+ await client.call('DELETE', `/admin/users/${userId}/${lifted}.json`, { signal: exec?.signal })
1027
+ return { userId, username, action }
1028
+ },
1029
+ render(_args, value) {
1030
+ const text = value.until === undefined
1031
+ ? `Applied ${value.action} to @${value.username} (id ${value.userId}).`
1032
+ : `Applied ${value.action} to @${value.username} (id ${value.userId}) until ${value.until}.`
1033
+ return [{ type: 'text', text }]
1034
+ },
1035
+ presentCall(args) {
1036
+ return { card: 'generic', title: `${clip(args?.action, 20) || 'Moderate'} @${clip(args?.username, 30)}`, kind: 'other' }
1037
+ },
1038
+ })
1039
+
1040
+ add({
1041
+ name: 'discourse_site_settings',
1042
+ description: 'Read or change forum site settings. Returns matching settings with their current values.',
1043
+ parameters: {
1044
+ type: 'object',
1045
+ additionalProperties: false,
1046
+ properties: {
1047
+ action: { type: 'string', enum: ['get', 'set'], description: 'Read settings or write one.', default: 'get' },
1048
+ name: { type: 'string', description: 'Exact setting name, required by `set`.' },
1049
+ filter: { type: 'string', description: 'Only return settings whose name contains this text.' },
1050
+ value: { type: 'string', description: 'New value for `set`; pass JSON-compatible text for booleans and numbers.' },
1051
+ },
1052
+ },
1053
+ async execute(args, exec) {
1054
+ const action = typeof args?.action === 'string' ? args.action : 'get'
1055
+ if (action === 'set') {
1056
+ client.writable('changing a site setting')
1057
+ const name = requiredString(args?.name, 'name')
1058
+ if (args?.value === undefined) fail('invalid arguments: `value` is required when `action` is `set`')
1059
+ await client.call('PUT', `/admin/site_settings/${encodeURIComponent(name)}.json`, {
1060
+ body: { [name]: coerceSettingValue(args.value) },
1061
+ signal: exec?.signal,
1062
+ })
1063
+ return { updated: true, name, value: args.value }
1064
+ }
1065
+ const payload = await client.call('GET', '/admin/site_settings.json', { signal: exec?.signal })
1066
+ const rows = Array.isArray(payload) ? payload : payload?.site_settings ?? []
1067
+ const filter = optionalString(args?.filter)?.toLowerCase()
1068
+ const name = optionalString(args?.name)?.toLowerCase()
1069
+ const settings = (Array.isArray(rows) ? rows : [])
1070
+ .filter((row) => {
1071
+ const key = String(row?.setting ?? '').toLowerCase()
1072
+ if (name !== undefined && key !== name) return false
1073
+ if (filter !== undefined && !key.includes(filter)) return false
1074
+ return true
1075
+ })
1076
+ .slice(0, 25)
1077
+ .map((row) => ({
1078
+ name: row?.setting,
1079
+ value: row?.value,
1080
+ kind: row?.type ?? null,
1081
+ description: clip(toPlainText(row?.description), 160),
1082
+ }))
1083
+ return { settings, matched: settings.length }
1084
+ },
1085
+ render(_args, value) {
1086
+ if (value.updated === true) return [{ type: 'text', text: `Set ${value.name} to ${JSON.stringify(value.value)}.` }]
1087
+ const lines = [`${value.matched} settings`]
1088
+ for (const setting of value.settings) lines.push(`- ${setting.name} = ${JSON.stringify(setting.value)}`)
1089
+ if (value.settings.length === 0) lines.push('No settings match this filter.')
1090
+ return [{ type: 'text', text: lines.join('\n') }]
1091
+ },
1092
+ presentCall(args) {
1093
+ return { card: 'generic', title: args?.action === 'set' ? `Set ${clip(args?.name, 40)}` : 'Read Discourse settings', kind: 'other' }
1094
+ },
1095
+ })
1096
+
1097
+ add({
1098
+ name: 'discourse_admin_stats',
1099
+ description: 'Read one admin dashboard report as a time series. Returns labelled data points for the requested window.',
1100
+ parameters: {
1101
+ type: 'object',
1102
+ additionalProperties: false,
1103
+ properties: {
1104
+ report: {
1105
+ type: 'string',
1106
+ enum: ['active_users', 'signups', 'topics', 'posts', 'likes', 'flags', 'page_views', 'visits', 'users_by_trust_level', 'new_contributors', 'daily_engaged_users'],
1107
+ description: 'Dashboard report to read.',
1108
+ default: 'topics',
1109
+ },
1110
+ days: { type: 'integer', description: 'Length of the window ending today, 1-365.', default: 30 },
1111
+ },
1112
+ },
1113
+ async execute(args, exec) {
1114
+ const report = typeof args?.report === 'string' ? args.report : 'topics'
1115
+ const days = integer(args?.days, 30, 365)
1116
+ const end = new Date()
1117
+ const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000)
1118
+ const payload = await client.call('GET', `/admin/reports/${report}.json`, {
1119
+ query: { start_date: isoDay(start), end_date: isoDay(end) },
1120
+ signal: exec?.signal,
1121
+ })
1122
+ const body = payload?.report ?? payload ?? {}
1123
+ const points = Array.isArray(body?.data) ? body.data.slice(-days) : []
1124
+ return {
1125
+ report,
1126
+ title: body?.title ?? report,
1127
+ total: body?.total ?? null,
1128
+ labels: body?.labels ?? null,
1129
+ points: points.map((point) => ({ x: point?.x, y: point?.y })),
1130
+ }
1131
+ },
1132
+ render(_args, value) {
1133
+ const lines = [`${value.title}${value.total === null ? '' : ` (total ${value.total})`}`]
1134
+ for (const point of value.points) lines.push(`- ${point.x}: ${point.y}`)
1135
+ if (value.points.length === 0) lines.push('The report returned no data points.')
1136
+ return [{ type: 'text', text: lines.join('\n') }]
1137
+ },
1138
+ presentCall(args) {
1139
+ return { card: 'generic', title: `Discourse report: ${clip(args?.report ?? 'topics', 30)}`, kind: 'read' }
1140
+ },
1141
+ })
1142
+
1143
+ return tools
1144
+ }
1145
+
1146
+ // ---------------------------------------------------------------------------
1147
+ // Tool support
1148
+ // ---------------------------------------------------------------------------
1149
+
1150
+ /** Post action ids used by the flag endpoint. */
1151
+ const FLAG_TYPES = {
1152
+ off_topic: 3,
1153
+ inappropriate: 4,
1154
+ spam: 6,
1155
+ notify_user: 7,
1156
+ notify_moderators: 8,
1157
+ }
1158
+
1159
+ /**
1160
+ * Extract a topic id from a bare id, a numeric argument, a `/t/slug/1234` path, or a full URL.
1161
+ * @param {unknown} input - model-supplied topic reference.
1162
+ * @returns {number} the topic id.
1163
+ */
1164
+ function extractTopicId(input) {
1165
+ if (typeof input === 'number' && Number.isInteger(input) && input > 0) return input
1166
+ if (typeof input !== 'string' || input.trim().length === 0) {
1167
+ fail('invalid arguments: `topic` must be a topic id or topic URL')
1168
+ }
1169
+ const text = input.trim()
1170
+ if (/^\d+$/.test(text)) return Number(text)
1171
+ const match = /\/(\d+)(?:\/|$|\?|#)/.exec(text) ?? /(\d+)\s*$/.exec(text)
1172
+ if (match !== null) return Number(match[1])
1173
+ fail(`invalid arguments: \`topic\` must be a topic id or topic URL, got ${JSON.stringify(input)}`)
1174
+ }
1175
+
1176
+ /**
1177
+ * Resolve a username to its numeric id for admin routes.
1178
+ * @param {ReturnType<typeof createClient>} client - the request client.
1179
+ * @param {string} username - forum username.
1180
+ * @param {AbortSignal} [signal] - caller cancellation.
1181
+ * @returns {Promise<number>} the user id.
1182
+ */
1183
+ async function resolveUserId(client, username, signal) {
1184
+ if (/^\d+$/.test(username)) return Number(username)
1185
+ const payload = await client.call('GET', `/u/${encodeURIComponent(username)}.json`, { signal })
1186
+ const id = payload?.user?.id ?? payload?.id
1187
+ if (typeof id !== 'number') fail(`could not resolve @${username} to a user id on ${client.config.baseUrl}`)
1188
+ return id
1189
+ }
1190
+
1191
+ /**
1192
+ * Interpret a setting value written as text.
1193
+ * @param {unknown} value - model-supplied value.
1194
+ * @returns {unknown} the JSON value a Discourse setting expects.
1195
+ */
1196
+ function coerceSettingValue(value) {
1197
+ if (typeof value !== 'string') return value
1198
+ const text = value.trim()
1199
+ if (text === 'true') return true
1200
+ if (text === 'false') return false
1201
+ if (text === 'null') return null
1202
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text)
1203
+ if (/^[[{"]/.test(text)) {
1204
+ try {
1205
+ return JSON.parse(text)
1206
+ } catch {
1207
+ return value
1208
+ }
1209
+ }
1210
+ return value
1211
+ }
1212
+
1213
+ /**
1214
+ * Format a date as `YYYY-MM-DD` in UTC.
1215
+ * @param {Date} date - the instant to format.
1216
+ * @returns {string} the calendar date.
1217
+ */
1218
+ function isoDay(date) {
1219
+ return date.toISOString().slice(0, 10)
1220
+ }
1221
+
1222
+ // ---------------------------------------------------------------------------
1223
+ // Plugin entry
1224
+ // ---------------------------------------------------------------------------
1225
+
1226
+ /**
1227
+ * Register every Discourse tool for the lifetime of this plugin.
1228
+ * @param {import('@deepseek-ai/cordis').Context} ctx - registrant context carrying the tool registry.
1229
+ * @param {unknown} config - loader row config.
1230
+ * @returns {void}
1231
+ */
1232
+ export function apply(ctx, config) {
1233
+ const client = createClient(readConfig(config))
1234
+ const tools = buildTools(client)
1235
+ for (const tool of tools) {
1236
+ ctx.effect(() => ctx.tools.register(tool), `discourse:${tool.name}`)
1237
+ }
1238
+ }