386 lines
10 KiB
TypeScript
386 lines
10 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
|
|
import { NewMessage } from '../types.js';
|
|
|
|
export interface ChatInfo {
|
|
jid: string;
|
|
name: string;
|
|
last_message_time: string;
|
|
channel: string;
|
|
is_group: number;
|
|
}
|
|
|
|
function normalizeMessageRow(
|
|
row: NewMessage & {
|
|
is_from_me?: boolean | number;
|
|
is_bot_message?: boolean | number;
|
|
},
|
|
): NewMessage {
|
|
return {
|
|
...row,
|
|
is_from_me: !!row.is_from_me,
|
|
is_bot_message: !!row.is_bot_message,
|
|
};
|
|
}
|
|
|
|
export function normalizeSeqCursor(
|
|
cursor: string | number | null | undefined,
|
|
): number {
|
|
if (typeof cursor === 'number') {
|
|
return Number.isFinite(cursor) && cursor > 0 ? cursor : 0;
|
|
}
|
|
if (!cursor) return 0;
|
|
const parsed = Number.parseInt(cursor, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
}
|
|
|
|
export function storeChatMetadataInDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
timestamp: string,
|
|
name?: string,
|
|
channel?: string,
|
|
isGroup?: boolean,
|
|
): void {
|
|
const ch = channel ?? null;
|
|
const group = isGroup === undefined ? null : isGroup ? 1 : 0;
|
|
|
|
if (name) {
|
|
database
|
|
.prepare(
|
|
`
|
|
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(jid) DO UPDATE SET
|
|
name = excluded.name,
|
|
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
|
channel = COALESCE(excluded.channel, channel),
|
|
is_group = COALESCE(excluded.is_group, is_group)
|
|
`,
|
|
)
|
|
.run(chatJid, name, timestamp, ch, group);
|
|
} else {
|
|
database
|
|
.prepare(
|
|
`
|
|
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(jid) DO UPDATE SET
|
|
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
|
channel = COALESCE(excluded.channel, channel),
|
|
is_group = COALESCE(excluded.is_group, is_group)
|
|
`,
|
|
)
|
|
.run(chatJid, chatJid, timestamp, ch, group);
|
|
}
|
|
}
|
|
|
|
export function getAllChatsFromDatabase(database: Database): ChatInfo[] {
|
|
return database
|
|
.prepare(
|
|
`
|
|
SELECT jid, name, last_message_time, channel, is_group
|
|
FROM chats
|
|
ORDER BY last_message_time DESC
|
|
`,
|
|
)
|
|
.all() as ChatInfo[];
|
|
}
|
|
|
|
export function storeMessageInDatabase(
|
|
database: Database,
|
|
msg: NewMessage,
|
|
): void {
|
|
const nextSeq = () => {
|
|
database.prepare('INSERT INTO message_sequence DEFAULT VALUES').run();
|
|
return (
|
|
database.prepare('SELECT last_insert_rowid() as id').get() as {
|
|
id: number;
|
|
}
|
|
).id;
|
|
};
|
|
|
|
database.transaction(() => {
|
|
const existing = database
|
|
.prepare('SELECT seq FROM messages WHERE id = ? AND chat_jid = ?')
|
|
.get(msg.id, msg.chat_jid) as { seq: number | null } | undefined;
|
|
const seq = existing?.seq ?? nextSeq();
|
|
database
|
|
.prepare(
|
|
`INSERT INTO messages (
|
|
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id, chat_jid) DO UPDATE SET
|
|
sender = excluded.sender,
|
|
sender_name = excluded.sender_name,
|
|
content = excluded.content,
|
|
timestamp = excluded.timestamp,
|
|
is_from_me = excluded.is_from_me,
|
|
is_bot_message = excluded.is_bot_message`,
|
|
)
|
|
.run(
|
|
msg.id,
|
|
msg.chat_jid,
|
|
msg.sender,
|
|
msg.sender_name,
|
|
msg.content,
|
|
msg.timestamp,
|
|
seq,
|
|
msg.is_from_me ? 1 : 0,
|
|
msg.is_bot_message ? 1 : 0,
|
|
);
|
|
})();
|
|
}
|
|
|
|
export function getNewMessagesFromDatabase(
|
|
database: Database,
|
|
jids: string[],
|
|
lastTimestamp: string,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): { messages: NewMessage[]; newTimestamp: string } {
|
|
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
|
|
|
const placeholders = jids.map(() => '?').join(',');
|
|
const sql = `
|
|
SELECT * FROM (
|
|
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
|
FROM messages
|
|
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
|
AND content NOT LIKE ?
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
) ORDER BY timestamp
|
|
`;
|
|
|
|
const rows = database
|
|
.prepare(sql)
|
|
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as Array<
|
|
NewMessage & {
|
|
is_from_me?: boolean | number;
|
|
is_bot_message?: boolean | number;
|
|
}
|
|
>;
|
|
|
|
let newTimestamp = lastTimestamp;
|
|
for (const row of rows) {
|
|
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
|
|
}
|
|
|
|
return { messages: rows.map(normalizeMessageRow), newTimestamp };
|
|
}
|
|
|
|
export function getMessagesSinceFromDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
sinceTimestamp: string,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): NewMessage[] {
|
|
const sql = `
|
|
SELECT * FROM (
|
|
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
|
FROM messages
|
|
WHERE chat_jid = ? AND timestamp > ?
|
|
AND content NOT LIKE ?
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
) ORDER BY timestamp
|
|
`;
|
|
const rows = database
|
|
.prepare(sql)
|
|
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as Array<
|
|
NewMessage & {
|
|
is_from_me?: boolean | number;
|
|
is_bot_message?: boolean | number;
|
|
}
|
|
>;
|
|
return rows.map(normalizeMessageRow);
|
|
}
|
|
|
|
export function getLatestMessageSeqAtOrBeforeFromDatabase(
|
|
database: Database,
|
|
timestamp: string,
|
|
chatJid?: string,
|
|
): number {
|
|
if (!timestamp) return 0;
|
|
const row = (
|
|
chatJid
|
|
? database
|
|
.prepare(
|
|
`SELECT COALESCE(MAX(seq), 0) AS maxSeq
|
|
FROM messages
|
|
WHERE chat_jid = ? AND timestamp <= ?`,
|
|
)
|
|
.get(chatJid, timestamp)
|
|
: database
|
|
.prepare(
|
|
`SELECT COALESCE(MAX(seq), 0) AS maxSeq
|
|
FROM messages
|
|
WHERE timestamp <= ?`,
|
|
)
|
|
.get(timestamp)
|
|
) as { maxSeq: number | null };
|
|
return row.maxSeq ?? 0;
|
|
}
|
|
|
|
export function getNewMessagesBySeqFromDatabase(
|
|
database: Database,
|
|
jids: string[],
|
|
lastSeqCursor: string | number,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): { messages: NewMessage[]; newSeqCursor: string } {
|
|
const sinceSeq = normalizeSeqCursor(lastSeqCursor);
|
|
if (jids.length === 0) {
|
|
return { messages: [], newSeqCursor: String(sinceSeq) };
|
|
}
|
|
|
|
const placeholders = jids.map(() => '?').join(',');
|
|
const sql = `
|
|
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
|
FROM messages
|
|
WHERE seq > ? AND chat_jid IN (${placeholders})
|
|
AND content NOT LIKE ?
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY seq
|
|
LIMIT ?
|
|
`;
|
|
|
|
const rows = database
|
|
.prepare(sql)
|
|
.all(sinceSeq, ...jids, `${botPrefix}:%`, limit) as Array<
|
|
NewMessage & {
|
|
seq: number;
|
|
is_from_me?: boolean | number;
|
|
is_bot_message?: boolean | number;
|
|
}
|
|
>;
|
|
|
|
const lastSeq = rows.length > 0 ? rows[rows.length - 1].seq : sinceSeq;
|
|
return {
|
|
messages: rows.map(normalizeMessageRow),
|
|
newSeqCursor: String(lastSeq),
|
|
};
|
|
}
|
|
|
|
export function getMessagesSinceSeqFromDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
sinceSeqCursor: string | number,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): NewMessage[] {
|
|
const sinceSeq = normalizeSeqCursor(sinceSeqCursor);
|
|
const sql = `
|
|
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
|
FROM messages
|
|
WHERE chat_jid = ? AND seq > ?
|
|
AND content NOT LIKE ?
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY seq
|
|
LIMIT ?
|
|
`;
|
|
const rows = database
|
|
.prepare(sql)
|
|
.all(chatJid, sinceSeq, `${botPrefix}:%`, limit) as Array<
|
|
NewMessage & {
|
|
seq: number;
|
|
is_from_me?: boolean | number;
|
|
is_bot_message?: boolean | number;
|
|
}
|
|
>;
|
|
return rows.map(normalizeMessageRow);
|
|
}
|
|
|
|
export function getRecentChatMessagesFromDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
limit: number = 20,
|
|
): NewMessage[] {
|
|
const sql = `
|
|
SELECT * FROM (
|
|
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
|
FROM messages
|
|
WHERE chat_jid = ?
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
) ORDER BY timestamp
|
|
`;
|
|
const rows = database.prepare(sql).all(chatJid, limit) as Array<
|
|
NewMessage & {
|
|
is_from_me?: boolean | number;
|
|
is_bot_message?: boolean | number;
|
|
}
|
|
>;
|
|
return rows.map(normalizeMessageRow);
|
|
}
|
|
|
|
export function getLastHumanMessageTimestampFromDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
): string | null {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT timestamp FROM messages
|
|
WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY timestamp DESC LIMIT 1`,
|
|
)
|
|
.get(chatJid) as { timestamp: string } | undefined;
|
|
return row?.timestamp ?? null;
|
|
}
|
|
|
|
export function getLastHumanMessageSenderFromDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
): string | null {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT sender FROM messages
|
|
WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY timestamp DESC, seq DESC LIMIT 1`,
|
|
)
|
|
.get(chatJid) as { sender: string } | undefined;
|
|
return row?.sender ?? null;
|
|
}
|
|
|
|
export function getLastHumanMessageContentFromDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
): string | null {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT content FROM messages
|
|
WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0
|
|
AND content != '' AND content IS NOT NULL
|
|
ORDER BY timestamp DESC, seq DESC LIMIT 1`,
|
|
)
|
|
.get(chatJid) as { content: string } | undefined;
|
|
return row?.content ?? null;
|
|
}
|
|
|
|
export function hasRecentRestartAnnouncementInDatabase(
|
|
database: Database,
|
|
chatJid: string,
|
|
sinceTimestamp: string,
|
|
): boolean {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT 1 FROM messages
|
|
WHERE chat_jid = ?
|
|
AND timestamp >= ?
|
|
AND is_bot_message = 1
|
|
AND (
|
|
content LIKE '재시작 완료.%'
|
|
OR content LIKE '재시작 감지.%'
|
|
OR content LIKE '서비스 재시작으로 이전 작업이 중단됐습니다.%'
|
|
)
|
|
LIMIT 1`,
|
|
)
|
|
.get(chatJid, sinceTimestamp) as { 1: number } | undefined;
|
|
return !!row;
|
|
}
|