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