This repository has no description
0

Configure Feed

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

move all discord SQL into db.ts, state.ts is now pure orchestration

+548 -296
+496 -2
src/discord/db.ts
··· 278 278 return Number(dmMessageResult.changes ?? 0) + Number(dmChannelResult.changes ?? 0) + Number(guildMessageResult.changes ?? 0) 279 279 } 280 280 281 - // ── inbox queries ────────────────────────────────────────────────────── 281 + // ── inbox status types ───────────────────────────────────────────────── 282 282 283 - type InboxStatus = "pending" | "seen" | "acted" | "ignored" 283 + export type InboxStatus = "pending" | "seen" | "acted" | "ignored" 284 + export type InboxAction = "none" | "replied" | "messaged" | "dismissed" | "noted" 284 285 285 286 const VALID_STATUS = new Set<InboxStatus>(["pending", "seen", "acted", "ignored"]) 287 + export const VALID_ACTION = new Set<InboxAction>(["none", "replied", "messaged", "dismissed", "noted"]) 286 288 287 289 /** 288 290 * Normalizes a status filter input into a validated array of inbox statuses. ··· 430 432 431 433 return out 432 434 } 435 + 436 + // ── reads: existence checks ──────────────────────────────────────────── 437 + 438 + /** 439 + * Checks whether a message exists in the database. 440 + * 441 + * @param messageId - Discord message id. 442 + * @returns `true` when the message is already stored. 443 + */ 444 + export function messageExists(messageId: string): boolean { 445 + const db = getDb() 446 + const row = db 447 + .prepare(`select 1 as present from discord_messages where message_id = ?`) 448 + .get(messageId) as { present?: number } | undefined 449 + return Boolean(row) 450 + } 451 + 452 + /** 453 + * Looks up a message id for an inbox item. 454 + * 455 + * @param itemId - Inbox item id. 456 + * @returns Associated message id, or `null`. 457 + */ 458 + export function getItemMessageId(itemId: string): string | null { 459 + const db = getDb() 460 + const row = db 461 + .prepare(`select message_id from discord_items where item_id = ?`) 462 + .get(itemId) as { message_id?: string } | undefined 463 + return row?.message_id?.trim() || null 464 + } 465 + 466 + /** 467 + * Resolves a channel id from an inbox item id. 468 + * 469 + * @param itemId - Inbox item id. 470 + * @returns Channel id, or `null`. 471 + */ 472 + export function getItemChannelId(itemId: string): string | null { 473 + const db = getDb() 474 + const row = db 475 + .prepare( 476 + `select m.channel_id 477 + from discord_items i 478 + join discord_messages m on m.message_id = i.message_id 479 + where i.item_id = ?`, 480 + ) 481 + .get(itemId) as { channel_id?: string } | undefined 482 + return row?.channel_id?.trim() || null 483 + } 484 + 485 + // ── reads: inbox listing ─────────────────────────────────────────────── 486 + 487 + /** 488 + * Queries inbox items joined with their messages, filtered by status. 489 + * 490 + * @param statusList - Statuses to include. 491 + * @param limit - Maximum rows. 492 + * @returns Raw inbox rows. 493 + */ 494 + export function queryInboxItems(statusList: InboxStatus[], limit: number): unknown[] { 495 + const db = getDb() 496 + const placeholders = statusList.map(() => "?").join(", ") 497 + return db 498 + .prepare( 499 + `select 500 + i.item_id, 501 + i.message_id, 502 + i.bucket, 503 + i.status, 504 + i.action_taken, 505 + i.decision_note, 506 + i.first_seen_at, 507 + i.last_seen_at, 508 + m.channel_id, 509 + m.guild_id, 510 + m.author_id, 511 + m.author_username, 512 + m.content, 513 + m.created_at, 514 + m.is_dm, 515 + m.mentions_bot 516 + from discord_items i 517 + join discord_messages m on m.message_id = i.message_id 518 + where i.status in (${placeholders}) 519 + order by i.last_seen_at desc 520 + limit ?`, 521 + ) 522 + .all(...statusList, limit) 523 + } 524 + 525 + // ── reads: backread ──────────────────────────────────────────────────── 526 + 527 + export type BackreadRow = { 528 + message_id: string 529 + channel_id: string 530 + guild_id: string | null 531 + author_id: string | null 532 + author_username: string | null 533 + content: string 534 + created_at: string 535 + is_dm: number 536 + mentions_bot: number 537 + is_from_bot: number 538 + raw_json: string 539 + } 540 + 541 + /** 542 + * Queries message history for a channel, newest first, with optional cursor. 543 + * 544 + * @param channelId - Discord channel id. 545 + * @param before - Empty string or a message id cursor. 546 + * @param limit - Maximum rows. 547 + * @returns Message rows. 548 + */ 549 + export function queryChannelMessages(channelId: string, before: string, limit: number): BackreadRow[] { 550 + const db = getDb() 551 + return db 552 + .prepare( 553 + `select 554 + message_id, 555 + channel_id, 556 + guild_id, 557 + author_id, 558 + author_username, 559 + content, 560 + created_at, 561 + is_dm, 562 + mentions_bot, 563 + is_from_bot, 564 + raw_json 565 + from discord_messages 566 + where channel_id = ? 567 + and (? = '' or cast(message_id as integer) < cast(? as integer)) 568 + order by cast(message_id as integer) desc 569 + limit ?`, 570 + ) 571 + .all(channelId, before, before, limit) as BackreadRow[] 572 + } 573 + 574 + // ── writes: mark ─────────────────────────────────────────────────────── 575 + 576 + /** 577 + * Updates an inbox item's status, action, and note. 578 + * 579 + * @param itemId - Inbox item id. 580 + * @param status - New status. 581 + * @param action - Action taken. 582 + * @param note - Decision note (or `null`). 583 + */ 584 + export function updateInboxItem(itemId: string, status: InboxStatus, action: InboxAction, note: string | null): void { 585 + const db = getDb() 586 + const now = new Date().toISOString() 587 + db.prepare( 588 + `update discord_items 589 + set status = ?, action_taken = ?, decision_note = ?, last_decision_at = ?, last_seen_at = ? 590 + where item_id = ?`, 591 + ).run(status, action, note, now, now, itemId) 592 + } 593 + 594 + // ── reads: channels ──────────────────────────────────────────────────── 595 + 596 + /** 597 + * Lists configured and active DM channels. 598 + * 599 + * @param configuredIds - Resolved configured channel ids. 600 + * @returns Channel rows. 601 + */ 602 + export function queryChannels(configuredIds: string[]): unknown[] { 603 + const db = getDb() 604 + const configuredPlaceholders = configuredIds.map(() => "?").join(", ") 605 + const configuredClause = configuredIds.length > 0 ? `channel_id in (${configuredPlaceholders})` : "0" 606 + 607 + return db 608 + .prepare( 609 + `select 610 + channel_id, 611 + configured, 612 + guild_id, 613 + guild_name, 614 + channel_name, 615 + channel_type, 616 + is_dm, 617 + topic, 618 + note, 619 + last_note_at, 620 + last_seen_at 621 + from discord_channels 622 + where ${configuredClause} 623 + or ( 624 + is_dm = 1 625 + and exists ( 626 + select 1 from discord_messages m 627 + where m.channel_id = discord_channels.channel_id 628 + ) 629 + ) 630 + order by configured desc, coalesce(guild_name, ''), coalesce(channel_name, channel_id)`, 631 + ) 632 + .all(...configuredIds) 633 + } 634 + 635 + // ── writes: channel note ─────────────────────────────────────────────── 636 + 637 + /** 638 + * Sets or clears a channel note. 639 + * 640 + * @param channelId - Channel id. 641 + * @param note - Note text or `null` to clear. 642 + */ 643 + export function updateChannelNote(channelId: string, note: string | null): void { 644 + const db = getDb() 645 + const now = new Date().toISOString() 646 + db.prepare( 647 + `update discord_channels 648 + set note = ?, last_note_at = ?, last_seen_at = ? 649 + where channel_id = ?`, 650 + ).run(note, now, now, channelId) 651 + } 652 + 653 + /** 654 + * Reads channel metadata for the note response. 655 + * 656 + * @param channelId - Channel id. 657 + * @returns Channel row. 658 + */ 659 + export function getChannelRow(channelId: string): Record<string, unknown> | undefined { 660 + const db = getDb() 661 + return db 662 + .prepare( 663 + `select channel_id, configured, guild_id, guild_name, channel_name, note, last_note_at 664 + from discord_channels 665 + where channel_id = ?`, 666 + ) 667 + .get(channelId) as Record<string, unknown> | undefined 668 + } 669 + 670 + // ── reads: batch digest ──────────────────────────────────────────────── 671 + 672 + export type BatchMessageRow = { 673 + message_id: string 674 + channel_id: string 675 + guild_id: string | null 676 + author_username: string | null 677 + content: string 678 + created_at: string 679 + first_seen_at: string 680 + is_dm: number 681 + raw_json: string 682 + guild_name: string | null 683 + channel_name: string | null 684 + } 685 + 686 + export type BatchPendingRow = { 687 + item_id: string 688 + bucket: string 689 + channel_id: string 690 + guild_id: string | null 691 + author_username: string | null 692 + content: string 693 + created_at: string 694 + is_dm: number 695 + message_id: string 696 + raw_json: string 697 + guild_name: string | null 698 + channel_name: string | null 699 + } 700 + 701 + /** 702 + * Queries recent messages for the batch digest, optionally scoped to configured channels. 703 + * 704 + * @param opts - Query parameters. 705 + * @returns Message rows (may be one more than `limit` for truncation detection). 706 + */ 707 + export function queryBatchMessages(opts: { 708 + botUserId: string 709 + from: string 710 + configuredIds: string[] 711 + channelScopeClause: string 712 + limit: number 713 + }): BatchMessageRow[] { 714 + const db = getDb() 715 + return db 716 + .prepare( 717 + `select 718 + m.message_id, 719 + m.channel_id, 720 + m.guild_id, 721 + m.author_username, 722 + m.content, 723 + m.created_at, 724 + m.first_seen_at, 725 + m.is_dm, 726 + m.raw_json, 727 + c.guild_name, 728 + c.channel_name 729 + from discord_messages m 730 + left join discord_channels c on c.channel_id = m.channel_id 731 + left join discord_items i on i.message_id = m.message_id 732 + where (? = '' or coalesce(m.author_id, '') != ?) 733 + and m.first_seen_at > ? 734 + and (i.message_id is null or i.status = 'pending') 735 + ${opts.channelScopeClause} 736 + order by m.first_seen_at asc 737 + limit ?`, 738 + ) 739 + .all(opts.botUserId, opts.botUserId, opts.from, ...opts.configuredIds, opts.limit) as BatchMessageRow[] 740 + } 741 + 742 + /** 743 + * Counts pending inbox items, optionally scoped. 744 + * 745 + * @param opts - Query parameters. 746 + * @returns Pending count. 747 + */ 748 + export function countPendingInbox(opts: { 749 + botUserId: string 750 + configuredIds: string[] 751 + channelScopeClause: string 752 + }): number { 753 + const db = getDb() 754 + const row = db 755 + .prepare( 756 + `select count(*) as count 757 + from discord_items i 758 + join discord_messages m on m.message_id = i.message_id 759 + left join discord_channels c on c.channel_id = m.channel_id 760 + where i.status = 'pending' 761 + and (? = '' or coalesce(m.author_id, '') != ?) 762 + ${opts.channelScopeClause}`, 763 + ) 764 + .get(opts.botUserId, opts.botUserId, ...opts.configuredIds) as { count?: number } | undefined 765 + return row?.count ?? 0 766 + } 767 + 768 + /** 769 + * Queries pending inbox items for the batch digest preview. 770 + * 771 + * @param opts - Query parameters. 772 + * @returns Pending item rows. 773 + */ 774 + export function queryBatchPendingPreview(opts: { 775 + botUserId: string 776 + configuredIds: string[] 777 + channelScopeClause: string 778 + limit: number 779 + }): BatchPendingRow[] { 780 + const db = getDb() 781 + return db 782 + .prepare( 783 + `select 784 + i.item_id, 785 + i.bucket, 786 + m.channel_id, 787 + m.guild_id, 788 + m.author_username, 789 + m.content, 790 + m.created_at, 791 + m.is_dm, 792 + m.message_id, 793 + m.raw_json, 794 + c.guild_name, 795 + c.channel_name 796 + from discord_items i 797 + join discord_messages m on m.message_id = i.message_id 798 + left join discord_channels c on c.channel_id = m.channel_id 799 + where i.status = 'pending' 800 + and (? = '' or coalesce(m.author_id, '') != ?) 801 + ${opts.channelScopeClause} 802 + order by i.last_seen_at desc 803 + limit ?`, 804 + ) 805 + .all(opts.botUserId, opts.botUserId, ...opts.configuredIds, opts.limit) as BatchPendingRow[] 806 + } 807 + 808 + // ── reads: reference resolution ──────────────────────────────────────── 809 + 810 + /** 811 + * Checks whether a message exists by snowflake id. 812 + * 813 + * @param messageId - Discord message id. 814 + * @returns `true` when found. 815 + */ 816 + export function messageExistsById(messageId: string): boolean { 817 + const db = getDb() 818 + return Boolean(db.prepare(`select 1 from discord_messages where message_id = ?`).get(messageId)) 819 + } 820 + 821 + /** 822 + * Finds the most recent message in a channel matching content. 823 + * 824 + * @param channelId - Channel id. 825 + * @param contentPattern - SQL LIKE pattern. 826 + * @returns Message id or `null`. 827 + */ 828 + export function findMessageByContent(channelId: string, contentPattern: string): string | null { 829 + const db = getDb() 830 + const row = db 831 + .prepare( 832 + `select message_id from discord_messages 833 + where channel_id = ? and content like ? and is_from_bot = 0 834 + order by cast(message_id as integer) desc limit 1`, 835 + ) 836 + .get(channelId, contentPattern) as { message_id?: string } | undefined 837 + return row?.message_id ?? null 838 + } 839 + 840 + /** 841 + * Finds the most recent message in a channel by author username. 842 + * 843 + * @param channelId - Channel id. 844 + * @param usernamePattern - SQL LIKE pattern. 845 + * @returns Message id or `null`. 846 + */ 847 + export function findMessageByUsername(channelId: string, usernamePattern: string): string | null { 848 + const db = getDb() 849 + const row = db 850 + .prepare( 851 + `select message_id from discord_messages 852 + where channel_id = ? and author_username like ? and is_from_bot = 0 853 + order by cast(message_id as integer) desc limit 1`, 854 + ) 855 + .get(channelId, usernamePattern) as { message_id?: string } | undefined 856 + return row?.message_id ?? null 857 + } 858 + 859 + /** 860 + * Fetches a message record for reference resolution. 861 + * 862 + * @param messageId - Discord message id. 863 + * @returns Message row or `undefined`. 864 + */ 865 + export function getMessageForReference(messageId: string): { 866 + message_id?: string 867 + channel_id?: string 868 + author_id?: string | null 869 + created_at?: string 870 + } | undefined { 871 + const db = getDb() 872 + return db 873 + .prepare( 874 + `select message_id, channel_id, author_id, created_at 875 + from discord_messages 876 + where message_id = ?`, 877 + ) 878 + .get(messageId) as { message_id?: string; channel_id?: string; author_id?: string | null; created_at?: string } | undefined 879 + } 880 + 881 + /** 882 + * Counts messages in a channel after a given message from different authors. 883 + * 884 + * @param channelId - Channel id. 885 + * @param afterMessageId - Message id threshold. 886 + * @param excludeAuthorId - Author id to exclude. 887 + * @returns Count of intervening messages. 888 + */ 889 + export function countInterveningMessages(channelId: string, afterMessageId: string, excludeAuthorId: string): number { 890 + const db = getDb() 891 + const row = db 892 + .prepare( 893 + `select count(*) as count 894 + from discord_messages 895 + where channel_id = ? 896 + and cast(message_id as integer) > cast(? as integer) 897 + and is_from_bot = 0 898 + and coalesce(author_id, '') != coalesce(?, '')`, 899 + ) 900 + .get(channelId, afterMessageId, excludeAuthorId) as { count?: number } | undefined 901 + return row?.count ?? 0 902 + } 903 + 904 + /** 905 + * Finds the most recent pending DM inbox item for a channel. 906 + * 907 + * @param channelId - Channel id. 908 + * @returns Item id or `null`. 909 + */ 910 + export function findPendingDmItemId(channelId: string): string | null { 911 + const db = getDb() 912 + const row = db 913 + .prepare( 914 + `select i.item_id 915 + from discord_items i 916 + join discord_messages m on m.message_id = i.message_id 917 + where i.status = 'pending' 918 + and m.channel_id = ? 919 + and m.is_dm = 1 920 + and m.is_from_bot = 0 921 + order by cast(m.message_id as integer) desc 922 + limit 1`, 923 + ) 924 + .get(channelId) as { item_id?: string } | undefined 925 + return row?.item_id?.trim() || null 926 + }
+52 -294
src/discord/state.ts
··· 1 1 /** 2 2 * Discord inbox orchestration — ingest, batch digest, scanning, sending, and listing. 3 3 * 4 - * Public API remains identical; internal parsing, REST, and DB operations 5 - * are delegated to their respective modules. 4 + * All SQL lives in `./db`; this module owns formatting, REST calls, 5 + * and the high-level workflows that compose db + rest + parse primitives. 6 6 * 7 7 * @module discord/state 8 8 */ 9 9 10 10 import { Routes } from "discord.js" 11 - import { getDb } from "../db" 12 11 import { 13 12 asNumber, 14 - asObject, 15 13 asString, 16 - configuredChannelIdSet, 17 14 parseChannelIds, 18 15 parseChannelRecord, 19 16 parseMessageRecord, ··· 22 19 import { 23 20 autoDemoteStalePendingItems, 24 21 buildReplyTargetContextMap, 22 + countInterveningMessages, 23 + countPendingInbox, 25 24 ensureConfiguredChannelsMaterialized, 25 + findMessageByContent, 26 + findMessageByUsername, 27 + findPendingDmItemId, 28 + getChannelRow, 26 29 getDiscordMeta, 30 + getItemChannelId, 31 + getItemMessageId, 32 + getMessageForReference, 27 33 isConfiguredDiscordChannel, 34 + messageExists, 35 + messageExistsById, 28 36 normalizeStatuses, 37 + queryBatchMessages, 38 + queryBatchPendingPreview, 39 + queryChannels, 40 + queryChannelMessages, 41 + queryInboxItems, 42 + repairDiscordMessageChannelFlags, 29 43 setDiscordMeta, 44 + updateChannelNote, 45 + updateInboxItem, 30 46 upsertDiscordChannel, 31 47 upsertInboxItem, 32 48 upsertDiscordMessage, 33 - repairDiscordMessageChannelFlags, 49 + type BackreadRow, 50 + type BatchMessageRow, 51 + type BatchPendingRow, 34 52 type DiscordReplyContext, 53 + type InboxAction, 54 + type InboxStatus, 55 + VALID_ACTION, 35 56 } from "./db" 36 57 import { 37 58 errorMessage, ··· 103 124 return ` [reply_to ${author} msg/${reply.message_id}: ${JSON.stringify(reply.content)}]` 104 125 } 105 126 106 - function channelLabel(row: { 107 - is_dm: number 108 - guild_name: string | null 109 - guild_id: string | null 110 - channel_name: string | null 111 - channel_id: string 112 - }): string { 127 + function channelLabel(row: { is_dm: number; guild_name: string | null; guild_id: string | null; channel_name: string | null; channel_id: string }): string { 113 128 if (row.is_dm === 1) return `dm/${row.channel_id}` 114 129 const guild = row.guild_name ?? row.guild_id ?? "unknown-guild" 115 130 const channel = row.channel_name ?? row.channel_id ··· 141 156 return { stored: false, isNew: false, reason: "payload is missing message/channel identity" } 142 157 } 143 158 144 - const db = getDb() 145 - const exists = db 146 - .prepare(`select 1 as present from discord_messages where message_id = ?`) 147 - .get(record.messageId) as { present?: number } | undefined 148 - const isNew = !exists 159 + const isNew = !messageExists(record.messageId) 149 160 150 161 const channelRecord = parseChannelRecord(payload, { 151 162 channelId: record.channelId, ··· 193 204 * @returns Raw inbox rows. 194 205 */ 195 206 export function listDiscordInbox(limit = 20, statuses?: string[] | string): unknown[] { 196 - const db = getDb() 197 207 const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit) || 20)) 198 208 const statusList = normalizeStatuses(statuses) 199 - const placeholders = statusList.map(() => "?").join(", ") 200 - 201 - const stmt = db.prepare( 202 - `select 203 - i.item_id, 204 - i.message_id, 205 - i.bucket, 206 - i.status, 207 - i.action_taken, 208 - i.decision_note, 209 - i.first_seen_at, 210 - i.last_seen_at, 211 - m.channel_id, 212 - m.guild_id, 213 - m.author_id, 214 - m.author_username, 215 - m.content, 216 - m.created_at, 217 - m.is_dm, 218 - m.mentions_bot 219 - from discord_items i 220 - join discord_messages m on m.message_id = i.message_id 221 - where i.status in (${placeholders}) 222 - order by i.last_seen_at desc 223 - limit ?`, 224 - ) 225 - 226 - return stmt.all(...statusList, safeLimit) 209 + return queryInboxItems(statusList, safeLimit) 227 210 } 228 211 229 212 // ── backread ─────────────────────────────────────────────────────────── ··· 237 220 * @returns Message rows with resolved reply context. 238 221 */ 239 222 export function listDiscordBackread(channelId: string, limit = 40, beforeMessageId?: string): unknown[] { 240 - const db = getDb() 241 223 const safeChannelId = String(channelId ?? "").trim() 242 224 if (!safeChannelId) throw new Error("channel_id is required") 243 225 244 226 const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit) || 40)) 245 227 const before = String(beforeMessageId ?? "").trim() 246 - const rows = db 247 - .prepare( 248 - `select 249 - message_id, 250 - channel_id, 251 - guild_id, 252 - author_id, 253 - author_username, 254 - content, 255 - created_at, 256 - is_dm, 257 - mentions_bot, 258 - is_from_bot, 259 - raw_json 260 - from discord_messages 261 - where channel_id = ? 262 - and (? = '' or cast(message_id as integer) < cast(? as integer)) 263 - order by cast(message_id as integer) desc 264 - limit ?`, 265 - ) 266 - .all(safeChannelId, before, before, safeLimit) as Array<Record<string, unknown> & { message_id: string; raw_json: string }> 228 + const rows = queryChannelMessages(safeChannelId, before, safeLimit) 267 229 268 230 const replyByMessageId = buildReplyTargetContextMap(rows) 269 231 return rows.map(({ raw_json: _rawJson, ...row }) => ({ 270 232 ...row, 271 - created_at: formatHumanTimestamp(row.created_at as string | undefined), 233 + created_at: formatHumanTimestamp(row.created_at), 272 234 ...(replyByMessageId.has(row.message_id) ? { reply_to: replyByMessageId.get(row.message_id) } : {}), 273 235 })) 274 236 } 275 237 276 238 // ── mark ─────────────────────────────────────────────────────────────── 277 239 278 - type InboxStatus = "pending" | "seen" | "acted" | "ignored" 279 - type InboxAction = "none" | "replied" | "messaged" | "dismissed" | "noted" 280 - 281 - const VALID_ACTION = new Set<InboxAction>(["none", "replied", "messaged", "dismissed", "noted"]) 282 - 283 240 /** 284 241 * Updates decision state for a Discord inbox item. 285 242 * ··· 293 250 if (!safeItemId) throw new Error("item_id is required") 294 251 if (!["pending", "seen", "acted", "ignored"].includes(status)) throw new Error(`invalid status: ${status}`) 295 252 if (!VALID_ACTION.has(action)) throw new Error(`invalid action: ${action}`) 296 - 297 - const db = getDb() 298 - const now = new Date().toISOString() 299 - 300 - db.prepare( 301 - `update discord_items 302 - set status = ?, action_taken = ?, decision_note = ?, last_decision_at = ?, last_seen_at = ? 303 - where item_id = ?`, 304 - ).run(status, action, note || null, now, now, safeItemId) 253 + updateInboxItem(safeItemId, status, action, note || null) 305 254 } 306 255 307 256 // ── channels listing / notes ─────────────────────────────────────────── ··· 313 262 */ 314 263 export function listDiscordChannels(): unknown[] { 315 264 ensureConfiguredChannelsMaterialized() 316 - const db = getDb() 317 265 const configuredIds = parseChannelIds() 318 - const configuredPlaceholders = configuredIds.map(() => "?").join(", ") 319 - const configuredClause = configuredIds.length > 0 ? `channel_id in (${configuredPlaceholders})` : "0" 320 - 321 - return db 322 - .prepare( 323 - `select 324 - channel_id, 325 - configured, 326 - guild_id, 327 - guild_name, 328 - channel_name, 329 - channel_type, 330 - is_dm, 331 - topic, 332 - note, 333 - last_note_at, 334 - last_seen_at 335 - from discord_channels 336 - where ${configuredClause} 337 - or ( 338 - is_dm = 1 339 - and exists ( 340 - select 1 from discord_messages m 341 - where m.channel_id = discord_channels.channel_id 342 - ) 343 - ) 344 - order by configured desc, coalesce(guild_name, ''), coalesce(channel_name, channel_id)`, 345 - ) 346 - .all(...configuredIds) 266 + return queryChannels(configuredIds) 347 267 } 348 268 349 269 /** ··· 358 278 if (!safeChannelId) throw new Error("channel_id is required") 359 279 360 280 ensureConfiguredChannelsMaterialized([safeChannelId]) 361 - 362 - const db = getDb() 363 - const now = new Date().toISOString() 364 281 const trimmed = note.trim() 282 + updateChannelNote(safeChannelId, trimmed.length > 0 ? trimmed : null) 365 283 366 - db.prepare( 367 - `update discord_channels 368 - set note = ?, last_note_at = ?, last_seen_at = ? 369 - where channel_id = ?`, 370 - ).run(trimmed.length > 0 ? trimmed : null, now, now, safeChannelId) 371 - 372 - const row = db 373 - .prepare( 374 - `select channel_id, configured, guild_id, guild_name, channel_name, note, last_note_at 375 - from discord_channels 376 - where channel_id = ?`, 377 - ) 378 - .get(safeChannelId) as Record<string, unknown> | undefined 379 - 284 + const row = getChannelRow(safeChannelId) 380 285 return { 381 286 ok: true, 382 287 cleared: trimmed.length === 0, ··· 399 304 pendingPreviewLimit?: number 400 305 intervalMs?: number 401 306 }): DiscordBatchDigest | null { 402 - const db = getDb() 403 307 const now = new Date() 404 308 const nowIso = now.toISOString() 405 309 const defaultIntervalMs = Math.max( ··· 430 334 getDiscordMeta("discord_batch_last_dispatched_at") ?? 431 335 new Date(now.getTime() - intervalMs).toISOString() 432 336 433 - const messageRows = db 434 - .prepare( 435 - `select 436 - m.message_id, 437 - m.channel_id, 438 - m.guild_id, 439 - m.author_username, 440 - m.content, 441 - m.created_at, 442 - m.first_seen_at, 443 - m.is_dm, 444 - m.raw_json, 445 - c.guild_name, 446 - c.channel_name 447 - from discord_messages m 448 - left join discord_channels c on c.channel_id = m.channel_id 449 - left join discord_items i on i.message_id = m.message_id 450 - where (? = '' or coalesce(m.author_id, '') != ?) 451 - and m.first_seen_at > ? 452 - and (i.message_id is null or i.status = 'pending') 453 - ${channelScopeClause} 454 - order by m.first_seen_at asc 455 - limit ?`, 456 - ) 457 - .all(botUserId, botUserId, from, ...configuredIds, maxMessages + 1) as Array<{ 458 - message_id: string 459 - channel_id: string 460 - guild_id: string | null 461 - author_username: string | null 462 - content: string 463 - created_at: string 464 - first_seen_at: string 465 - is_dm: number 466 - raw_json: string 467 - guild_name: string | null 468 - channel_name: string | null 469 - }> 337 + const queryOpts = { botUserId, from, configuredIds, channelScopeClause } 338 + 339 + const messageRows = queryBatchMessages({ ...queryOpts, limit: maxMessages + 1 }) 470 340 471 341 if (messageRows.length === 0) return null 472 342 473 343 const truncated = messageRows.length > maxMessages 474 344 const recentMessages = truncated ? messageRows.slice(0, maxMessages) : messageRows 475 345 476 - const pendingCountRow = db 477 - .prepare( 478 - `select count(*) as count 479 - from discord_items i 480 - join discord_messages m on m.message_id = i.message_id 481 - left join discord_channels c on c.channel_id = m.channel_id 482 - where i.status = 'pending' 483 - and (? = '' or coalesce(m.author_id, '') != ?) 484 - ${channelScopeClause}`, 485 - ) 486 - .get(botUserId, botUserId, ...configuredIds) as { count?: number } | undefined 487 - const pendingCount = pendingCountRow?.count ?? 0 346 + const pendingCount = countPendingInbox(queryOpts) 488 347 489 - const pendingPreview = db 490 - .prepare( 491 - `select 492 - i.item_id, 493 - i.bucket, 494 - m.channel_id, 495 - m.guild_id, 496 - m.author_username, 497 - m.content, 498 - m.created_at, 499 - m.is_dm, 500 - m.message_id, 501 - m.raw_json, 502 - c.guild_name, 503 - c.channel_name 504 - from discord_items i 505 - join discord_messages m on m.message_id = i.message_id 506 - left join discord_channels c on c.channel_id = m.channel_id 507 - where i.status = 'pending' 508 - and (? = '' or coalesce(m.author_id, '') != ?) 509 - ${channelScopeClause} 510 - order by i.last_seen_at desc 511 - limit ?`, 512 - ) 513 - .all(botUserId, botUserId, ...configuredIds, previewLimit) as Array<{ 514 - item_id: string 515 - bucket: string 516 - channel_id: string 517 - guild_id: string | null 518 - author_username: string | null 519 - content: string 520 - created_at: string 521 - is_dm: number 522 - message_id: string 523 - raw_json: string 524 - guild_name: string | null 525 - channel_name: string | null 526 - }> 348 + const pendingPreview = queryBatchPendingPreview({ ...queryOpts, limit: previewLimit }) 527 349 528 350 const uniqueChannels = new Set(recentMessages.map((row) => row.channel_id)) 529 351 const replyContextByMessageId = buildReplyTargetContextMap([...recentMessages, ...pendingPreview]) ··· 585 407 function resolveReferenceMessage(channelId: string, sourceItemId?: string, referenceMessage?: string): string | null { 586 408 const ref = referenceMessage?.trim() 587 409 if (ref) { 588 - const db = getDb() 589 - 590 410 if (/^\d+$/.test(ref)) { 591 - const exists = db 592 - .prepare(`select 1 from discord_messages where message_id = ?`) 593 - .get(ref) 594 - return exists ? ref : null 411 + return messageExistsById(ref) ? ref : null 595 412 } 596 413 597 - const byContent = db 598 - .prepare( 599 - `select message_id from discord_messages 600 - where channel_id = ? and content like ? and is_from_bot = 0 601 - order by cast(message_id as integer) desc limit 1`, 602 - ) 603 - .get(channelId, `%${ref}%`) as { message_id?: string } | undefined 604 - if (byContent?.message_id) return byContent.message_id 414 + const byContent = findMessageByContent(channelId, `%${ref}%`) 415 + if (byContent) return byContent 605 416 606 - const byUsername = db 607 - .prepare( 608 - `select message_id from discord_messages 609 - where channel_id = ? and author_username like ? and is_from_bot = 0 610 - order by cast(message_id as integer) desc limit 1`, 611 - ) 612 - .get(channelId, `%${ref}%`) as { message_id?: string } | undefined 613 - if (byUsername?.message_id) return byUsername.message_id 417 + const byUsername = findMessageByUsername(channelId, `%${ref}%`) 418 + if (byUsername) return byUsername 614 419 615 420 return null 616 421 } 617 422 618 423 if (!sourceItemId?.trim()) return null 619 - 620 - const db = getDb() 621 - const row = db 622 - .prepare(`select message_id from discord_items where item_id = ?`) 623 - .get(sourceItemId.trim()) as { message_id?: string } | undefined 624 - 625 - return row?.message_id?.trim() ? row.message_id : null 424 + return getItemMessageId(sourceItemId.trim()) 626 425 } 627 426 628 427 const AUTO_REPLY_STALE_MINUTES = 10 629 428 630 429 function shouldUseExplicitReference(channelId: string, sourceMessageId: string): boolean { 631 - const db = getDb() 632 - const source = db 633 - .prepare( 634 - `select message_id, channel_id, author_id, created_at 635 - from discord_messages 636 - where message_id = ?`, 637 - ) 638 - .get(sourceMessageId) as { message_id?: string; channel_id?: string; author_id?: string | null; created_at?: string } | undefined 430 + const source = getMessageForReference(sourceMessageId) 639 431 640 432 if (!source?.message_id || !source.channel_id) return false 641 433 if (source.channel_id !== channelId) return false ··· 646 438 if (Date.now() - createdMs >= staleMs) return true 647 439 } 648 440 649 - const row = db 650 - .prepare( 651 - `select count(*) as count 652 - from discord_messages 653 - where channel_id = ? 654 - and cast(message_id as integer) > cast(? as integer) 655 - and is_from_bot = 0 656 - and coalesce(author_id, '') != coalesce(?, '')`, 657 - ) 658 - .get(channelId, sourceMessageId, source.author_id ?? "") as { count?: number } | undefined 659 - 660 - return (row?.count ?? 0) > 0 441 + const intervening = countInterveningMessages(channelId, sourceMessageId, source.author_id ?? "") 442 + return intervening > 0 661 443 } 662 444 663 445 async function chooseMessageReference(options: { ··· 676 458 } 677 459 678 460 function inferPendingDmItemId(channelId: string): string | null { 679 - const db = getDb() 680 - const row = db 681 - .prepare( 682 - `select i.item_id 683 - from discord_items i 684 - join discord_messages m on m.message_id = i.message_id 685 - where i.status = 'pending' 686 - and m.channel_id = ? 687 - and m.is_dm = 1 688 - and m.is_from_bot = 0 689 - order by cast(m.message_id as integer) desc 690 - limit 1`, 691 - ) 692 - .get(channelId) as { item_id?: string } | undefined 693 - 694 - return row?.item_id?.trim() ? row.item_id : null 461 + return findPendingDmItemId(channelId) 695 462 } 696 463 697 464 function normalizeReplyMode(value: unknown): ReplyMode { ··· 714 481 }): Promise<Record<string, unknown>> { 715 482 let channelId = String(params.channelId ?? "").trim() 716 483 if (!channelId && params.sourceItemId?.trim()) { 717 - const db = getDb() 718 - const row = db 719 - .prepare( 720 - `select m.channel_id 721 - from discord_items i 722 - join discord_messages m on m.message_id = i.message_id 723 - where i.item_id = ?`, 724 - ) 725 - .get(params.sourceItemId.trim()) as { channel_id?: string } | undefined 726 - channelId = row?.channel_id?.trim() ?? "" 484 + channelId = getItemChannelId(params.sourceItemId.trim()) ?? "" 727 485 } 728 486 if (!channelId) throw new Error("channel_id is required (or provide source_item_id that maps to one)") 729 487