my harness for niri
1import { REST, Routes } from "discord.js"
2import { getDb } from "../db.js"
3
4type InboxStatus = "pending" | "seen" | "acted" | "ignored"
5type InboxAction = "none" | "replied" | "messaged" | "dismissed" | "noted"
6type ReplyMode = "auto" | "plain" | "explicit"
7
8type DiscordObject = Record<string, unknown>
9
10type DiscordMessageRecord = {
11 messageId: string
12 channelId: string
13 guildId: string | null
14 channelType: number | null
15 authorId: string | null
16 authorUsername: string | null
17 content: string
18 createdAt: string
19 isDm: boolean
20 isFromBot: boolean
21 mentionsBot: boolean
22 rawJson: string
23}
24
25type DiscordChannelRecord = {
26 channelId: string
27 guildId: string | null
28 channelType: number | null
29 channelName: string | null
30 guildName: string | null
31 topic: string | null
32 isDm: boolean
33 configured: boolean
34 rawJson: string
35}
36
37export type DiscordIngestResult = {
38 stored: boolean
39 isNew: boolean
40 messageId?: string
41 itemId?: string
42 bucket?: "dm" | "mention"
43 reason?: string
44}
45
46export type DiscordBatchDigest = {
47 content: string
48 messageCount: number
49 pendingCount: number
50 from: string
51 to: string
52}
53
54const DEFAULT_SCAN_LIMIT = 50
55const AUTO_REPLY_STALE_MINUTES = 10
56
57const VALID_STATUS = new Set<InboxStatus>(["pending", "seen", "acted", "ignored"])
58const VALID_ACTION = new Set<InboxAction>(["none", "replied", "messaged", "dismissed", "noted"])
59
60function asObject(value: unknown): DiscordObject | null {
61 return value && typeof value === "object" && !Array.isArray(value) ? (value as DiscordObject) : null
62}
63
64function asString(value: unknown): string | null {
65 if (typeof value === "string") {
66 const trimmed = value.trim()
67 return trimmed.length > 0 ? trimmed : null
68 }
69 if (typeof value === "number" && Number.isFinite(value)) return String(value)
70 return null
71}
72
73function asBoolean(value: unknown): boolean {
74 if (typeof value === "boolean") return value
75 if (typeof value === "number") return value !== 0
76 if (typeof value === "string") {
77 const normalized = value.trim().toLowerCase()
78 return normalized === "1" || normalized === "true" || normalized === "yes"
79 }
80 return false
81}
82
83function asNumber(value: unknown): number | null {
84 if (typeof value === "number" && Number.isFinite(value)) return value
85 if (typeof value === "string") {
86 const parsed = Number.parseInt(value, 10)
87 return Number.isFinite(parsed) ? parsed : null
88 }
89 return null
90}
91
92function toIsoString(value: unknown): string {
93 const raw = asString(value)
94 if (!raw) return new Date().toISOString()
95 const parsed = new Date(raw)
96 if (Number.isNaN(parsed.getTime())) return new Date().toISOString()
97 return parsed.toISOString()
98}
99
100function parseChannelIds(input?: string[] | string | null): string[] {
101 if (Array.isArray(input)) {
102 return input.map((x) => String(x).trim()).filter(Boolean)
103 }
104
105 const text = typeof input === "string" ? input : process.env.DISCORD_SCAN_CHANNEL_IDS ?? ""
106 return text
107 .split(",")
108 .map((x) => x.trim())
109 .filter(Boolean)
110}
111
112function configuredChannelIdSet(input?: string[] | string | null): Set<string> {
113 return new Set(parseChannelIds(input))
114}
115
116function getBotToken(): string {
117 const token = process.env.DISCORD_BOT_TOKEN?.trim()
118 if (!token) throw new Error("DISCORD_BOT_TOKEN is required")
119 return token
120}
121
122function makeRestClient(): REST {
123 return new REST({ version: "10" }).setToken(getBotToken())
124}
125
126async function getBotUserId(rest: REST): Promise<string> {
127 const me = (await rest.get(Routes.user("@me"))) as { id?: unknown }
128 const id = asString(me?.id)
129 if (!id) throw new Error("failed to resolve bot user id")
130 return id
131}
132
133function parseMessageRecord(payload: unknown, botUserId?: string): DiscordMessageRecord | null {
134 const root = asObject(payload)
135 if (!root) return null
136 const botId = botUserId ?? process.env.DISCORD_BOT_USER_ID?.trim()
137
138 const message = asObject(root.message) ?? root
139 const channel = asObject(root.channel)
140 const author = asObject(message.author) ?? asObject(root.author)
141
142 const messageId = asString(message.id ?? root.message_id)
143 const channelId = asString(message.channel_id ?? root.channel_id ?? channel?.id)
144
145 if (!messageId || !channelId) return null
146
147 const guildId = asString(message.guild_id ?? root.guild_id ?? channel?.guild_id)
148 const channelType = asNumber(message.channel_type ?? root.channel_type ?? channel?.type)
149
150 const authorId = asString(author?.id ?? root.author_id)
151 const authorUsername =
152 asString(author?.global_name) ?? asString(author?.username) ?? asString(root.author_username) ?? asString(root.author)
153
154 const content = String(message.content ?? root.content ?? "")
155 const createdAt = toIsoString(message.timestamp ?? root.timestamp)
156
157 const isDm =
158 asBoolean(root.is_dm) ||
159 channelType === 1 ||
160 channelType === 3 ||
161 (message.guild_id == null && root.guild_id == null && channel?.guild_id == null)
162 const isFromBot = asBoolean(author?.bot ?? root.author_is_bot)
163
164 let mentionsBot = asBoolean(root.mentions_bot)
165 if (!mentionsBot && botId) {
166 const mentions = Array.isArray(message.mentions) ? message.mentions : []
167 mentionsBot = mentions.some((entry) => {
168 const obj = asObject(entry)
169 if (!obj) return false
170 const mentionedId = asString(obj.id)
171 if (mentionedId && mentionedId === botId) return true
172 return asBoolean(obj.bot)
173 })
174
175 if (!mentionsBot && content.includes(`<@${botId}>`)) mentionsBot = true
176 if (!mentionsBot && content.includes(`<@!${botId}>`)) mentionsBot = true
177 }
178
179 return {
180 messageId,
181 channelId,
182 guildId,
183 channelType,
184 authorId,
185 authorUsername,
186 content,
187 createdAt,
188 isDm,
189 isFromBot,
190 mentionsBot,
191 rawJson: JSON.stringify(payload),
192 }
193}
194
195function parseChannelRecord(
196 payload: unknown,
197 fallback?: { channelId?: string; guildId?: string | null; channelType?: number | null; isDm?: boolean },
198): DiscordChannelRecord | null {
199 const root = asObject(payload)
200 if (!root) return null
201
202 const channel = asObject(root.channel) ?? root
203 const channelId = asString(channel.id ?? root.channel_id ?? fallback?.channelId)
204 if (!channelId) return null
205
206 const guildId = asString(channel.guild_id ?? root.guild_id ?? fallback?.guildId)
207 const channelType = asNumber(channel.type ?? root.channel_type ?? fallback?.channelType)
208 const isDm = asBoolean(root.is_dm) || channelType === 1 || channelType === 3 || fallback?.isDm === true
209 const configured = configuredChannelIdSet().has(channelId)
210
211 return {
212 channelId,
213 guildId,
214 channelType,
215 channelName: asString(channel.name ?? root.channel_name),
216 guildName: asString(root.guild_name),
217 topic: asString(channel.topic ?? root.channel_topic),
218 isDm,
219 configured,
220 rawJson: JSON.stringify(channel),
221 }
222}
223
224function upsertDiscordChannel(record: DiscordChannelRecord): void {
225 const db = getDb()
226 const now = new Date().toISOString()
227
228 db.prepare(
229 `insert into discord_channels (
230 channel_id, guild_id, channel_type, channel_name, guild_name, topic,
231 is_dm, configured, first_seen_at, last_seen_at, raw_json
232 ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
233 on conflict(channel_id) do update set
234 guild_id = coalesce(excluded.guild_id, discord_channels.guild_id),
235 channel_type = coalesce(excluded.channel_type, discord_channels.channel_type),
236 channel_name = coalesce(excluded.channel_name, discord_channels.channel_name),
237 guild_name = coalesce(excluded.guild_name, discord_channels.guild_name),
238 topic = coalesce(excluded.topic, discord_channels.topic),
239 is_dm = excluded.is_dm,
240 configured = max(discord_channels.configured, excluded.configured),
241 last_seen_at = excluded.last_seen_at,
242 raw_json = excluded.raw_json`,
243 ).run(
244 record.channelId,
245 record.guildId,
246 record.channelType,
247 record.channelName,
248 record.guildName,
249 record.topic,
250 record.isDm ? 1 : 0,
251 record.configured ? 1 : 0,
252 now,
253 now,
254 record.rawJson,
255 )
256}
257
258function upsertDiscordMessage(record: DiscordMessageRecord): void {
259 const db = getDb()
260 const now = new Date().toISOString()
261
262 db.prepare(
263 `insert into discord_messages (
264 message_id, channel_id, guild_id, channel_type,
265 author_id, author_username, content, created_at,
266 is_dm, mentions_bot, is_from_bot,
267 first_seen_at, last_seen_at, raw_json
268 ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
269 on conflict(message_id) do update set
270 channel_id = excluded.channel_id,
271 guild_id = excluded.guild_id,
272 channel_type = excluded.channel_type,
273 author_id = excluded.author_id,
274 author_username = excluded.author_username,
275 content = excluded.content,
276 created_at = excluded.created_at,
277 is_dm = excluded.is_dm,
278 mentions_bot = excluded.mentions_bot,
279 is_from_bot = excluded.is_from_bot,
280 last_seen_at = excluded.last_seen_at,
281 raw_json = excluded.raw_json`,
282 ).run(
283 record.messageId,
284 record.channelId,
285 record.guildId,
286 record.channelType,
287 record.authorId,
288 record.authorUsername,
289 record.content,
290 record.createdAt,
291 record.isDm ? 1 : 0,
292 record.mentionsBot ? 1 : 0,
293 record.isFromBot ? 1 : 0,
294 now,
295 now,
296 record.rawJson,
297 )
298}
299
300function upsertInboxItem(messageId: string, bucket: "dm" | "mention"): void {
301 const db = getDb()
302 const now = new Date().toISOString()
303
304 db.prepare(
305 `insert into discord_items (
306 item_id, message_id, bucket, status,
307 action_taken, first_seen_at, last_seen_at
308 ) values (?, ?, ?, 'pending', 'none', ?, ?)
309 on conflict(item_id) do update set
310 bucket = excluded.bucket,
311 last_seen_at = excluded.last_seen_at`,
312 ).run(messageId, messageId, bucket, now, now)
313}
314
315function detectInboxBucket(record: DiscordMessageRecord): "dm" | "mention" | null {
316 if (record.isFromBot) return null
317 if (record.isDm) return "dm"
318 if (record.mentionsBot) return "mention"
319 return null
320}
321
322export function ingestDiscordEvent(payload: unknown, options?: { botUserId?: string }): DiscordIngestResult {
323 const record = parseMessageRecord(payload, options?.botUserId)
324 if (!record) {
325 return { stored: false, isNew: false, reason: "payload is missing message/channel identity" }
326 }
327
328 const db = getDb()
329 const exists = db
330 .prepare(`select 1 as present from discord_messages where message_id = ?`)
331 .get(record.messageId) as { present?: number } | undefined
332 const isNew = !exists
333
334 const channelRecord = parseChannelRecord(payload, {
335 channelId: record.channelId,
336 guildId: record.guildId,
337 channelType: record.channelType,
338 isDm: record.isDm,
339 })
340 if (channelRecord) upsertDiscordChannel(channelRecord)
341
342 upsertDiscordMessage(record)
343
344 const bucket = detectInboxBucket(record)
345 if (bucket) {
346 upsertInboxItem(record.messageId, bucket)
347 return {
348 stored: true,
349 isNew,
350 messageId: record.messageId,
351 itemId: record.messageId,
352 bucket,
353 }
354 }
355
356 return {
357 stored: true,
358 isNew,
359 messageId: record.messageId,
360 }
361}
362
363function ensureConfiguredChannelsMaterialized(channelIds?: string[] | string | null): void {
364 const ids = parseChannelIds(channelIds)
365 if (ids.length === 0) return
366
367 const db = getDb()
368 const now = new Date().toISOString()
369 const stmt = db.prepare(
370 `insert into discord_channels (
371 channel_id, configured, first_seen_at, last_seen_at, raw_json
372 ) values (?, 1, ?, ?, ?)
373 on conflict(channel_id) do update set
374 configured = 1,
375 last_seen_at = excluded.last_seen_at`,
376 )
377
378 for (const channelId of ids) {
379 stmt.run(channelId, now, now, "{}")
380 }
381}
382
383function getDiscordMeta(key: string): string | null {
384 const db = getDb()
385 const row = db
386 .prepare(`select value from discord_meta where key = ?`)
387 .get(key) as { value?: string } | undefined
388 return row?.value ?? null
389}
390
391function setDiscordMeta(key: string, value: string): void {
392 const db = getDb()
393 const now = new Date().toISOString()
394 db.prepare(
395 `insert into discord_meta (key, value, updated_at)
396 values (?, ?, ?)
397 on conflict(key) do update set
398 value = excluded.value,
399 updated_at = excluded.updated_at`,
400 ).run(key, value, now)
401}
402
403function compactText(value: unknown, maxChars = 180): string {
404 const text = String(value ?? "").replace(/\s+/g, " ").trim()
405 if (!text) return "(no text)"
406 if (text.length <= maxChars) return text
407 return `${text.slice(0, maxChars - 1)}...`
408}
409
410function formatBatchTimestamp(value: string | null | undefined): string {
411 if (!value) return "unknown-time"
412 const parsed = new Date(value)
413 if (Number.isNaN(parsed.getTime())) return value
414 return parsed.toISOString().replace("T", " ").replace(".000Z", "Z")
415}
416
417function extractReferencedMessageId(rawJson: string): string | null {
418 try {
419 const root = asObject(JSON.parse(rawJson))
420 if (!root) return null
421 const message = asObject(root.message) ?? root
422 const reference =
423 asObject(message.message_reference) ??
424 asObject(root.message_reference) ??
425 asObject(message.reference) ??
426 asObject(root.reference)
427
428 const direct =
429 asString(reference?.message_id) ??
430 asString(reference?.messageId) ??
431 asString(reference?.id)
432 if (direct) return direct
433
434 const referencedMessage = asObject(message.referenced_message) ?? asObject(root.referenced_message)
435 return asString(referencedMessage?.id)
436 } catch {
437 return null
438 }
439}
440
441function buildReplyTargetLabelMap(rows: Array<{ message_id: string; raw_json: string }>): Map<string, string> {
442 const refsByMessage = new Map<string, string>()
443 for (const row of rows) {
444 const refId = extractReferencedMessageId(row.raw_json)
445 if (refId) refsByMessage.set(row.message_id, refId)
446 }
447
448 if (refsByMessage.size === 0) return new Map()
449
450 const refIds = Array.from(new Set(refsByMessage.values()))
451 const db = getDb()
452 const placeholders = refIds.map(() => "?").join(", ")
453 const targetRows = db
454 .prepare(
455 `select message_id, author_username
456 from discord_messages
457 where message_id in (${placeholders})`,
458 )
459 .all(...refIds) as Array<{ message_id: string; author_username: string | null }>
460
461 const labelById = new Map<string, string>()
462 for (const row of targetRows) {
463 labelById.set(row.message_id, row.author_username ? `@${row.author_username}` : `msg/${row.message_id}`)
464 }
465
466 const out = new Map<string, string>()
467 for (const [messageId, refId] of refsByMessage.entries()) {
468 out.set(messageId, labelById.get(refId) ?? `msg/${refId}`)
469 }
470
471 return out
472}
473
474function autoDemoteStalePendingItems(staleMinutes: number): number {
475 if (staleMinutes <= 0) return 0
476
477 const db = getDb()
478 const nowIso = new Date().toISOString()
479 const cutoffIso = new Date(Date.now() - staleMinutes * 60_000).toISOString()
480 const note = `auto-demoted after ${staleMinutes}m pending timeout`
481
482 const result = db.prepare(
483 `update discord_items
484 set status = 'seen',
485 action_taken = case when action_taken = 'none' then 'noted' else action_taken end,
486 decision_note = coalesce(decision_note, ?),
487 last_decision_at = ?,
488 last_seen_at = ?
489 where status = 'pending'
490 and first_seen_at <= ?`,
491 ).run(note, nowIso, nowIso, cutoffIso)
492
493 return Number(result.changes ?? 0)
494}
495
496function channelLabel(row: {
497 is_dm: number
498 guild_name: string | null
499 guild_id: string | null
500 channel_name: string | null
501 channel_id: string
502}): string {
503 if (row.is_dm === 1) return `dm/${row.channel_id}`
504 const guild = row.guild_name ?? row.guild_id ?? "unknown-guild"
505 const channel = row.channel_name ?? row.channel_id
506 return `${guild}/#${channel}`
507}
508
509function normalizeStatuses(input?: string[] | string | null): InboxStatus[] {
510 const rawValues = Array.isArray(input)
511 ? input
512 : typeof input === "string"
513 ? input.split(",").map((x) => x.trim())
514 : ["pending"]
515
516 const values = rawValues
517 .map((x) => x.trim())
518 .filter((x): x is InboxStatus => VALID_STATUS.has(x as InboxStatus))
519
520 return values.length > 0 ? values : ["pending"]
521}
522
523export function listDiscordInbox(limit = 20, statuses?: string[] | string): unknown[] {
524 const db = getDb()
525 const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit) || 20))
526 const statusList = normalizeStatuses(statuses)
527 const placeholders = statusList.map(() => "?").join(", ")
528
529 const stmt = db.prepare(
530 `select
531 i.item_id,
532 i.message_id,
533 i.bucket,
534 i.status,
535 i.action_taken,
536 i.decision_note,
537 i.first_seen_at,
538 i.last_seen_at,
539 m.channel_id,
540 m.guild_id,
541 m.author_id,
542 m.author_username,
543 m.content,
544 m.created_at,
545 m.is_dm,
546 m.mentions_bot
547 from discord_items i
548 join discord_messages m on m.message_id = i.message_id
549 where i.status in (${placeholders})
550 order by i.last_seen_at desc
551 limit ?`,
552 )
553
554 return stmt.all(...statusList, safeLimit)
555}
556
557export function listDiscordBackread(channelId: string, limit = 40, beforeMessageId?: string): unknown[] {
558 const db = getDb()
559 const safeChannelId = String(channelId ?? "").trim()
560 if (!safeChannelId) throw new Error("channel_id is required")
561
562 const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit) || 40))
563 const before = String(beforeMessageId ?? "").trim()
564
565 if (!before) {
566 return db
567 .prepare(
568 `select
569 message_id,
570 channel_id,
571 guild_id,
572 author_id,
573 author_username,
574 content,
575 created_at,
576 is_dm,
577 mentions_bot,
578 is_from_bot
579 from discord_messages
580 where channel_id = ?
581 order by cast(message_id as integer) desc
582 limit ?`,
583 )
584 .all(safeChannelId, safeLimit)
585 }
586
587 return db
588 .prepare(
589 `select
590 message_id,
591 channel_id,
592 guild_id,
593 author_id,
594 author_username,
595 content,
596 created_at,
597 is_dm,
598 mentions_bot,
599 is_from_bot
600 from discord_messages
601 where channel_id = ?
602 and cast(message_id as integer) < cast(? as integer)
603 order by cast(message_id as integer) desc
604 limit ?`,
605 )
606 .all(safeChannelId, before, safeLimit)
607}
608
609export function markDiscordItem(itemId: string, status: InboxStatus, note = "", action: InboxAction = "none"): void {
610 const safeItemId = String(itemId ?? "").trim()
611 if (!safeItemId) throw new Error("item_id is required")
612 if (!VALID_STATUS.has(status)) throw new Error(`invalid status: ${status}`)
613 if (!VALID_ACTION.has(action)) throw new Error(`invalid action: ${action}`)
614
615 const db = getDb()
616 const now = new Date().toISOString()
617
618 db.prepare(
619 `update discord_items
620 set status = ?, action_taken = ?, decision_note = ?, last_decision_at = ?, last_seen_at = ?
621 where item_id = ?`,
622 ).run(status, action, note || null, now, now, safeItemId)
623}
624
625export function listDiscordChannels(includeUnconfigured = true): unknown[] {
626 ensureConfiguredChannelsMaterialized()
627 const db = getDb()
628
629 const whereClause = includeUnconfigured ? "" : "where configured = 1 or note is not null"
630 return db
631 .prepare(
632 `select
633 channel_id,
634 configured,
635 guild_id,
636 guild_name,
637 channel_name,
638 channel_type,
639 is_dm,
640 topic,
641 note,
642 last_note_at,
643 last_seen_at
644 from discord_channels
645 ${whereClause}
646 order by configured desc, coalesce(guild_name, ''), coalesce(channel_name, channel_id)`,
647 )
648 .all()
649}
650
651export function setDiscordChannelNote(channelId: string, note: string): Record<string, unknown> {
652 const safeChannelId = String(channelId ?? "").trim()
653 if (!safeChannelId) throw new Error("channel_id is required")
654
655 ensureConfiguredChannelsMaterialized([safeChannelId])
656
657 const db = getDb()
658 const now = new Date().toISOString()
659 const trimmed = note.trim()
660
661 db.prepare(
662 `update discord_channels
663 set note = ?, last_note_at = ?, last_seen_at = ?
664 where channel_id = ?`,
665 ).run(trimmed.length > 0 ? trimmed : null, now, now, safeChannelId)
666
667 const row = db
668 .prepare(
669 `select channel_id, configured, guild_id, guild_name, channel_name, note, last_note_at
670 from discord_channels
671 where channel_id = ?`,
672 )
673 .get(safeChannelId) as Record<string, unknown> | undefined
674
675 return {
676 ok: true,
677 cleared: trimmed.length === 0,
678 ...(row ?? { channel_id: safeChannelId }),
679 }
680}
681
682export function buildDiscordBatchDigest(params?: {
683 maxMessages?: number
684 pendingPreviewLimit?: number
685 intervalMs?: number
686}): DiscordBatchDigest | null {
687 const db = getDb()
688 const now = new Date()
689 const nowIso = now.toISOString()
690 const defaultIntervalMs = Math.max(
691 60_000,
692 Number.parseInt(process.env.DISCORD_BATCH_INTERVAL_MS ?? "60000", 10) || 60_000,
693 )
694 const intervalMs = Math.max(60_000, Math.trunc(params?.intervalMs ?? defaultIntervalMs))
695 const maxMessages = Math.max(1, Math.min(200, Math.trunc(params?.maxMessages ?? 40) || 40))
696 const previewLimit = Math.max(1, Math.min(50, Math.trunc(params?.pendingPreviewLimit ?? 6) || 6))
697 const batchOnlyConfigured = (process.env.DISCORD_BATCH_ONLY_CONFIGURED ?? "true").trim().toLowerCase() !== "false"
698 const autoSeenMinutes = Math.max(
699 0,
700 Number.parseInt(process.env.DISCORD_PENDING_AUTO_SEEN_MINUTES ?? "10", 10) || 10,
701 )
702 const channelScopeClause = batchOnlyConfigured
703 ? "and (m.is_dm = 1 or coalesce(c.configured, 0) = 1)"
704 : ""
705
706 const autoDemotedCount = autoDemoteStalePendingItems(autoSeenMinutes)
707
708 const from =
709 getDiscordMeta("discord_batch_last_dispatched_at") ??
710 new Date(now.getTime() - intervalMs).toISOString()
711
712 const messageRows = db
713 .prepare(
714 `select
715 m.message_id,
716 m.channel_id,
717 m.guild_id,
718 m.author_username,
719 m.content,
720 m.created_at,
721 m.first_seen_at,
722 m.is_dm,
723 m.raw_json,
724 c.guild_name,
725 c.channel_name
726 from discord_messages m
727 left join discord_channels c on c.channel_id = m.channel_id
728 left join discord_items i on i.message_id = m.message_id
729 where m.is_from_bot = 0
730 and m.first_seen_at > ?
731 and (i.message_id is null or i.status = 'pending')
732 ${channelScopeClause}
733 order by m.first_seen_at asc
734 limit ?`,
735 )
736 .all(from, maxMessages + 1) as Array<{
737 message_id: string
738 channel_id: string
739 guild_id: string | null
740 author_username: string | null
741 content: string
742 created_at: string
743 first_seen_at: string
744 is_dm: number
745 raw_json: string
746 guild_name: string | null
747 channel_name: string | null
748 }>
749
750 if (messageRows.length === 0) return null
751
752 const truncated = messageRows.length > maxMessages
753 const recentMessages = truncated ? messageRows.slice(0, maxMessages) : messageRows
754
755 const pendingCountRow = db
756 .prepare(
757 `select count(*) as count
758 from discord_items i
759 join discord_messages m on m.message_id = i.message_id
760 left join discord_channels c on c.channel_id = m.channel_id
761 where i.status = 'pending'
762 ${channelScopeClause}`,
763 )
764 .get() as { count?: number } | undefined
765 const pendingCount = pendingCountRow?.count ?? 0
766
767 const pendingPreview = db
768 .prepare(
769 `select
770 i.item_id,
771 i.bucket,
772 m.channel_id,
773 m.guild_id,
774 m.author_username,
775 m.content,
776 m.created_at,
777 m.is_dm,
778 m.message_id,
779 m.raw_json,
780 c.guild_name,
781 c.channel_name
782 from discord_items i
783 join discord_messages m on m.message_id = i.message_id
784 left join discord_channels c on c.channel_id = m.channel_id
785 where i.status = 'pending'
786 ${channelScopeClause}
787 order by i.last_seen_at desc
788 limit ?`,
789 )
790 .all(previewLimit) as Array<{
791 item_id: string
792 bucket: string
793 channel_id: string
794 guild_id: string | null
795 author_username: string | null
796 content: string
797 created_at: string
798 is_dm: number
799 message_id: string
800 raw_json: string
801 guild_name: string | null
802 channel_name: string | null
803 }>
804
805 const uniqueChannels = new Set(recentMessages.map((row) => row.channel_id))
806 const replyLabelByMessageId = buildReplyTargetLabelMap([...recentMessages, ...pendingPreview])
807
808 const lines: string[] = [
809 `[discord batch] ${from} -> ${nowIso}`,
810 `new_messages=${recentMessages.length}${truncated ? "+" : ""} channels=${uniqueChannels.size} pending_inbox=${pendingCount} scope=${batchOnlyConfigured ? "configured+dm" : "all"}`,
811 `auto_seen_timeout=${autoSeenMinutes}m auto_demoted=${autoDemotedCount}`,
812 "",
813 "recent messages:",
814 ]
815
816 for (const row of recentMessages) {
817 const label = channelLabel(row)
818 const author = row.author_username ? `@${row.author_username}` : "@unknown"
819 const ts = formatBatchTimestamp(row.created_at)
820 const replyTo = replyLabelByMessageId.get(row.message_id)
821 lines.push(`- [${label}] [${ts}] ${author}${replyTo ? ` [reply_to ${replyTo}]` : ""}: ${compactText(row.content)}`)
822 }
823
824 if (truncated) {
825 lines.push(`- ...truncated at ${maxMessages} messages`)
826 }
827
828 lines.push("")
829 lines.push("pending preview:")
830 if (pendingPreview.length === 0) {
831 lines.push("- (none)")
832 } else {
833 for (const row of pendingPreview) {
834 const label = channelLabel(row)
835 const author = row.author_username ? `@${row.author_username}` : "@unknown"
836 const ts = formatBatchTimestamp(row.created_at)
837 const replyTo = replyLabelByMessageId.get(row.message_id)
838 lines.push(`- ${row.item_id} [${row.bucket}] [${label}] [${ts}] ${author}${replyTo ? ` [reply_to ${replyTo}]` : ""}: ${compactText(row.content, 120)}`)
839 }
840 }
841
842 lines.push("")
843 lines.push("you can reply if you want via discord_send, then mark decisions with discord_mark.")
844
845 setDiscordMeta("discord_batch_last_dispatched_at", nowIso)
846
847 return {
848 content: lines.join("\n"),
849 messageCount: recentMessages.length,
850 pendingCount,
851 from,
852 to: nowIso,
853 }
854}
855
856function getSourceMessageId(sourceItemId?: string, referenceMessageId?: string): string | null {
857 if (referenceMessageId && referenceMessageId.trim()) return referenceMessageId.trim()
858 if (!sourceItemId || !sourceItemId.trim()) return null
859
860 const db = getDb()
861 const row = db
862 .prepare(`select message_id from discord_items where item_id = ?`)
863 .get(sourceItemId.trim()) as { message_id?: string } | undefined
864
865 return row?.message_id?.trim() ? row.message_id : null
866}
867
868function shouldUseExplicitReference(channelId: string, sourceMessageId: string): boolean {
869 const db = getDb()
870 const source = db
871 .prepare(
872 `select message_id, channel_id, author_id, created_at
873 from discord_messages
874 where message_id = ?`,
875 )
876 .get(sourceMessageId) as { message_id?: string; channel_id?: string; author_id?: string | null; created_at?: string } | undefined
877
878 if (!source?.message_id || !source.channel_id) return false
879 if (source.channel_id !== channelId) return false
880
881 const createdMs = Date.parse(source.created_at ?? "")
882 if (Number.isFinite(createdMs)) {
883 const staleMs = AUTO_REPLY_STALE_MINUTES * 60_000
884 if (Date.now() - createdMs >= staleMs) return true
885 }
886
887 const row = db
888 .prepare(
889 `select count(*) as count
890 from discord_messages
891 where channel_id = ?
892 and cast(message_id as integer) > cast(? as integer)
893 and is_from_bot = 0
894 and coalesce(author_id, '') != coalesce(?, '')`,
895 )
896 .get(channelId, sourceMessageId, source.author_id ?? "") as { count?: number } | undefined
897
898 return (row?.count ?? 0) > 0
899}
900
901async function chooseMessageReference(options: {
902 channelId: string
903 replyMode: ReplyMode
904 sourceItemId?: string
905 referenceMessageId?: string
906}): Promise<string | null> {
907 const sourceMessageId = getSourceMessageId(options.sourceItemId, options.referenceMessageId)
908 if (!sourceMessageId) return null
909
910 if (options.replyMode === "plain") return null
911 if (options.replyMode === "explicit") return sourceMessageId
912
913 return shouldUseExplicitReference(options.channelId, sourceMessageId) ? sourceMessageId : null
914}
915
916function inferPendingDmItemId(channelId: string): string | null {
917 const db = getDb()
918 const row = db
919 .prepare(
920 `select i.item_id
921 from discord_items i
922 join discord_messages m on m.message_id = i.message_id
923 where i.status = 'pending'
924 and m.channel_id = ?
925 and m.is_dm = 1
926 and m.is_from_bot = 0
927 order by cast(m.message_id as integer) desc
928 limit 1`,
929 )
930 .get(channelId) as { item_id?: string } | undefined
931
932 return row?.item_id?.trim() ? row.item_id : null
933}
934
935function normalizeReplyMode(value: unknown): ReplyMode {
936 if (value === "plain" || value === "explicit" || value === "auto") return value
937 return "auto"
938}
939
940export async function scanDiscordChannels(params?: {
941 limit?: number
942 channelIds?: string[] | string
943 beforeMessageId?: string
944}): Promise<Record<string, unknown>> {
945 const rest = makeRestClient()
946 const botUserId = await getBotUserId(rest)
947
948 const channelIds = parseChannelIds(params?.channelIds)
949 ensureConfiguredChannelsMaterialized(channelIds)
950 if (channelIds.length === 0) {
951 return {
952 scanned_channels: 0,
953 fetched_messages: 0,
954 stored_messages: 0,
955 inbox_items: 0,
956 note: "no channels configured; set DISCORD_SCAN_CHANNEL_IDS or pass channel_ids",
957 }
958 }
959
960 const limit = Math.max(1, Math.min(100, Math.trunc(params?.limit ?? DEFAULT_SCAN_LIMIT) || DEFAULT_SCAN_LIMIT))
961 const before = asString(params?.beforeMessageId)
962
963 let fetchedMessages = 0
964 let storedMessages = 0
965 let inboxItems = 0
966 const guildNameCache = new Map<string, string | null>()
967
968 for (const channelId of channelIds) {
969 const channel = (await rest.get(Routes.channel(channelId))) as DiscordObject
970 const channelType = asNumber(channel.type)
971 const guildId = asString(channel.guild_id)
972 let guildName: string | null = null
973 if (guildId) {
974 if (guildNameCache.has(guildId)) {
975 guildName = guildNameCache.get(guildId) ?? null
976 } else {
977 try {
978 const guild = (await rest.get(Routes.guild(guildId))) as DiscordObject
979 guildName = asString(guild.name)
980 guildNameCache.set(guildId, guildName)
981 } catch {
982 guildNameCache.set(guildId, null)
983 }
984 }
985 }
986
987 upsertDiscordChannel({
988 channelId,
989 guildId,
990 channelType,
991 channelName: asString(channel.name),
992 guildName,
993 topic: asString(channel.topic),
994 isDm: channelType === 1 || channelType === 3,
995 configured: true,
996 rawJson: JSON.stringify(channel),
997 })
998 const query = new URLSearchParams({ limit: String(limit) })
999 if (before) query.set("before", before)
1000
1001 const messages = (await rest.get(Routes.channelMessages(channelId), {
1002 query,
1003 })) as unknown[]
1004
1005 fetchedMessages += messages.length
1006
1007 for (const message of messages) {
1008 const result = ingestDiscordEvent(
1009 {
1010 message,
1011 channel: {
1012 id: channelId,
1013 type: channelType,
1014 guild_id: guildId,
1015 },
1016 },
1017 { botUserId },
1018 )
1019
1020 if (result.stored) {
1021 storedMessages += 1
1022 if (result.itemId) inboxItems += 1
1023 }
1024 }
1025 }
1026
1027 return {
1028 scanned_channels: channelIds.length,
1029 fetched_messages: fetchedMessages,
1030 stored_messages: storedMessages,
1031 inbox_items: inboxItems,
1032 }
1033}
1034
1035export async function sendDiscordMessage(params: {
1036 channelId?: string
1037 content: string
1038 sourceItemId?: string
1039 replyMode?: string
1040 referenceMessageId?: string
1041}): Promise<Record<string, unknown>> {
1042 let channelId = String(params.channelId ?? "").trim()
1043 if (!channelId && params.sourceItemId?.trim()) {
1044 const db = getDb()
1045 const row = db
1046 .prepare(
1047 `select m.channel_id
1048 from discord_items i
1049 join discord_messages m on m.message_id = i.message_id
1050 where i.item_id = ?`,
1051 )
1052 .get(params.sourceItemId.trim()) as { channel_id?: string } | undefined
1053 channelId = row?.channel_id?.trim() ?? ""
1054 }
1055 if (!channelId) throw new Error("channel_id is required (or provide source_item_id that maps to one)")
1056
1057 const content = String(params.content ?? "").trim()
1058 if (!content) throw new Error("content is required")
1059
1060 const replyMode = normalizeReplyMode(params.replyMode)
1061 const explicitSourceItemId = params.sourceItemId?.trim() ? params.sourceItemId.trim() : null
1062 const inferredSourceItemId = explicitSourceItemId ? null : inferPendingDmItemId(channelId)
1063 const resolvedSourceItemId = explicitSourceItemId ?? inferredSourceItemId
1064 const referenceMessageId = await chooseMessageReference({
1065 channelId,
1066 replyMode,
1067 sourceItemId: resolvedSourceItemId ?? undefined,
1068 referenceMessageId: params.referenceMessageId,
1069 })
1070
1071 const rest = makeRestClient()
1072 const botUserId = await getBotUserId(rest)
1073
1074 const message = (await rest.post(Routes.channelMessages(channelId), {
1075 body: {
1076 content,
1077 ...(referenceMessageId
1078 ? {
1079 message_reference: {
1080 message_id: referenceMessageId,
1081 channel_id: channelId,
1082 fail_if_not_exists: false,
1083 },
1084 allowed_mentions: { replied_user: false },
1085 }
1086 : {}),
1087 },
1088 })) as DiscordObject
1089
1090 const ingest = ingestDiscordEvent(
1091 {
1092 message,
1093 channel: {
1094 id: channelId,
1095 type: message.type,
1096 guild_id: message.guild_id,
1097 },
1098 author_is_bot: true,
1099 },
1100 { botUserId },
1101 )
1102
1103 if (resolvedSourceItemId) {
1104 const wasInferred = !explicitSourceItemId
1105 markDiscordItem(
1106 resolvedSourceItemId,
1107 "acted",
1108 `responded via discord_send (${replyMode}${referenceMessageId ? ", explicit" : ", plain"}${wasInferred ? ", inferred_dm_item" : ""})`,
1109 referenceMessageId ? "replied" : "messaged",
1110 )
1111 }
1112
1113 return {
1114 ok: true,
1115 sent_message_id: asString(message.id),
1116 channel_id: channelId,
1117 reply_mode: replyMode,
1118 used_reference_message_id: referenceMessageId,
1119 resolved_source_item_id: resolvedSourceItemId,
1120 inferred_source_item_id: inferredSourceItemId,
1121 stored: ingest.stored,
1122 }
1123}