diff --git a/apps/dashboard/src/api/queries.ts b/apps/dashboard/src/api/queries.ts
index f442a4a..22c9804 100644
--- a/apps/dashboard/src/api/queries.ts
+++ b/apps/dashboard/src/api/queries.ts
@@ -8,7 +8,11 @@ export const dashboardQueryKeys = {
};
export function useStatsQuery() {
- return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
+ return useQuery({
+ queryKey: dashboardQueryKeys.stats,
+ queryFn: dashboardApi.getStats,
+ refetchInterval: 15_000,
+ });
}
export function useTimersQuery() {
diff --git a/apps/dashboard/src/features/operations/operations-page.tsx b/apps/dashboard/src/features/operations/operations-page.tsx
index 5d608a2..a7814f2 100644
--- a/apps/dashboard/src/features/operations/operations-page.tsx
+++ b/apps/dashboard/src/features/operations/operations-page.tsx
@@ -1,5 +1,5 @@
import { useEffect, useReducer, useRef, useState } from 'react';
-import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
+import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
@@ -72,6 +72,16 @@ function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
}
}
+export function accountOwnerFromLogData(data: unknown): RedditAccountOwner | undefined {
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
+ const accountId = Reflect.get(data, 'accountId');
+ const redditUsername = Reflect.get(data, 'redditUsername');
+ return typeof accountId === 'string' && accountId.length > 0
+ && typeof redditUsername === 'string' && redditUsername.length > 0
+ ? { accountId, redditUsername }
+ : undefined;
+}
+
export interface OperationsPageProps {
realtimeEnabled?: boolean;
}
@@ -175,9 +185,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
{consoleClearNotice ?
Cleared from this browser view. Server logs were not deleted.
: null}
= {
+ ready: { label: 'Ready', tone: 'success', detail: 'Available for the next outbound candidate.' },
+ 'bot-stopped': { label: 'Bot stopped', tone: 'neutral', detail: 'Global sending is stopped.' },
+ 'runtime-stopped': { label: 'Runtime stopped', tone: 'warning', detail: 'This account runtime is not running.' },
+ 'runtime-failed': { label: 'Runtime failed', tone: 'warning', detail: 'This account runtime needs attention.' },
+ 'login-required': { label: 'Login required', tone: 'warning', detail: 'Refresh this account session before it can send.' },
+ 'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
+ 'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
+ pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
+};
+
+function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {
+ return (
+
+
+
Multi-account delivery
Account status
+
{accounts.length} configured
+
+ {accounts.length === 0 ? No accounts configured.Add an account under Settings → Access.
: (
+
+ {accounts.map((account) => {
+ const state = sendingStateCopy[account.sendingState];
+ return (
+
+
+ {state.detail}
+
+ - Runtime
- {account.runtimeState}
+ - Authentication
- {account.authenticated ? 'Authenticated' : 'Unavailable'}
+ - Hourly usage
- {account.messagesThisHour} of {account.maxMessagesPerHour}
+ - Remaining
- {account.messagesRemaining}
+ - Allowance resets
- {formatTime(account.hourlyResetAt)}
+ - Pending replies
- {account.actionableReplies}
+
+ {account.nextMessageAvailableAt && account.nextMessageAvailableAt > Date.now() && !account.invitationCooldown
+ ? Next send: {formatTime(account.nextMessageAvailableAt)}
+ : null}
+ {account.invitationCooldown ? (
+
+ Cooldown until {formatTime(account.invitationCooldown.until)}
+ {account.invitationCooldown.reason}
+ {account.invitationCooldown.redditCode ? {account.invitationCooldown.redditCode} : null}
+
+ ) : null}
+ {account.failure ? Runtime failure{account.failure}
: null}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
function ActivityPanel({ stats }: { stats: BotStats }) {
return (
@@ -166,7 +224,9 @@ export function OverviewPage() {
{stats.invitationCooldown ? (
- Reddit invitation cooldown
+ {stats.invitationCooldown.redditUsername
+ ? `u/${stats.invitationCooldown.redditUsername} invitation cooldown`
+ : 'Reddit invitation cooldown'}
{stats.invitationCooldown.reason}
) : null}
@@ -178,6 +238,8 @@ export function OverviewPage() {
+
+
{timersQuery.isPending ? (
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index 444ad76..8025e4c 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -76,6 +76,20 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
.metric-card > span, .metric-card small { color: var(--muted); }
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
+.account-status-panel { margin-bottom: .75rem; }
+.account-status-grid { display: grid; gap: .75rem; }
+.account-status-card { display: grid; gap: .75rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
+.account-status-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; }
+.account-status-card > p { margin: 0; color: var(--muted); font-size: .85rem; }
+.account-status-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem .9rem; margin: 0; }
+.account-status-details div { display: grid; gap: .1rem; }
+.account-status-details dt { color: var(--muted); font-size: .72rem; }
+.account-status-details dd { margin: 0; font-weight: 700; text-transform: capitalize; }
+.account-status-note { color: var(--muted); }
+.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .55rem; }
+.account-status-warning span { font-size: .8rem; }
+.account-status-warning code { width: fit-content; padding: .15rem .3rem; color: inherit; background: rgba(0, 0, 0, .25); border-radius: .25rem; }
+.account-status-warning.is-error { color: #ffd1ce; background: #461b18; border-color: #80352f; }
.overview-grid { display: grid; gap: .75rem; }
.panel { padding: 1rem; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
@@ -150,5 +164,6 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.page { padding: 3rem 2rem 5rem; }
.navigation-backdrop { display: none; }
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+ .account-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
}
diff --git a/packages/api-contracts/src/stats.ts b/packages/api-contracts/src/stats.ts
index d717117..580b446 100644
--- a/packages/api-contracts/src/stats.ts
+++ b/packages/api-contracts/src/stats.ts
@@ -1,6 +1,37 @@
import { z } from 'zod';
import { ActivityItemSchema } from './activity.js';
+export const AccountSendingStateSchema = z.enum([
+ 'ready',
+ 'bot-stopped',
+ 'runtime-stopped',
+ 'runtime-failed',
+ 'login-required',
+ 'matrix-cooldown',
+ 'replies-prioritized',
+ 'pacing',
+]);
+
+export const AccountStatusSchema = z.strictObject({
+ accountId: z.string(),
+ redditUsername: z.string(),
+ runtimeState: z.enum(['created', 'starting', 'running', 'stopping', 'stopped', 'failed']),
+ authenticated: z.boolean(),
+ sendingState: AccountSendingStateSchema,
+ messagesThisHour: z.number(),
+ maxMessagesPerHour: z.number(),
+ nextMessageAvailableAt: z.number().nullable(),
+ hourlyResetAt: z.number(),
+ messagesRemaining: z.number(),
+ actionableReplies: z.number(),
+ invitationCooldown: z.strictObject({
+ until: z.number(),
+ reason: z.string(),
+ redditCode: z.string().nullable(),
+ }).nullable(),
+ failure: z.string().nullable(),
+});
+
const PerformanceFields = {
totalSent: z.number(),
totalResponses: z.number(),
@@ -27,10 +58,13 @@ export const StatsResponseSchema = z.strictObject({
})),
targetGenders: z.array(z.string()),
recentActivity: z.array(ActivityItemSchema),
+ accounts: z.array(AccountStatusSchema),
invitationCooldown: z.strictObject({
until: z.number(),
reason: z.string(),
redditCode: z.string().nullable(),
+ accountId: z.string().optional(),
+ redditUsername: z.string().optional(),
}).nullable(),
});
@@ -93,5 +127,7 @@ export const AiUsageSummaryResponseSchema = z.strictObject({
});
export type StatsResponse = z.infer;
+export type AccountStatus = z.infer;
+export type AccountSendingState = z.infer;
export type TimersResponse = z.infer;
export type AiUsageSummaryResponse = z.infer;
diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts
index d153161..71c6350 100644
--- a/packages/api-contracts/tests/contracts.test.ts
+++ b/packages/api-contracts/tests/contracts.test.ts
@@ -58,6 +58,12 @@ describe('dashboard endpoint contracts', () => {
subreddits: [{ name: 'example', type: 'image' }],
targetGenders: ['female'],
recentActivity: [activity],
+ accounts: [{
+ accountId: '@t2_owner:reddit.com', redditUsername: 'owner', runtimeState: 'running',
+ authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8,
+ nextMessageAvailableAt: null, hourlyResetAt: 1_700_003_600_000, messagesRemaining: 6,
+ actionableReplies: 0, invitationCooldown: null, failure: null,
+ }],
invitationCooldown: null,
};
expect(StatsResponseSchema.parse(payload)).toEqual(payload);
diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts
index 20112ec..503a9a2 100644
--- a/src/services/reddit-account-runtime-builder.ts
+++ b/src/services/reddit-account-runtime-builder.ts
@@ -84,11 +84,15 @@ export function createConcreteRedditAccountRuntimeFactory(
}
async function buildRuntime(
- { config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
+ { config, logger: rootLogger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
session: VerifiedAccountSession,
recipientDeduplicator: RecipientDeduplicator,
redditRequestCoordinator: RedditRequestCoordinator,
): Promise {
+ const logger = rootLogger.child({
+ accountId: session.accountId,
+ redditUsername: session.redditUsername,
+ });
const browserProfileDir = accountBrowserProfileDir(
path.resolve(process.cwd(), config.browser.sessionDir),
session.accountId,
diff --git a/src/web/routes/stats.ts b/src/web/routes/stats.ts
index 59f0e01..821b10e 100644
--- a/src/web/routes/stats.ts
+++ b/src/web/routes/stats.ts
@@ -26,18 +26,64 @@ export function createStatsRouter(ctx: WebContext): Router {
router.get('/stats', (_req, res) => {
const runtimes = runningRuntimes(ctx);
+ const runtimeSnapshots = ctx.services.accountRuntimes?.list() ?? [];
const messages = runtimes.length > 0
? runtimes.flatMap((runtime) => runtime.resources.messagesRepo.getRecent(1000))
: ctx.repos.messages.getRecent(1000);
const totalSent = messages.length;
const totalResponses = messages.filter((m) => m.gotResponse).length;
- const cooldown = runtimes.map((runtime) => runtime.resources.chatService.getInvitationCooldown()).find(Boolean)
+ const cooldownRuntime = runtimes.find((runtime) => runtime.resources.chatService.getInvitationCooldown());
+ const cooldown = cooldownRuntime?.resources.chatService.getInvitationCooldown()
?? ctx.services.chat?.getInvitationCooldown()
?? null;
const messagesThisHour = runtimes.length > 0
? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0)
: ctx.getMessagesThisHour();
+ const accounts: BotStats['accounts'] = (ctx.services.redditAccounts?.list() ?? []).map((account) => {
+ const snapshot = runtimeSnapshots.find((item) => item.accountId === account.accountId);
+ const runtime = snapshot ? ctx.services.accountRuntimes?.get(snapshot.accountId) : null;
+ const authenticated = runtime?.resources.accountAuth.isFullyAuthenticated() ?? false;
+ const accountCooldown = runtime?.resources.chatService.getInvitationCooldown() ?? null;
+ const timers = runtime?.resources.rateLimiter.getTimers() ?? {
+ nextMessageAvailableAt: null,
+ hourlyResetAt: Date.now() + 60 * 60_000,
+ messagesRemaining: ctx.config.rateLimit.maxMessagesPerHour,
+ };
+ const messageStats = runtime?.resources.rateLimiter.getStats();
+ const priority = runtime?.resources.conversationAutomation.getPriorityStatus();
+ const runtimeState = snapshot?.state ?? 'stopped';
+ const sendingState: BotStats['accounts'][number]['sendingState'] = runtimeState === 'failed'
+ ? 'runtime-failed'
+ : account.status === 'login-required' || (runtimeState === 'running' && !authenticated)
+ ? 'login-required'
+ : runtimeState !== 'running'
+ ? 'runtime-stopped'
+ : accountCooldown
+ ? 'matrix-cooldown'
+ : !ctx.getBotRunning()
+ ? 'bot-stopped'
+ : (priority?.processing || (priority?.actionableBacklog ?? 0) > 0)
+ ? 'replies-prioritized'
+ : timers.nextMessageAvailableAt !== null && timers.nextMessageAvailableAt > Date.now()
+ ? 'pacing'
+ : 'ready';
+ return {
+ accountId: account.accountId,
+ redditUsername: account.redditUsername,
+ runtimeState,
+ authenticated,
+ sendingState,
+ messagesThisHour: messageStats?.messagesThisHour ?? 0,
+ maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour,
+ nextMessageAvailableAt: timers.nextMessageAvailableAt,
+ hourlyResetAt: timers.hourlyResetAt,
+ messagesRemaining: timers.messagesRemaining,
+ actionableReplies: priority?.actionableBacklog ?? 0,
+ invitationCooldown: accountCooldown,
+ failure: snapshot?.failure ?? null,
+ };
+ });
const stats: BotStats = {
isRunning: ctx.getBotRunning(),
@@ -53,8 +99,17 @@ export function createStatsRouter(ctx: WebContext): Router {
subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })),
targetGenders: ctx.config.reddit.targetGenders,
recentActivity: recentActivity(10),
+ accounts,
invitationCooldown: cooldown
- ? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode }
+ ? {
+ until: cooldown.until,
+ reason: cooldown.reason,
+ redditCode: cooldown.redditCode,
+ ...(cooldownRuntime ? {
+ accountId: cooldownRuntime.session.accountId,
+ redditUsername: cooldownRuntime.session.redditUsername,
+ } : {}),
+ }
: null,
};
diff --git a/src/web/types.ts b/src/web/types.ts
index 605561b..d280e5c 100644
--- a/src/web/types.ts
+++ b/src/web/types.ts
@@ -10,11 +10,28 @@ export interface BotStats {
subreddits: { name: string; type: 'image' | 'chat' }[];
targetGenders: string[];
recentActivity: ActivityItem[];
+ accounts: Array<{
+ accountId: string;
+ redditUsername: string;
+ runtimeState: 'created' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
+ authenticated: boolean;
+ sendingState: 'ready' | 'bot-stopped' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing';
+ messagesThisHour: number;
+ maxMessagesPerHour: number;
+ nextMessageAvailableAt: number | null;
+ hourlyResetAt: number;
+ messagesRemaining: number;
+ actionableReplies: number;
+ invitationCooldown: { until: number; reason: string; redditCode: string | null } | null;
+ failure: string | null;
+ }>;
/** Non-null when Reddit has throttled new DM invitations. */
invitationCooldown: {
until: number;
reason: string;
redditCode: string | null;
+ accountId?: string;
+ redditUsername?: string;
} | null;
}
diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx
index 3176a92..f065815 100644
--- a/tests/dashboard/app.test.tsx
+++ b/tests/dashboard/app.test.tsx
@@ -13,6 +13,7 @@ import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
interface TestStatsOverrides {
isRunning?: boolean;
recentActivity?: readonly unknown[];
+ accounts?: readonly unknown[];
}
const server = setupServer();
@@ -30,6 +31,7 @@ function createStats(overrides: TestStatsOverrides = {}) {
subreddits: [{ name: 'casualconversation', type: 'chat' }],
targetGenders: ['female'],
recentActivity: overrides.recentActivity ?? [],
+ accounts: overrides.accounts ?? [],
invitationCooldown: null,
};
}
@@ -224,11 +226,51 @@ describe('dashboard foundation', () => {
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
expect(screen.getByText('Dry run')).toBeInTheDocument();
+ expect(screen.getByText('No accounts configured.')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener');
expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded');
expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
});
+ it('shows the live sending status and cooldown for every account', async () => {
+ const now = Date.now();
+ installSuccessfulApi({
+ isRunning: true,
+ accounts: [
+ {
+ accountId: '@t2_ready:reddit.com', redditUsername: 'ready-account', runtimeState: 'running',
+ authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8,
+ nextMessageAvailableAt: null, hourlyResetAt: now + 3_600_000, messagesRemaining: 6,
+ actionableReplies: 0, invitationCooldown: null, failure: null,
+ },
+ {
+ accountId: '@t2_limited:reddit.com', redditUsername: 'limited-account', runtimeState: 'running',
+ authenticated: true, sendingState: 'matrix-cooldown', messagesThisHour: 5, maxMessagesPerHour: 8,
+ nextMessageAvailableAt: now + 600_000, hourlyResetAt: now + 3_600_000, messagesRemaining: 3,
+ actionableReplies: 1,
+ invitationCooldown: {
+ until: now + 600_000,
+ reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED',
+ redditCode: 'rate.invitation_limit_mature_account',
+ },
+ failure: null,
+ },
+ ],
+ });
+
+ renderDashboard();
+
+ const panel = await screen.findByRole('region', { name: 'Account status' });
+ expect(within(panel).getByText('2 configured')).toBeInTheDocument();
+ expect(within(panel).getByRole('article', { name: 'Status for u/ready-account' })).toHaveTextContent('Ready');
+ const limited = within(panel).getByRole('article', { name: 'Status for u/limited-account' });
+ expect(limited).toHaveTextContent('Matrix cooldown');
+ expect(limited).toHaveTextContent('Matrix rate-limited: M_LIMIT_EXCEEDED');
+ expect(limited).toHaveTextContent('rate.invitation_limit_mature_account');
+ expect(limited).toHaveTextContent('5 of 8');
+ expect(limited).toHaveTextContent('Pending replies1');
+ });
+
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
let timerRequests = 0;
server.use(
diff --git a/tests/dashboard/performance-operations.test.tsx b/tests/dashboard/performance-operations.test.tsx
index 47c219d..6ff6197 100644
--- a/tests/dashboard/performance-operations.test.tsx
+++ b/tests/dashboard/performance-operations.test.tsx
@@ -132,7 +132,12 @@ describe('OperationsPage', () => {
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
return HttpResponse.json([
{ timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' },
- { timestamp: Date.parse('2026-08-08T12:00:02Z'), level: 'error', message: 'Matrix request failed' },
+ {
+ timestamp: Date.parse('2026-08-08T12:00:02Z'),
+ level: 'error',
+ message: 'Matrix request failed: M_LIMIT_EXCEEDED',
+ data: { accountId: '@t2_owner:reddit.com', redditUsername: 'operator' },
+ },
]);
}),
http.get('/api/chats/sync', () =>
@@ -171,10 +176,14 @@ describe('OperationsPage', () => {
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
expect(consoleRegion).toHaveTextContent('Sync complete');
- expect(consoleRegion).toHaveTextContent('Matrix request failed');
+ expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
+ expect(within(consoleRegion).getByRole('link', { name: 'u/operator' })).toHaveAttribute(
+ 'href',
+ 'https://www.reddit.com/u/operator',
+ );
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
expect(consoleRegion).not.toHaveTextContent('Sync complete');
- expect(consoleRegion).toHaveTextContent('Matrix request failed');
+ expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
diff --git a/tests/log-broadcaster.test.ts b/tests/log-broadcaster.test.ts
index f7fd448..f3de2d0 100644
--- a/tests/log-broadcaster.test.ts
+++ b/tests/log-broadcaster.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
+import winston from 'winston';
import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js';
import { LogBroadcaster } from '../src/web/log-broadcaster.js';
+import { attachLogSink } from '../src/utils/logger.js';
describe('LogBroadcaster realtime bridge', () => {
it('publishes each log through the typed event hub', () => {
@@ -67,4 +69,27 @@ describe('LogBroadcaster realtime bridge', () => {
expect(serializedRecent).not.toContain('must-not-leak');
expect(serializedRecent).not.toContain('"request"');
});
+
+ it('preserves account metadata from a child logger', async () => {
+ const broadcaster = new LogBroadcaster();
+ const logger = winston.createLogger({ level: 'debug' });
+ attachLogSink(logger, broadcaster);
+
+ logger.child({
+ accountId: '@t2_owner:reddit.com',
+ redditUsername: 'operator',
+ }).warn('Matrix request failed: M_LIMIT_EXCEEDED');
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(broadcaster.getRecent()).toEqual([
+ expect.objectContaining({
+ level: 'warn',
+ message: 'Matrix request failed: M_LIMIT_EXCEEDED',
+ data: {
+ accountId: '@t2_owner:reddit.com',
+ redditUsername: 'operator',
+ },
+ }),
+ ]);
+ });
});
diff --git a/tests/web/stats-route.test.ts b/tests/web/stats-route.test.ts
new file mode 100644
index 0000000..b3cffd0
--- /dev/null
+++ b/tests/web/stats-route.test.ts
@@ -0,0 +1,94 @@
+import express from 'express';
+import type { AddressInfo } from 'node:net';
+import { afterEach, describe, expect, it } from 'vitest';
+import { StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
+import type { WebContext } from '../../src/web/context.js';
+import { createStatsRouter } from '../../src/web/routes/stats.js';
+
+const servers: Array> = [];
+
+async function request(app: express.Express, path: string): Promise {
+ const server = app.listen(0);
+ servers.push(server);
+ await new Promise((resolve) => server.once('listening', resolve));
+ const { port } = server.address() as AddressInfo;
+ return fetch(`http://127.0.0.1:${port}${path}`);
+}
+
+afterEach(async () => {
+ await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))));
+});
+
+describe('stats route account status', () => {
+ it('reports sending availability and Matrix cooldown independently for every account', async () => {
+ const now = Date.now();
+ const account = (
+ accountId: string,
+ redditUsername: string,
+ cooldown: { until: number; reason: string; redditCode: string | null } | null,
+ ) => ({
+ session: { accountId, redditUsername },
+ resources: {
+ accountAuth: { isFullyAuthenticated: () => true },
+ chatService: { getInvitationCooldown: () => cooldown },
+ rateLimiter: {
+ getStats: () => ({ messagesThisHour: cooldown ? 5 : 2, minutesSinceLastMessage: 1 }),
+ getTimers: () => ({
+ nextMessageAvailableAt: cooldown?.until ?? null,
+ hourlyResetAt: now + 3_600_000,
+ isHourlyLimitReached: false,
+ messagesRemaining: cooldown ? 3 : 6,
+ }),
+ },
+ conversationAutomation: { getPriorityStatus: () => ({ processing: false, actionableBacklog: 0 }) },
+ messagesRepo: { getRecent: () => [] },
+ activityLogRepo: { getRecent: () => [], countSentSkipped: () => 0 },
+ },
+ });
+ const ready = account('@t2_ready:reddit.com', 'ready-account', null);
+ const limited = account('@t2_limited:reddit.com', 'limited-account', {
+ until: now + 600_000,
+ reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED',
+ redditCode: 'rate.invitation_limit_mature_account',
+ });
+ const runtimes = new Map([[ready.session.accountId, ready], [limited.session.accountId, limited]]);
+ const context = {
+ config: {
+ browser: { dryRun: false },
+ rateLimit: { maxMessagesPerHour: 8 },
+ reddit: { subreddits: [], targetGenders: [] },
+ },
+ services: {
+ redditAccounts: { list: () => [...runtimes.values()].map((runtime) => runtime.session) },
+ accountRuntimes: {
+ list: () => [...runtimes.values()].map((runtime) => ({
+ ...runtime.session,
+ matrixUserId: runtime.session.accountId,
+ state: 'running',
+ updatedAt: now,
+ failure: null,
+ })),
+ get: (accountId: string) => runtimes.get(accountId) ?? null,
+ },
+ },
+ repos: { messages: { getRecent: () => [] } },
+ activity: { recent: () => [], totalProcessed: () => 0 },
+ getBotRunning: () => true,
+ getMessagesThisHour: () => 0,
+ } as unknown as WebContext;
+ const app = express().use('/api', createStatsRouter(context));
+
+ const response = await request(app, '/api/stats');
+ const stats = StatsResponseSchema.parse(await response.json());
+
+ expect(stats.accounts).toEqual([
+ expect.objectContaining({ redditUsername: 'ready-account', sendingState: 'ready', messagesThisHour: 2 }),
+ expect.objectContaining({
+ redditUsername: 'limited-account',
+ sendingState: 'matrix-cooldown',
+ invitationCooldown: expect.objectContaining({ reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED' }),
+ }),
+ ]);
+ expect(stats.invitationCooldown).toMatchObject({ redditUsername: 'limited-account' });
+ });
+});