Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/commands/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* `/events` command: list recent notifications the bot delivered.
*
* Renders the last N (default 10, max 30) delivered events as one-line
* summaries with explorer links, newest first. Supports `/events market` or
* `/events squad` filtering. Output is split with `splitTelegramMessage`
* when it exceeds Telegram's message ceiling. Memory use is bounded by the
* backing ring buffer (see src/notifications/recent.ts).
*/

import { type CommandContext, type Context } from "grammy";
import { escapeMd, splitTelegramMessage, TELEGRAM_MAX_MESSAGE_LENGTH } from "../notifications/format.js";
import { txExplorerUrl } from "../stellar/client.js";
import { isUsableTxHash } from "../stellar/decode.js";
import type { BotConfig } from "../config.js";
import type { ContractSource } from "../stellar/decode.js";
import { RecentEvents, recentEvents, type RecentEvent } from "../notifications/recent.js";

export const DEFAULT_EVENTS_LIMIT = 10;
export const MAX_EVENTS_LIMIT = 30;

export interface EventsCommandOptions {
/** "market" | "squad" filter; undefined shows both. */
source?: ContractSource;
limit?: number;
}

function normalizeLimit(limit: unknown): number {
const n = typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_EVENTS_LIMIT;
return Math.min(Math.max(n, 1), MAX_EVENTS_LIMIT);
}

function clipEventName(name: string): string {
const MAX = 80;
return name.length > MAX ? name.slice(0, MAX) : name;
}

function renderEventLine(event: RecentEvent, config: BotConfig): string {
const name = escapeMd(clipEventName(event.eventName));
const source = escapeMd(event.source);
let line = `mimir-${source}`;
if (event.marketId !== undefined) line += ` #${event.marketId}`;
line += ` ${name} at ledger ${event.ledger}`;
const url = event.txHash && isUsableTxHash(event.txHash) ? txExplorerUrl(config, event.txHash) : null;
if (url) line += ` · [tx](${url})`;
return line;
}

/**
* Renders the recent-events message text. Callers split the result with
* `splitTelegramMessage` before sending.
*/
export function eventsMessage(
recent: RecentEvents,
config: BotConfig,
options: EventsCommandOptions = {},
): string {
const limit = normalizeLimit(options.limit);
const events = recent.list({ source: options.source, limit });
const lines: string[] = [];
lines.push(options.source ? `*Recent ${options.source} events*` : "*Recent events*");
lines.push("");
if (events.length === 0) {
lines.push("No recent events\\.");
return lines.join("\n");
}
for (const event of events) {
lines.push(`• ${renderEventLine(event, config)}`);
}
return lines.join("\n");
}

/** Builds the `/events` command handler bound to a buffer and config. */
export function createEventsHandler(
recent: RecentEvents,
config: BotConfig,
): (ctx: CommandContext<Context>) => Promise<void> {
return async (ctx) => {
const allowed = config.allowedChatIds.length === 0 || config.allowedChatIds.includes(String(ctx.chat.id));
if (!allowed) return;
const text = ctx.message?.text ?? "";
const spaceIndex = text.indexOf(" ");
const arg = spaceIndex !== -1 ? text.slice(spaceIndex + 1).trim() : "";
let source: ContractSource | undefined;
if (arg === "market" || arg === "squad") source = arg;

const body = eventsMessage(recent, config, { source });
const parts = splitTelegramMessage(body, TELEGRAM_MAX_MESSAGE_LENGTH);
for (const part of parts) {
await ctx.reply(part, { link_preview_options: { is_disabled: true } });
}
};
}

/** Default handler bound to the process-wide buffer and a resolved config. */
export function createDefaultEventsHandler(config: BotConfig): (ctx: CommandContext<Context>) => Promise<void> {
return createEventsHandler(recentEvents, config);
}
92 changes: 92 additions & 0 deletions src/notifications/recent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Bounded ring buffer of delivered notifications.
*
* Fed by one call (`record`) after each successful Telegram send so the
* `/events` command can scroll back through recent activity. Memory use is
* bounded by a constant: the buffer holds at most `maxEvents` entries and
* evicts the oldest when full. Nothing is persisted across restarts.
*/

import type { ContractSource, DecodedEvent } from "../stellar/decode.js";

export interface RecentEvent {
source: ContractSource;
ledger: number;
eventId: string;
eventName: string;
marketId: number | undefined;
txHash: string;
/** Ledger close time, unix seconds. */
at: number;
}

/** Default ceiling on retained events in memory. */
export const DEFAULT_MAX_EVENTS = 1000;

export interface RecentEventsOptions {
maxEvents?: number;
}

/**
* Bounded FIFO ring buffer of delivered events, newest at the end of the
* internal array. `list` returns newest first.
*/
export class RecentEvents {
private readonly buffer: RecentEvent[] = [];
private readonly maxEvents: number;

constructor(options: RecentEventsOptions = {}) {
this.maxEvents =
typeof options.maxEvents === "number" && options.maxEvents > 0
? options.maxEvents
: DEFAULT_MAX_EVENTS;
}

/** Record a delivered event after a successful send. */
record(event: DecodedEvent): void {
this.buffer.push({
source: event.source,
ledger: event.ledger,
eventId: event.eventId,
eventName:
event.payload.name === "unknown" ? event.payload.eventName : event.payload.name,
marketId: this.marketId(event.payload),
txHash: event.txHash,
at: event.at,
});
if (this.buffer.length > this.maxEvents) this.buffer.shift();
}

private marketId(payload: unknown): number | undefined {
if (payload && typeof payload === "object" && "marketId" in payload) {
return (payload as { marketId?: number }).marketId;
}
return undefined;
}

/**
* Events newest first, optionally filtered by source and capped by `limit`.
* Does not mutate the buffer.
*/
list(options: { source?: ContractSource; limit?: number } = {}): RecentEvent[] {
let limit =
typeof options.limit === "number" && options.limit > 0 ? options.limit : this.buffer.length;
const filtered = options.source
? this.buffer.filter((e) => e.source === options.source)
: this.buffer;
return filtered.slice().reverse().slice(0, limit);
}

/** Number of events currently retained. */
get size(): number {
return this.buffer.length;
}

/** Clear all retained events (mainly for tests). */
clear(): void {
this.buffer.length = 0;
}
}

/** Shared buffer fed by the poller after successful sends and read by /events. */
export const recentEvents = new RecentEvents();
10 changes: 10 additions & 0 deletions src/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
import { formatSuppressedRepeats, LogSampler } from "./log-sampler.js";
import { Logger } from "./logger.js";
import { isNotificationAllowed } from "./notifications/featureFlags.js";
import { recentEvents } from "./notifications/recent.js";
import { buildStatusSnapshot, writeStatusFile, type StatusSnapshot } from "./status.js";
import { validateLedgerWindow, type LedgerWindow } from "./stellar/client.js";
import { LedgerCache } from "./stellar/ledger-cache.js";
Expand Down Expand Up @@ -2204,6 +2205,15 @@ export function createPoller(deps: PollerDeps) {
recordSentIncrement();
metrics?.notificationsSent.inc();
sentThisCycle += 1;
// Feed the bounded ring buffer so /events can scroll recent deliveries.
// Best-effort: a recording failure must not fail a successful send.
try {
recentEvents.record(event);
} catch (err) {
console.error(
`[poller] failed to record delivered event ${event.eventId}: ${safeErrorMessage(err)}`,
);
}
if (sendJournal) {
try {
await sendJournal.recordDone(event.eventId);
Expand Down
Loading