Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
1107 lines
39 KiB
TypeScript
1107 lines
39 KiB
TypeScript
import { SqliteDatabase, type SqliteRow, type SqliteStatement } from '@snowluma/sqlite';
|
|
import { createLogger } from '@snowluma/common/logger';
|
|
import type { JsonObject, MessageMeta } from './types';
|
|
import type { MessageElement } from '@snowluma/protocol/events';
|
|
import {
|
|
cachedQuotePreview,
|
|
isUsableQuoteTime,
|
|
PRIVATE_QUOTE_MAX_DRIFT_SECONDS,
|
|
selectPrivateQuote,
|
|
type PrivateQuoteSource,
|
|
} from './private-quote';
|
|
import { openSqliteDb } from './sqlite-open';
|
|
import {
|
|
mergeMessageMedia,
|
|
parseMessageMedia,
|
|
serializeMessageMedia,
|
|
snapshotMessageEvent,
|
|
type MessageMedia,
|
|
} from './message-media';
|
|
import { createMessageStoreIndexes, prepareMessageStoreSchema } from './message-store-migration';
|
|
|
|
const log = createLogger('OneBot.MessageStore');
|
|
|
|
export interface ReadSessionTargets {
|
|
groupIds: number[];
|
|
privateUserIds: number[];
|
|
}
|
|
|
|
export interface StoreEventOptions {
|
|
/** False for locally reconstructed events whose sequence QQ never confirmed. */
|
|
sequenceAuthoritative?: boolean;
|
|
}
|
|
|
|
export interface StoreHistoryEventOptions extends StoreEventOptions {
|
|
/**
|
|
* Private history metadata that must become visible atomically with the
|
|
* event body. Group history does not require a separate metadata write.
|
|
*/
|
|
meta?: MessageMeta;
|
|
}
|
|
|
|
export class MessageStore {
|
|
private readonly db: SqliteDatabase;
|
|
private stmtStoreEvent!: SqliteStatement;
|
|
private stmtStoreMeta!: SqliteStatement;
|
|
private stmtFindEvent!: SqliteStatement;
|
|
private stmtFindMedia!: SqliteStatement;
|
|
private stmtUpdateMedia!: SqliteStatement;
|
|
private stmtFindMeta!: SqliteStatement;
|
|
private stmtResolveReplyGroup!: SqliteStatement;
|
|
private stmtResolveReplyPrivate!: SqliteStatement;
|
|
private stmtListEventsAnchored!: SqliteStatement;
|
|
private stmtListEventsAnchoredForward!: SqliteStatement;
|
|
private stmtListEventsLatest!: SqliteStatement;
|
|
private stmtFindLatestAuthoritativeSequence!: SqliteStatement;
|
|
private stmtFindLatestPersistedAuthoritativeSequence!: SqliteStatement;
|
|
private stmtListIncomingC2CSessions!: SqliteStatement;
|
|
private stmtFindPrivateRecall!: SqliteStatement;
|
|
private stmtFindPrivateReplyBySequence!: SqliteStatement;
|
|
private stmtFindPrivateReplyByTime!: SqliteStatement;
|
|
private stmtFindPrivateRecallTombstone!: SqliteStatement;
|
|
private stmtUpsertPrivateRecallTombstone!: SqliteStatement;
|
|
private stmtDeleteMessage!: SqliteStatement;
|
|
|
|
readonly initialization: Promise<void>;
|
|
|
|
constructor(dbPath: string) {
|
|
// Replace .json extension with .db if present
|
|
const finalPath = dbPath.replace(/\.json$/, '.db');
|
|
this.db = openSqliteDb(finalPath);
|
|
this.initialization = this.db.serial(async () => {
|
|
await this.initSchema();
|
|
|
|
// Prepare once. Statements survive for the lifetime of the
|
|
// Database instance — `close()` finalizes them automatically.
|
|
this.stmtStoreEvent = this.db.prepare(
|
|
`INSERT INTO messages
|
|
(message_hash, is_group, session_id, sequence, sequence_authoritative, event_name, client_sequence, private_direction, random, timestamp, data, media_data, classification_version)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, ?, 1)
|
|
ON CONFLICT(message_hash) DO UPDATE SET
|
|
is_group = excluded.is_group,
|
|
session_id = excluded.session_id,
|
|
sequence = CASE WHEN excluded.is_group = 1 THEN excluded.sequence ELSE messages.sequence END,
|
|
sequence_authoritative = CASE WHEN excluded.is_group = 1 THEN excluded.sequence_authoritative ELSE messages.sequence_authoritative END,
|
|
event_name = CASE
|
|
WHEN excluded.is_group = 1 THEN excluded.event_name
|
|
ELSE messages.event_name
|
|
END,
|
|
private_direction = CASE
|
|
WHEN excluded.private_direction >= 0 THEN excluded.private_direction
|
|
ELSE messages.private_direction
|
|
END,
|
|
timestamp = excluded.timestamp,
|
|
data = excluded.data,
|
|
media_data = excluded.media_data,
|
|
classification_version = CASE
|
|
WHEN excluded.is_group = 1 THEN excluded.classification_version
|
|
ELSE messages.classification_version
|
|
END`,
|
|
);
|
|
|
|
this.stmtStoreMeta = this.db.prepare(
|
|
`INSERT INTO messages
|
|
(message_hash, is_group, session_id, sequence, sequence_authoritative, event_name, client_sequence, private_direction, random, timestamp, classification_version)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
ON CONFLICT(message_hash) DO UPDATE SET
|
|
is_group = excluded.is_group,
|
|
session_id = excluded.session_id,
|
|
sequence = excluded.sequence,
|
|
sequence_authoritative = excluded.sequence_authoritative,
|
|
event_name = excluded.event_name,
|
|
client_sequence = excluded.client_sequence,
|
|
private_direction = excluded.private_direction,
|
|
random = excluded.random,
|
|
timestamp = excluded.timestamp,
|
|
classification_version = excluded.classification_version`,
|
|
);
|
|
|
|
this.stmtFindEvent = this.db.prepare(
|
|
'SELECT data FROM messages WHERE message_hash = ? AND data IS NOT NULL',
|
|
);
|
|
|
|
this.stmtFindMedia = this.db.prepare(
|
|
'SELECT media_data FROM messages WHERE message_hash = ?',
|
|
);
|
|
this.stmtUpdateMedia = this.db.prepare(
|
|
`UPDATE messages SET data = ?, media_data = ?
|
|
WHERE message_hash = ? AND data = ? AND media_data IS ?`,
|
|
);
|
|
|
|
this.stmtFindMeta = this.db.prepare(
|
|
'SELECT is_group, session_id, sequence, sequence_authoritative, event_name, client_sequence, private_direction, random, timestamp FROM messages WHERE message_hash = ?',
|
|
);
|
|
|
|
this.stmtResolveReplyGroup = this.db.prepare(
|
|
`SELECT sequence
|
|
FROM messages
|
|
WHERE is_group = 1 AND session_id = ? AND message_hash = ? AND sequence_authoritative = 1
|
|
LIMIT 1`,
|
|
);
|
|
|
|
this.stmtResolveReplyPrivate = this.db.prepare(
|
|
`SELECT CASE
|
|
WHEN client_sequence > 0 THEN client_sequence
|
|
ELSE sequence
|
|
END AS sequence
|
|
FROM messages
|
|
WHERE is_group = 0 AND message_hash = ?
|
|
AND (client_sequence > 0 OR sequence_authoritative = 1)
|
|
LIMIT 1`,
|
|
);
|
|
|
|
this.stmtListEventsAnchored = this.db.prepare(
|
|
`SELECT data
|
|
FROM messages
|
|
WHERE is_group = ? AND session_id = ? AND data IS NOT NULL
|
|
AND sequence_authoritative = 1 AND sequence > 0 AND sequence <= ?
|
|
ORDER BY sequence DESC
|
|
LIMIT ?`,
|
|
);
|
|
|
|
this.stmtListEventsAnchoredForward = this.db.prepare(
|
|
`SELECT data
|
|
FROM messages
|
|
WHERE is_group = ? AND session_id = ? AND data IS NOT NULL
|
|
AND sequence_authoritative = 1 AND sequence > 0 AND sequence >= ?
|
|
ORDER BY sequence ASC
|
|
LIMIT ?`,
|
|
);
|
|
|
|
this.stmtListEventsLatest = this.db.prepare(
|
|
`SELECT data
|
|
FROM messages
|
|
WHERE is_group = ? AND session_id = ? AND data IS NOT NULL
|
|
AND sequence_authoritative = 1 AND sequence > 0
|
|
ORDER BY sequence DESC
|
|
LIMIT ?`,
|
|
);
|
|
|
|
this.stmtFindLatestAuthoritativeSequence = this.db.prepare(
|
|
`SELECT sequence
|
|
FROM messages
|
|
WHERE is_group = ? AND session_id = ? AND sequence_authoritative = 1 AND sequence > 0
|
|
ORDER BY sequence DESC
|
|
LIMIT 1`,
|
|
);
|
|
|
|
this.stmtFindLatestPersistedAuthoritativeSequence = this.db.prepare(
|
|
`SELECT sequence
|
|
FROM messages
|
|
WHERE is_group = ? AND session_id = ? AND data IS NOT NULL
|
|
AND sequence_authoritative = 1 AND sequence > 0
|
|
ORDER BY sequence DESC
|
|
LIMIT 1`,
|
|
);
|
|
|
|
this.stmtListIncomingC2CSessions = this.db.prepare(
|
|
`SELECT session_id
|
|
FROM messages
|
|
WHERE is_group = 0
|
|
AND data IS NOT NULL
|
|
AND json_extract(data, '$.post_type') = 'message'
|
|
AND json_extract(data, '$.message_type') = 'private'
|
|
AND json_extract(data, '$.sub_type') = 'friend'
|
|
GROUP BY session_id
|
|
ORDER BY session_id ASC`,
|
|
);
|
|
|
|
this.stmtFindPrivateRecall = this.db.prepare(
|
|
`SELECT message_hash
|
|
FROM messages
|
|
WHERE is_group = 0 AND session_id = ? AND client_sequence = ?
|
|
AND private_direction = ? AND timestamp <= ?
|
|
ORDER BY timestamp DESC, sequence DESC
|
|
LIMIT 1`,
|
|
);
|
|
|
|
this.stmtFindPrivateReplyBySequence = this.db.prepare(
|
|
`SELECT message_hash, sequence, sequence_authoritative, client_sequence, timestamp, data
|
|
FROM messages
|
|
WHERE is_group = 0 AND session_id = ? AND private_direction = ?
|
|
AND client_sequence = ? AND timestamp BETWEEN ? AND ?
|
|
UNION
|
|
SELECT message_hash, sequence, sequence_authoritative, client_sequence, timestamp, data
|
|
FROM messages
|
|
WHERE is_group = 0 AND session_id = ? AND private_direction = ?
|
|
AND sequence = ? AND sequence_authoritative = 1 AND timestamp BETWEEN ? AND ?`,
|
|
);
|
|
this.stmtFindPrivateReplyByTime = this.db.prepare(
|
|
`SELECT message_hash, sequence, sequence_authoritative, client_sequence, timestamp, data
|
|
FROM messages
|
|
WHERE is_group = 0 AND session_id = ? AND private_direction = ?
|
|
AND timestamp BETWEEN ? AND ?`,
|
|
);
|
|
|
|
this.stmtFindPrivateRecallTombstone = this.db.prepare(
|
|
`SELECT recalled_at
|
|
FROM private_recall_tombstones
|
|
WHERE session_id = ? AND client_sequence = ? AND private_direction = ?`,
|
|
);
|
|
|
|
this.stmtUpsertPrivateRecallTombstone = this.db.prepare(
|
|
`INSERT INTO private_recall_tombstones
|
|
(session_id, client_sequence, private_direction, recalled_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(session_id, client_sequence, private_direction) DO UPDATE SET
|
|
recalled_at = MAX(private_recall_tombstones.recalled_at, excluded.recalled_at)`,
|
|
);
|
|
|
|
this.stmtDeleteMessage = this.db.prepare('DELETE FROM messages WHERE message_hash = ?');
|
|
});
|
|
void this.initialization.catch(() => undefined);
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await this.initialization.catch(() => undefined);
|
|
await this.db.close();
|
|
}
|
|
|
|
async storeEvent(
|
|
messageId: number,
|
|
isGroup: boolean,
|
|
sessionId: number,
|
|
sequence: number,
|
|
eventName: string,
|
|
event: JsonObject,
|
|
options: StoreEventOptions = {},
|
|
): Promise<boolean> {
|
|
event = snapshotMessageEvent(event);
|
|
options = { ...options };
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!isValidMessageId(messageId)) {
|
|
return false;
|
|
}
|
|
|
|
const json = JSON.stringify(event);
|
|
const timestamp = toInt(event.time);
|
|
const privateDirection = eventPrivateDirection(isGroup, event);
|
|
|
|
if (privateDirection >= 0) {
|
|
const existing = (await this.stmtFindMeta.get(messageId)) as
|
|
{ client_sequence: number } | undefined;
|
|
const eventClientSequence = toInt(event.message_seq);
|
|
const clientSequence =
|
|
existing?.client_sequence && existing.client_sequence > 0
|
|
? existing.client_sequence
|
|
: eventClientSequence;
|
|
|
|
if (
|
|
clientSequence > 0 &&
|
|
(await this.isPrivateMessageRecalled(
|
|
sessionId,
|
|
clientSequence,
|
|
privateDirection === 1,
|
|
timestamp,
|
|
))
|
|
) {
|
|
await this.stmtDeleteMessage.run(messageId);
|
|
log.debug(
|
|
'suppressed recalled private event: peer=%d clientSeq=%d self=%s messageId=%d',
|
|
sessionId,
|
|
clientSequence,
|
|
String(privateDirection === 1),
|
|
messageId,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const previous = (await this.stmtFindMedia.get(messageId)) as
|
|
{ media_data: string | null } | undefined;
|
|
const media = mergeMessageMedia(
|
|
previous?.media_data ?? null,
|
|
serializeMessageMedia(event),
|
|
);
|
|
await this.stmtStoreEvent.run(
|
|
messageId,
|
|
isGroup ? 1 : 0,
|
|
sessionId,
|
|
sequence,
|
|
options.sequenceAuthoritative === false ? 0 : 1,
|
|
eventName,
|
|
privateDirection,
|
|
timestamp,
|
|
json,
|
|
media,
|
|
);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/** Enrich a read record without changing its identity or restoring a removed row. */
|
|
async updateMedia(
|
|
messageId: number,
|
|
expected: JsonObject,
|
|
updated: JsonObject,
|
|
): Promise<boolean> {
|
|
const before = JSON.stringify(expected);
|
|
updated = snapshotMessageEvent(updated);
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
const previous = (await this.stmtFindMedia.get(messageId)) as
|
|
{ media_data: string | null } | undefined;
|
|
const media = mergeMessageMedia(
|
|
previous?.media_data ?? null,
|
|
serializeMessageMedia(updated),
|
|
);
|
|
const result = await this.stmtUpdateMedia.run(
|
|
JSON.stringify(updated),
|
|
media,
|
|
messageId,
|
|
before,
|
|
previous?.media_data ?? null,
|
|
);
|
|
return result.changes === 1;
|
|
});
|
|
}
|
|
|
|
async findMedia(messageId: number): Promise<MessageMedia[] | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!isValidMessageId(messageId)) {
|
|
return null;
|
|
}
|
|
|
|
const row = (await this.stmtFindMedia.get(messageId)) as
|
|
{ media_data: string | null } | undefined;
|
|
return row?.media_data != null ? parseMessageMedia(row.media_data) : null;
|
|
});
|
|
}
|
|
|
|
async storeMeta(messageId: number, meta: MessageMeta): Promise<void> {
|
|
meta = { ...meta };
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!isValidMessageId(messageId)) {
|
|
return;
|
|
}
|
|
|
|
const privateDirection = encodePrivateDirection(meta.privateDirection);
|
|
|
|
if (
|
|
!meta.isGroup &&
|
|
privateDirection >= 0 &&
|
|
meta.clientSequence > 0 &&
|
|
(await this.isPrivateMessageRecalled(
|
|
meta.targetId,
|
|
meta.clientSequence,
|
|
privateDirection === 1,
|
|
meta.timestamp,
|
|
))
|
|
) {
|
|
await this.stmtDeleteMessage.run(messageId);
|
|
log.debug(
|
|
'suppressed recalled private meta: peer=%d clientSeq=%d self=%s messageId=%d',
|
|
meta.targetId,
|
|
meta.clientSequence,
|
|
String(privateDirection === 1),
|
|
messageId,
|
|
);
|
|
return;
|
|
}
|
|
|
|
await this.stmtStoreMeta.run(
|
|
messageId,
|
|
meta.isGroup ? 1 : 0,
|
|
meta.targetId,
|
|
meta.sequence,
|
|
meta.sequenceAuthoritative === false ? 0 : 1,
|
|
meta.eventName,
|
|
meta.clientSequence,
|
|
privateDirection,
|
|
meta.random,
|
|
meta.timestamp,
|
|
);
|
|
});
|
|
}
|
|
|
|
async storeMetas(
|
|
entries: ReadonlyArray<{ messageId: number; meta: MessageMeta }>,
|
|
): Promise<void> {
|
|
entries = structuredClone(entries);
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (entries.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await this.runInImmediateTransaction(async () => {
|
|
for (const { messageId, meta } of entries) {
|
|
await this.storeMeta(messageId, meta);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private async runInImmediateTransaction<T>(work: () => T | Promise<T>): Promise<T> {
|
|
await this.db.exec('BEGIN IMMEDIATE');
|
|
|
|
try {
|
|
const result = await work();
|
|
await this.db.exec('COMMIT');
|
|
return result;
|
|
} catch (error) {
|
|
try {
|
|
await this.db.exec('ROLLBACK');
|
|
} catch (rollbackError) {
|
|
const originalMessage = error instanceof Error ? error.message : String(error);
|
|
const rollbackMessage =
|
|
rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
throw new AggregateError(
|
|
[error, rollbackError],
|
|
`${originalMessage}; rollback also failed: ${rollbackMessage}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store one fetched history event.
|
|
*
|
|
* Private history needs both metadata and the event body. Keep those writes
|
|
* in one transaction so a failed body write can never leave an authoritative
|
|
* sequence that makes a later login skip the missing message.
|
|
*/
|
|
async storeHistoryEvent(
|
|
messageId: number,
|
|
isGroup: boolean,
|
|
sessionId: number,
|
|
sequence: number,
|
|
eventName: string,
|
|
event: JsonObject,
|
|
options: StoreHistoryEventOptions = {},
|
|
): Promise<boolean> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
const { meta, ...eventOptions } = options;
|
|
|
|
if (!meta) {
|
|
return this.storeEvent(
|
|
messageId,
|
|
isGroup,
|
|
sessionId,
|
|
sequence,
|
|
eventName,
|
|
event,
|
|
eventOptions,
|
|
);
|
|
}
|
|
|
|
return this.runInImmediateTransaction(async () => {
|
|
await this.storeMeta(messageId, meta);
|
|
return this.storeEvent(
|
|
messageId,
|
|
isGroup,
|
|
sessionId,
|
|
sequence,
|
|
eventName,
|
|
event,
|
|
eventOptions,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
async findEvent(messageId: number): Promise<JsonObject | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!isValidMessageId(messageId)) {
|
|
return null;
|
|
}
|
|
|
|
const row = (await this.stmtFindEvent.get(messageId)) as { data: string } | undefined;
|
|
|
|
if (!row?.data) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(row.data) as JsonObject;
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
async findMeta(messageId: number): Promise<MessageMeta | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!isValidMessageId(messageId)) {
|
|
return null;
|
|
}
|
|
|
|
const row = (await this.stmtFindMeta.get(messageId)) as
|
|
| {
|
|
is_group: number;
|
|
session_id: number;
|
|
sequence: number;
|
|
sequence_authoritative: number;
|
|
event_name: string;
|
|
client_sequence: number;
|
|
private_direction: number;
|
|
random: number;
|
|
timestamp: number;
|
|
}
|
|
| undefined;
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
isGroup: row.is_group === 1,
|
|
targetId: row.session_id,
|
|
sequence: row.sequence,
|
|
sequenceAuthoritative: row.sequence_authoritative === 1,
|
|
eventName: row.event_name,
|
|
clientSequence: row.client_sequence,
|
|
...(row.private_direction === 0
|
|
? { privateDirection: 'incoming' as const }
|
|
: row.private_direction === 1
|
|
? { privateDirection: 'outgoing' as const }
|
|
: {}),
|
|
random: row.random,
|
|
timestamp: row.timestamp,
|
|
};
|
|
});
|
|
}
|
|
|
|
async resolveReplySequence(
|
|
isGroup: boolean,
|
|
sessionId: number,
|
|
messageId: number,
|
|
): Promise<number | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!Number.isInteger(sessionId) || sessionId <= 0 || !isValidMessageId(messageId)) {
|
|
return null;
|
|
}
|
|
|
|
// For private messages, we cannot rely on session_id matching because:
|
|
// - When receiving: session_id is the sender's UIN
|
|
// - When sending reply: sessionId parameter is the recipient's UIN (who we're sending to)
|
|
// So for private messages, we only match by message_hash and is_group flag.
|
|
// Replies use ContentHead field 5 (sender-local client sequence), while
|
|
// `sequence` stores field 11 for bidirectional history pagination.
|
|
const row = isGroup
|
|
? ((await this.stmtResolveReplyGroup.get(sessionId, messageId)) as
|
|
{ sequence: number } | undefined)
|
|
: ((await this.stmtResolveReplyPrivate.get(messageId)) as
|
|
{ sequence: number } | undefined);
|
|
|
|
if (!row || !Number.isInteger(row.sequence) || row.sequence <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return row.sequence;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a sender-local C2C sequence to the stored OneBot id. Direction and
|
|
* timestamp are part of the key because QQ can reuse the same client sequence
|
|
* in the opposite direction and after a client restart.
|
|
*/
|
|
async findPrivateMessageId(
|
|
sessionId: number,
|
|
clientSequence: number,
|
|
sentBySelf: boolean,
|
|
atOrBefore?: number,
|
|
): Promise<number | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
const upperTimestamp = atOrBefore === undefined ? Number.MAX_SAFE_INTEGER : atOrBefore;
|
|
validatePrivateRecallKey(
|
|
sessionId,
|
|
clientSequence,
|
|
upperTimestamp,
|
|
'message lookup time',
|
|
);
|
|
const row = (await this.stmtFindPrivateRecall.get(
|
|
sessionId,
|
|
clientSequence,
|
|
sentBySelf ? 1 : 0,
|
|
upperTimestamp,
|
|
)) as { message_hash: number } | undefined;
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
if (!isValidMessageId(row.message_hash)) {
|
|
throw new Error(
|
|
`private message lookup matched invalid message id ${String(row.message_hash)}`,
|
|
);
|
|
}
|
|
|
|
return row.message_hash;
|
|
});
|
|
}
|
|
|
|
/** Resolve quotes independently of recall lookup: a reused sequence is not an identity. */
|
|
async resolvePrivateReplyMessageId(
|
|
sessionId: number,
|
|
replySequence: number,
|
|
sentBySelf: boolean,
|
|
timestamp?: number,
|
|
replyElements?: readonly MessageElement[],
|
|
excludedMessage?: PrivateQuoteSource,
|
|
): Promise<number | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
const hasTime = isUsableQuoteTime(timestamp);
|
|
const lower = hasTime ? timestamp - PRIVATE_QUOTE_MAX_DRIFT_SECONDS : 0;
|
|
const upper = hasTime
|
|
? timestamp + PRIVATE_QUOTE_MAX_DRIFT_SECONDS
|
|
: Number.MAX_SAFE_INTEGER;
|
|
const key = {
|
|
sequence: replySequence,
|
|
sentBySelf,
|
|
timestamp,
|
|
elements: replyElements,
|
|
exclude: excludedMessage,
|
|
};
|
|
const rows =
|
|
Number.isSafeInteger(replySequence) && replySequence > 0
|
|
? ((await this.stmtFindPrivateReplyBySequence.all(
|
|
sessionId,
|
|
sentBySelf ? 1 : 0,
|
|
replySequence,
|
|
lower,
|
|
upper,
|
|
sessionId,
|
|
sentBySelf ? 1 : 0,
|
|
replySequence,
|
|
lower,
|
|
upper,
|
|
)) as PrivateReplyRow[])
|
|
: [];
|
|
let selected = selectPrivateQuote(rows.map(privateReplyCandidate), key);
|
|
|
|
if (!selected.candidate && selected.reason !== 'ambiguous' && sentBySelf && hasTime) {
|
|
const nearby = (await this.stmtFindPrivateReplyByTime.all(
|
|
sessionId,
|
|
1,
|
|
lower,
|
|
upper,
|
|
)) as PrivateReplyRow[];
|
|
selected = selectPrivateQuote(nearby.map(privateReplyCandidate), key);
|
|
}
|
|
|
|
log.trace(
|
|
'private quote lookup: peer=%d seq=%d outgoing=%s time=%s result=%s messageId=%s',
|
|
sessionId,
|
|
replySequence,
|
|
sentBySelf,
|
|
timestamp,
|
|
selected.reason,
|
|
selected.candidate?.id ?? 'unknown',
|
|
);
|
|
return selected.candidate?.id ?? null;
|
|
});
|
|
}
|
|
|
|
async listSessionEvents(
|
|
isGroup: boolean,
|
|
sessionId: number,
|
|
count = 20,
|
|
anchorSequence?: number,
|
|
reverseOrder = true,
|
|
): Promise<JsonObject[]> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!Number.isInteger(sessionId) || sessionId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
const limit = normalizePositiveInt(count, 20, 200);
|
|
const hasAnchor = Number.isInteger(anchorSequence) && (anchorSequence as number) > 0;
|
|
const anchor = hasAnchor ? (anchorSequence as number) : 0;
|
|
|
|
const rows = hasAnchor
|
|
? reverseOrder
|
|
? await this.stmtListEventsAnchored.all(
|
|
isGroup ? 1 : 0,
|
|
sessionId,
|
|
anchor,
|
|
limit,
|
|
)
|
|
: await this.stmtListEventsAnchoredForward.all(
|
|
isGroup ? 1 : 0,
|
|
sessionId,
|
|
anchor,
|
|
limit,
|
|
)
|
|
: await this.stmtListEventsLatest.all(isGroup ? 1 : 0, sessionId, limit);
|
|
|
|
const result: JsonObject[] = [];
|
|
|
|
for (const row of rows as Array<{ data: string }>) {
|
|
if (!row?.data) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(row.data) as JsonObject;
|
|
result.push(parsed);
|
|
} catch {
|
|
// Ignore malformed rows to avoid breaking history APIs.
|
|
}
|
|
}
|
|
|
|
// Backward/latest queries are selected DESC, while forward queries already
|
|
// arrive ASC. Always expose chronological order to API consumers.
|
|
if (!hasAnchor || reverseOrder) {
|
|
result.reverse();
|
|
}
|
|
|
|
return result;
|
|
});
|
|
}
|
|
|
|
async findLatestAuthoritativeSequence(
|
|
isGroup: boolean,
|
|
sessionId: number,
|
|
): Promise<number | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!Number.isInteger(sessionId) || sessionId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const row = (await this.stmtFindLatestAuthoritativeSequence.get(
|
|
isGroup ? 1 : 0,
|
|
sessionId,
|
|
)) as { sequence: number } | undefined;
|
|
|
|
if (!row || !Number.isInteger(row.sequence) || row.sequence <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return row.sequence;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Return the latest server-confirmed sequence whose OneBot event body is
|
|
* actually present. Automatic history backfill must use this cursor so a
|
|
* metadata-only live row cannot hide a missing event body.
|
|
*/
|
|
async findLatestPersistedAuthoritativeSequence(
|
|
isGroup: boolean,
|
|
sessionId: number,
|
|
): Promise<number | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
|
|
if (!Number.isInteger(sessionId) || sessionId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const row = (await this.stmtFindLatestPersistedAuthoritativeSequence.get(
|
|
isGroup ? 1 : 0,
|
|
sessionId,
|
|
)) as { sequence: number } | undefined;
|
|
|
|
if (!row || !Number.isInteger(row.sequence) || row.sequence <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return row.sequence;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a private recall's sender-local client sequence to the OneBot
|
|
* message id and remove the cached row. The protocol recall notice does not
|
|
* carry the bidirectional NT sequence, so this lookup must happen before the
|
|
* notice is converted.
|
|
*/
|
|
async recordPrivateRecall(
|
|
sessionId: number,
|
|
clientSequence: number,
|
|
recalledBySelf: boolean,
|
|
recalledAt: number,
|
|
): Promise<number | null> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
validatePrivateRecallKey(sessionId, clientSequence, recalledAt);
|
|
const direction = recalledBySelf ? 1 : 0;
|
|
|
|
const removedMessageId = await this.runInImmediateTransaction(async () => {
|
|
await this.stmtUpsertPrivateRecallTombstone.run(
|
|
sessionId,
|
|
clientSequence,
|
|
direction,
|
|
recalledAt,
|
|
);
|
|
const row = (await this.stmtFindPrivateRecall.get(
|
|
sessionId,
|
|
clientSequence,
|
|
direction,
|
|
recalledAt,
|
|
)) as { message_hash: number } | undefined;
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
if (!isValidMessageId(row.message_hash)) {
|
|
throw new Error(
|
|
`private recall matched invalid message id ${String(row.message_hash)}`,
|
|
);
|
|
}
|
|
|
|
const deleted = await this.stmtDeleteMessage.run(row.message_hash);
|
|
|
|
if (Number(deleted.changes) !== 1) {
|
|
throw new Error(
|
|
`private recall cache deletion lost row message=${row.message_hash} peer=${sessionId} clientSeq=${clientSequence}`,
|
|
);
|
|
}
|
|
|
|
return row.message_hash;
|
|
});
|
|
|
|
if (removedMessageId !== null) {
|
|
log.debug(
|
|
'private recall removed cached message: peer=%d clientSeq=%d self=%s messageId=%d',
|
|
sessionId,
|
|
clientSequence,
|
|
String(recalledBySelf),
|
|
removedMessageId,
|
|
);
|
|
}
|
|
|
|
return removedMessageId;
|
|
});
|
|
}
|
|
|
|
async isPrivateMessageRecalled(
|
|
sessionId: number,
|
|
clientSequence: number,
|
|
sentBySelf: boolean,
|
|
messageTime: number,
|
|
): Promise<boolean> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
validatePrivateRecallKey(sessionId, clientSequence, messageTime, 'message time');
|
|
const row = (await this.stmtFindPrivateRecallTombstone.get(
|
|
sessionId,
|
|
clientSequence,
|
|
sentBySelf ? 1 : 0,
|
|
)) as { recalled_at: number } | undefined;
|
|
return row !== undefined && row.recalled_at >= messageTime;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build read-report targets from current groups plus genuine incoming C2C
|
|
* sessions. `is_group = 0` alone is insufficient: group temp sessions use
|
|
* the same storage lane, and sent-message echoes may be keyed by self UIN.
|
|
* Only an incoming `sub_type=friend` event can represent unread C2C state.
|
|
*/
|
|
async listReadSessions(currentGroupIds: readonly number[]): Promise<ReadSessionTargets> {
|
|
return this.db.serial(async () => {
|
|
await this.initialization;
|
|
const groupIds: number[] = [];
|
|
const seenGroups = new Set<number>();
|
|
|
|
for (const groupId of currentGroupIds) {
|
|
if (!Number.isSafeInteger(groupId) || groupId <= 0) {
|
|
throw new Error(
|
|
`current group list contains invalid group id ${String(groupId)}`,
|
|
);
|
|
}
|
|
|
|
if (!seenGroups.has(groupId)) {
|
|
seenGroups.add(groupId);
|
|
groupIds.push(groupId);
|
|
}
|
|
}
|
|
|
|
const rows = (await this.stmtListIncomingC2CSessions.all()) as Array<{
|
|
session_id: number;
|
|
}>;
|
|
const privateUserIds: number[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (!Number.isSafeInteger(row.session_id) || row.session_id <= 0) {
|
|
throw new Error(
|
|
`messages database contains invalid session id ${String(row.session_id)}`,
|
|
);
|
|
}
|
|
|
|
privateUserIds.push(row.session_id);
|
|
}
|
|
|
|
return { groupIds, privateUserIds };
|
|
});
|
|
}
|
|
|
|
private async initSchema(): Promise<void> {
|
|
const messagesTableExists =
|
|
(await this.db
|
|
.prepare(
|
|
`
|
|
SELECT 1 AS present
|
|
FROM sqlite_schema
|
|
WHERE type = 'table' AND name = 'messages'
|
|
`,
|
|
)
|
|
.get()) !== undefined;
|
|
await this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
message_hash INTEGER PRIMARY KEY,
|
|
is_group INTEGER NOT NULL,
|
|
session_id INTEGER NOT NULL,
|
|
sequence INTEGER NOT NULL,
|
|
sequence_authoritative INTEGER NOT NULL DEFAULT 1,
|
|
event_name TEXT NOT NULL,
|
|
client_sequence INTEGER NOT NULL DEFAULT 0,
|
|
private_direction INTEGER NOT NULL DEFAULT -1,
|
|
random INTEGER NOT NULL DEFAULT 0,
|
|
timestamp INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT,
|
|
classification_version INTEGER NOT NULL DEFAULT 1
|
|
)
|
|
`);
|
|
await prepareMessageStoreSchema(this.db);
|
|
await this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS private_recall_tombstones (
|
|
session_id INTEGER NOT NULL,
|
|
client_sequence INTEGER NOT NULL,
|
|
private_direction INTEGER NOT NULL CHECK(private_direction IN (0, 1)),
|
|
recalled_at INTEGER NOT NULL,
|
|
PRIMARY KEY (session_id, client_sequence, private_direction)
|
|
)
|
|
`);
|
|
|
|
if (!messagesTableExists) {
|
|
await createMessageStoreIndexes(this.db);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isValidMessageId(messageId: number): boolean {
|
|
return Number.isInteger(messageId) && messageId !== 0;
|
|
}
|
|
|
|
function encodePrivateDirection(direction: MessageMeta['privateDirection']): number {
|
|
if (direction === 'incoming') {
|
|
return 0;
|
|
}
|
|
|
|
if (direction === 'outgoing') {
|
|
return 1;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function eventPrivateDirection(isGroup: boolean, event: JsonObject): number {
|
|
if (isGroup || event.message_type !== 'private' || event.sub_type !== 'friend') {
|
|
return -1;
|
|
}
|
|
|
|
if (event.post_type === 'message') {
|
|
return 0;
|
|
}
|
|
|
|
if (event.post_type === 'message_sent') {
|
|
return 1;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
interface PrivateReplyRow extends SqliteRow {
|
|
message_hash: number;
|
|
sequence: number;
|
|
sequence_authoritative: number;
|
|
client_sequence: number;
|
|
timestamp: number;
|
|
data: string | null;
|
|
}
|
|
|
|
function privateReplyCandidate(row: PrivateReplyRow) {
|
|
if (!isValidMessageId(row.message_hash)) {
|
|
throw new Error(`private quote matched invalid message id ${String(row.message_hash)}`);
|
|
}
|
|
|
|
return {
|
|
id: row.message_hash,
|
|
sequence: row.sequence_authoritative === 1 ? row.sequence : 0,
|
|
clientSequence: row.client_sequence,
|
|
timestamp: row.timestamp,
|
|
preview: cachedQuotePreview(row.data),
|
|
};
|
|
}
|
|
|
|
function validatePrivateRecallKey(
|
|
sessionId: number,
|
|
clientSequence: number,
|
|
timestamp: number,
|
|
timestampLabel = 'recall time',
|
|
): void {
|
|
if (!Number.isSafeInteger(sessionId) || sessionId <= 0) {
|
|
throw new Error(`invalid private recall peer ${String(sessionId)}`);
|
|
}
|
|
|
|
if (!Number.isSafeInteger(clientSequence) || clientSequence <= 0) {
|
|
throw new Error(`invalid private recall client sequence ${String(clientSequence)}`);
|
|
}
|
|
|
|
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
throw new Error(`invalid private recall ${timestampLabel} ${String(timestamp)}`);
|
|
}
|
|
}
|
|
|
|
function toInt(value: unknown): number {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return Math.trunc(value);
|
|
}
|
|
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = Number(value);
|
|
|
|
if (Number.isFinite(parsed)) {
|
|
return Math.trunc(parsed);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function normalizePositiveInt(value: number, fallback: number, max: number): number {
|
|
if (!Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
|
|
const n = Math.trunc(value);
|
|
|
|
if (n <= 0) {
|
|
return fallback;
|
|
}
|
|
|
|
if (n > max) {
|
|
return max;
|
|
}
|
|
|
|
return n;
|
|
}
|