src/telegram/noise.ts
Rama main · réplica de lectura del repositorio original.
/**
* What the bridge is allowed to interrupt you with.
*
* A catch-all address on a public domain grows noise until the operator mutes the
* whole bot, and a bridge nobody looks at is a bridge that died of success. The
* three answers here are deliberately narrow, and none of them ever stops mail
* from being *stored*: a muted sender, a night that batches into one summary, and
* a badge that says this address has never written before — the one piece of
* context that makes a cold inbox readable at all.
*
* Storage lives beside the bridge, like telegram_topics: nothing outside
* src/telegram reads it, and losing the whole thing costs a night of grouping,
* never an email.
*/
import { insertOpsEvent } from "../db/d1";
import {
inlineKeyboard,
readTelegramConfig,
sendMessage,
type TelegramConfig,
type TelegramInlineButton,
} from "./api";
import { resolveTelegramChatId } from "./binding";
import { renderNightDigest } from "./format";
import { listMailIndexRows } from "./index-rows";
const nowIso = () => new Date().toISOString();
// --- muted senders ---------------------------------------------------------
/** Addresses compare case-insensitively in practice; the table stores one form. */
function normalizeSender(sender: string): string {
return sender.trim().toLowerCase();
}
export async function isSenderMuted(db: D1Database, sender: string): Promise<boolean> {
const row = await db
.prepare("SELECT 1 AS hit FROM telegram_muted_senders WHERE sender = ?")
.bind(normalizeSender(sender))
.first<{ hit: number }>();
return row !== null;
}
/**
* Flips the mute for a sender and answers with the state it is now in.
*
* A toggle rather than a one-way switch because the button is the only place the
* decision is ever visible: a sender you silenced still appears in /buscar and in
* a digest, and pressing the same button there is how you take it back. A mute
* you cannot find your way out of is a mute nobody dares to press.
*/
export async function toggleSenderMute(
db: D1Database,
input: { sender: string; mutedBy: string },
): Promise<boolean> {
const sender = normalizeSender(input.sender);
if (await isSenderMuted(db, sender)) {
await db.prepare("DELETE FROM telegram_muted_senders WHERE sender = ?").bind(sender).run();
return false;
}
await db
.prepare(
"INSERT OR REPLACE INTO telegram_muted_senders (sender, muted_at, muted_by) VALUES (?, ?, ?)",
)
.bind(sender, nowIso(), input.mutedBy)
.run();
return true;
}
export async function listMutedSenders(db: D1Database, limit = 50): Promise<string[]> {
const result = await db
.prepare("SELECT sender FROM telegram_muted_senders ORDER BY muted_at DESC LIMIT ?")
.bind(limit)
.all<{ sender: string }>();
return (result.results ?? []).map((row) => row.sender);
}
// --- quiet hours -----------------------------------------------------------
export type QuietHours = {
/** Minutes from local midnight. Null on both ends means quiet hours are off. */
startMinutes: number | null;
endMinutes: number | null;
/** The operator states a window in his own clock; the worker only knows UTC. */
utcOffsetMinutes: number;
};
const QUIET_HOURS_OFF: QuietHours = {
startMinutes: null,
endMinutes: null,
utcOffsetMinutes: 0,
};
export async function readQuietHours(db: D1Database): Promise<QuietHours> {
const row = await db
.prepare(
"SELECT quiet_start_minutes, quiet_end_minutes, utc_offset_minutes FROM telegram_settings WHERE id = 1",
)
.first<{
quiet_start_minutes: number | null;
quiet_end_minutes: number | null;
utc_offset_minutes: number;
}>();
if (!row) return QUIET_HOURS_OFF;
return {
startMinutes: row.quiet_start_minutes,
endMinutes: row.quiet_end_minutes,
utcOffsetMinutes: row.utc_offset_minutes ?? 0,
};
}
export async function writeQuietHours(db: D1Database, hours: QuietHours): Promise<void> {
await db
.prepare(
`INSERT INTO telegram_settings (id, quiet_start_minutes, quiet_end_minutes, utc_offset_minutes, updated_at)
VALUES (1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
quiet_start_minutes = excluded.quiet_start_minutes,
quiet_end_minutes = excluded.quiet_end_minutes,
utc_offset_minutes = excluded.utc_offset_minutes,
updated_at = excluded.updated_at`,
)
.bind(hours.startMinutes, hours.endMinutes, hours.utcOffsetMinutes, nowIso())
.run();
}
/**
* Is `at` inside the window?
*
* The wrap-around case is the only one anybody configures — 23:00 to 08:00 is a
* night, not an empty interval — so the comparison is deliberately two-branched
* rather than a clever modulo that reads correctly and behaves wrongly at 00:00.
*/
export function isWithinQuietHours(hours: QuietHours, at: Date): boolean {
if (hours.startMinutes === null || hours.endMinutes === null) return false;
if (hours.startMinutes === hours.endMinutes) return false;
const utcMinutes = at.getUTCHours() * 60 + at.getUTCMinutes();
const local = (((utcMinutes + hours.utcOffsetMinutes) % 1440) + 1440) % 1440;
return hours.startMinutes < hours.endMinutes
? local >= hours.startMinutes && local < hours.endMinutes
: local >= hours.startMinutes || local < hours.endMinutes;
}
export async function isQuietNow(db: D1Database, at = new Date()): Promise<boolean> {
return isWithinQuietHours(await readQuietHours(db), at);
}
function twoDigits(value: number): string {
return String(value).padStart(2, "0");
}
export function formatMinutes(minutes: number): string {
return `${twoDigits(Math.floor(minutes / 60))}:${twoDigits(minutes % 60)}`;
}
export function formatOffset(minutes: number): string {
const sign = minutes < 0 ? "-" : "+";
const absolute = Math.abs(minutes);
return `${sign}${twoDigits(Math.floor(absolute / 60))}:${twoDigits(absolute % 60)}`;
}
/**
* `23:00-08:00 +02:00` -> a window. `off` -> quiet hours disabled.
*
* The offset is part of the command rather than a separate setting because the
* two are only ever meaningful together: a window without the clock it was stated
* in is a window that silences the wrong nine hours.
*/
export function parseQuietHours(argument: string): QuietHours | "off" | null {
const text = argument.trim().toLowerCase();
if (!text || text === "off" || text === "no" || text === "nunca") return "off";
const match = text.match(
/^(\d{1,2}):(\d{2})\s*[-–a]\s*(\d{1,2}):(\d{2})(?:\s*(?:utc)?\s*([+-])(\d{1,2})(?::?(\d{2}))?)?$/,
);
if (!match) return null;
const [, startHour, startMinute, endHour, endMinute, sign, offsetHour, offsetMinute] = match;
const start = Number(startHour) * 60 + Number(startMinute);
const end = Number(endHour) * 60 + Number(endMinute);
if (start >= 1440 || end >= 1440 || Number(startMinute) > 59 || Number(endMinute) > 59) {
return null;
}
const offsetMagnitude = Number(offsetHour ?? 0) * 60 + Number(offsetMinute ?? 0);
if (offsetMagnitude > 14 * 60) return null;
return {
startMinutes: start,
endMinutes: end,
utcOffsetMinutes: sign === "-" ? -offsetMagnitude : offsetMagnitude,
};
}
// --- retention and the morning digest --------------------------------------
export type RetainedRow = {
id: string;
chat_id: string;
mailbox_id: string;
mailbox_address: string;
message_local_id: string;
thread_id: string;
from_addr: string;
retained_at: string;
digest_at: string | null;
};
/**
* Short enough to ride in callback_data next to a verb, long enough that two
* retained emails never collide inside one night.
*/
function retainedId(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
}
export async function retainNotification(
db: D1Database,
input: {
chatId: string;
mailboxId: string;
mailboxAddress: string;
messageLocalId: string;
threadId: string;
fromAddr: string;
},
): Promise<void> {
// One row per email, whatever the queue does: a redelivered notification must
// not put the same message in the digest twice.
const existing = await db
.prepare(
"SELECT id FROM telegram_retained WHERE chat_id = ? AND mailbox_id = ? AND message_local_id = ?",
)
.bind(input.chatId, input.mailboxId, input.messageLocalId)
.first<{ id: string }>();
if (existing) return;
await db
.prepare(
`INSERT INTO telegram_retained
(id, chat_id, mailbox_id, mailbox_address, message_local_id, thread_id, from_addr, retained_at, digest_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
)
.bind(
retainedId(),
input.chatId,
input.mailboxId,
input.mailboxAddress,
input.messageLocalId,
input.threadId,
input.fromAddr,
nowIso(),
)
.run();
}
export async function getRetainedItem(db: D1Database, id: string): Promise<RetainedRow | null> {
return db.prepare("SELECT * FROM telegram_retained WHERE id = ?").bind(id).first<RetainedRow>();
}
async function pendingRetained(db: D1Database, chatId: string): Promise<RetainedRow[]> {
const result = await db
.prepare(
`SELECT * FROM telegram_retained WHERE chat_id = ? AND digest_at IS NULL
ORDER BY retained_at ASC LIMIT 200`,
)
.bind(chatId)
.all<RetainedRow>();
return result.results ?? [];
}
/**
* Rows whose digest is old enough that nobody is still tapping its buttons.
*
* Retained-but-never-digested rows are deliberately never swept by age: they are
* mail the operator has not been told about, and dropping them silently is the
* one outcome this whole mechanism exists to avoid.
*/
export async function deleteStaleDigestItems(
db: D1Database,
olderThanMs = 7 * 86_400_000,
): Promise<number> {
const result = await db
.prepare("DELETE FROM telegram_retained WHERE digest_at IS NOT NULL AND digest_at < ?")
.bind(new Date(Date.now() - olderThanMs).toISOString())
.run();
return result.meta.changes ?? 0;
}
/** More buttons than this and the summary stops being one glance. */
const DIGEST_BUTTON_LIMIT = 12;
const DIGEST_BUTTONS_PER_ROW = 4;
export type DigestOutcome =
| { status: "sent"; count: number }
| {
status: "skipped";
reason: "bridge_disabled" | "no_chat" | "quiet_hours" | "nothing_pending";
};
/**
* Empties the night into one message.
*
* One message, not one per email, and that is the point rather than a nicety:
* Telegram accepts roughly one message per second per chat, so releasing six
* hours of retained mail card by card would either be throttled into the
* afternoon or answered with 429s. A summary with numbered buttons spends one
* message and still leaves every item one tap from a real, operable card.
*
* Called from the notification path (the first email after the window closes
* flushes what the window held) and from the hourly cron, so a quiet morning with
* no new mail still gets its digest.
*/
export async function flushTelegramDigest(env: Env, at = new Date()): Promise<DigestOutcome> {
const config = readTelegramConfig(env);
if (!config) return { status: "skipped", reason: "bridge_disabled" };
const chatId = await resolveTelegramChatId(env);
if (!chatId) return { status: "skipped", reason: "no_chat" };
if (await isQuietNow(env.INDEX_DB, at)) return { status: "skipped", reason: "quiet_hours" };
const pending = await pendingRetained(env.INDEX_DB, chatId);
if (pending.length === 0) return { status: "skipped", reason: "nothing_pending" };
await sendDigest(env, config, chatId, pending);
// Marked only after Telegram accepted the summary: a failure here throws to the
// caller, and the rows stay pending so the next pass tries again rather than
// swallowing a night of mail.
await env.INDEX_DB.prepare(
`UPDATE telegram_retained SET digest_at = ? WHERE chat_id = ? AND digest_at IS NULL`,
)
.bind(nowIso(), chatId)
.run();
await insertOpsEvent(env.INDEX_DB, {
id: crypto.randomUUID(),
event_type: "telegram.digest_sent",
severity: "info",
subject: chatId,
payload_json: JSON.stringify({ count: pending.length }),
}).catch(() => undefined);
return { status: "sent", count: pending.length };
}
/** Callback verb for "open the card for item N of the digest". */
export const DIGEST_CALLBACK_VERB = "g";
async function sendDigest(
env: Env,
config: TelegramConfig,
chatId: string,
rows: RetainedRow[],
): Promise<void> {
// Subjects are re-read rather than retained, so the summary and the card it
// expands into can never disagree about the same email.
const subjects = new Map<string, string | null>();
const byMailbox = new Map<string, string[]>();
for (const row of rows) {
byMailbox.set(row.mailbox_id, [...(byMailbox.get(row.mailbox_id) ?? []), row.message_local_id]);
}
for (const [mailboxId, ids] of byMailbox) {
for (const indexed of await listMailIndexRows(env.INDEX_DB, mailboxId, ids)) {
subjects.set(`${mailboxId}/${indexed.message_local_id}`, indexed.subject);
}
}
const shown = rows.slice(0, DIGEST_BUTTON_LIMIT);
const text = renderNightDigest({
total: rows.length,
items: shown.map((row) => ({
fromAddr: row.from_addr,
subject: subjects.get(`${row.mailbox_id}/${row.message_local_id}`) ?? null,
})),
});
const buttons: TelegramInlineButton[][] = [];
shown.forEach((row, index) => {
const target = Math.floor(index / DIGEST_BUTTONS_PER_ROW);
buttons[target] ??= [];
buttons[target]?.push({
text: String(index + 1),
callback_data: `v1:${DIGEST_CALLBACK_VERB}:${row.id}`,
});
});
await sendMessage(config, {
chatId,
text,
replyMarkup: buttons.length > 0 ? inlineKeyboard(buttons) : undefined,
});
}