my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 1123 lines 35 kB view raw
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}