Show per-account delivery status
This commit is contained in:
1 parent
9b86cee9be
commit
22eb32e5bf
14 files changed
+402
-13
No files matched your search
@@ -8,7 +8,11 @@ export const dashboardQueryKeys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useStatsQuery() {
|
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() {
|
export function useTimersQuery() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
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 { useDashboardRealtime } from '../../app/realtime-provider.js';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { RedditUserText } from '../../components/reddit-user-text.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 {
|
export interface OperationsPageProps {
|
||||||
realtimeEnabled?: boolean;
|
realtimeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -175,9 +185,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
</div>
|
</div>
|
||||||
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
||||||
<div aria-live="polite" className="live-console" role="log">
|
<div aria-live="polite" className="live-console" role="log">
|
||||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
|
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
|
||||||
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span><RedditUserText>{entry.message}</RedditUserText></span></article>
|
const owner = accountOwnerFromLogData(entry.data);
|
||||||
))}
|
return (
|
||||||
|
<article className={`console-entry ${entry.level}`} key={entry.id}>
|
||||||
|
<time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time>
|
||||||
|
<strong>{entry.level.toUpperCase()}</strong>
|
||||||
|
<span className="console-account">{owner ? <RedditUsernameLink username={owner.redditUsername} /> : 'global'}</span>
|
||||||
|
<span><RedditUserText>{entry.message}</RedditUserText></span>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div ref={consoleEnd} />
|
<div ref={consoleEnd} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -21,9 +21,11 @@
|
|||||||
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
|
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
|
||||||
.console-controls .checkbox-control input { min-height: auto; }
|
.console-controls .checkbox-control input { min-height: auto; }
|
||||||
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
|
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
|
||||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(6rem, auto) minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||||
.console-entry time { color: #708178; }
|
.console-entry time { color: #708178; }
|
||||||
.console-entry strong { color: #8da49a; }
|
.console-entry strong { color: #8da49a; }
|
||||||
|
.console-account { color: #708178; }
|
||||||
|
.console-account a { color: var(--accent); font-weight: 700; }
|
||||||
.console-entry.warn strong { color: var(--warning); }
|
.console-entry.warn strong { color: var(--warning); }
|
||||||
.console-entry.error strong { color: var(--danger); }
|
.console-entry.error strong { color: var(--danger); }
|
||||||
.live-console > p { color: var(--muted); text-align: center; }
|
.live-console > p { color: var(--muted); text-align: center; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
||||||
import type { BotStats, Timers } from '../../api/contracts.js';
|
import type { BotStats, Timers } from '../../api/contracts.js';
|
||||||
|
import type { AccountStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
|
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
|
||||||
import { RedditUserText } from '../../components/reddit-user-text.js';
|
import { RedditUserText } from '../../components/reddit-user-text.js';
|
||||||
import { OriginPostButton } from '../../components/origin-post.js';
|
import { OriginPostButton } from '../../components/origin-post.js';
|
||||||
@@ -55,6 +56,63 @@ function TimersPanel({ timers }: { timers: Timers }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; tone: 'success' | 'warning' | 'neutral'; detail: string }> = {
|
||||||
|
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 (
|
||||||
|
<section aria-labelledby="account-status-heading" className="panel account-status-panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div><span className="eyebrow">Multi-account delivery</span><h2 id="account-status-heading">Account status</h2></div>
|
||||||
|
<span className="pill neutral">{accounts.length} configured</span>
|
||||||
|
</div>
|
||||||
|
{accounts.length === 0 ? <div className="empty-state"><strong>No accounts configured.</strong><span>Add an account under Settings → Access.</span></div> : (
|
||||||
|
<div className="account-status-grid">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const state = sendingStateCopy[account.sendingState];
|
||||||
|
return (
|
||||||
|
<article aria-label={`Status for u/${account.redditUsername}`} className="account-status-card" key={account.accountId}>
|
||||||
|
<div className="account-status-heading">
|
||||||
|
<AccountOwnerBadge owner={account} />
|
||||||
|
<span className={`pill ${state.tone}`}>{state.label}</span>
|
||||||
|
</div>
|
||||||
|
<p>{state.detail}</p>
|
||||||
|
<dl className="account-status-details">
|
||||||
|
<div><dt>Runtime</dt><dd>{account.runtimeState}</dd></div>
|
||||||
|
<div><dt>Authentication</dt><dd>{account.authenticated ? 'Authenticated' : 'Unavailable'}</dd></div>
|
||||||
|
<div><dt>Hourly usage</dt><dd>{account.messagesThisHour} of {account.maxMessagesPerHour}</dd></div>
|
||||||
|
<div><dt>Remaining</dt><dd>{account.messagesRemaining}</dd></div>
|
||||||
|
<div><dt>Allowance resets</dt><dd>{formatTime(account.hourlyResetAt)}</dd></div>
|
||||||
|
<div><dt>Pending replies</dt><dd>{account.actionableReplies}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{account.nextMessageAvailableAt && account.nextMessageAvailableAt > Date.now() && !account.invitationCooldown
|
||||||
|
? <p className="account-status-note">Next send: {formatTime(account.nextMessageAvailableAt)}</p>
|
||||||
|
: null}
|
||||||
|
{account.invitationCooldown ? (
|
||||||
|
<div className="account-status-warning">
|
||||||
|
<strong>Cooldown until {formatTime(account.invitationCooldown.until)}</strong>
|
||||||
|
<span>{account.invitationCooldown.reason}</span>
|
||||||
|
{account.invitationCooldown.redditCode ? <code>{account.invitationCooldown.redditCode}</code> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{account.failure ? <div className="account-status-warning is-error" role="alert"><strong>Runtime failure</strong><span>{account.failure}</span></div> : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActivityPanel({ stats }: { stats: BotStats }) {
|
function ActivityPanel({ stats }: { stats: BotStats }) {
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="activity-heading" className="panel activity-panel">
|
<section aria-labelledby="activity-heading" className="panel activity-panel">
|
||||||
@@ -166,7 +224,9 @@ export function OverviewPage() {
|
|||||||
|
|
||||||
{stats.invitationCooldown ? (
|
{stats.invitationCooldown ? (
|
||||||
<div className="cooldown-banner" role="status">
|
<div className="cooldown-banner" role="status">
|
||||||
<strong>Reddit invitation cooldown</strong>
|
<strong>{stats.invitationCooldown.redditUsername
|
||||||
|
? `u/${stats.invitationCooldown.redditUsername} invitation cooldown`
|
||||||
|
: 'Reddit invitation cooldown'}</strong>
|
||||||
<span>{stats.invitationCooldown.reason}</span>
|
<span>{stats.invitationCooldown.reason}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -178,6 +238,8 @@ export function OverviewPage() {
|
|||||||
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
|
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AccountStatusPanel accounts={stats.accounts} />
|
||||||
|
|
||||||
<div className="overview-grid">
|
<div className="overview-grid">
|
||||||
{timersQuery.isPending ? (
|
{timersQuery.isPending ? (
|
||||||
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
|
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
|
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
|
||||||
.metric-card > span, .metric-card small { color: var(--muted); }
|
.metric-card > span, .metric-card small { color: var(--muted); }
|
||||||
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
|
.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; }
|
.overview-grid { display: grid; gap: .75rem; }
|
||||||
.panel { padding: 1rem; }
|
.panel { padding: 1rem; }
|
||||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 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; }
|
.page { padding: 3rem 2rem 5rem; }
|
||||||
.navigation-backdrop { display: none; }
|
.navigation-backdrop { display: none; }
|
||||||
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.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); }
|
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,37 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ActivityItemSchema } from './activity.js';
|
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 = {
|
const PerformanceFields = {
|
||||||
totalSent: z.number(),
|
totalSent: z.number(),
|
||||||
totalResponses: z.number(),
|
totalResponses: z.number(),
|
||||||
@@ -27,10 +58,13 @@ export const StatsResponseSchema = z.strictObject({
|
|||||||
})),
|
})),
|
||||||
targetGenders: z.array(z.string()),
|
targetGenders: z.array(z.string()),
|
||||||
recentActivity: z.array(ActivityItemSchema),
|
recentActivity: z.array(ActivityItemSchema),
|
||||||
|
accounts: z.array(AccountStatusSchema),
|
||||||
invitationCooldown: z.strictObject({
|
invitationCooldown: z.strictObject({
|
||||||
until: z.number(),
|
until: z.number(),
|
||||||
reason: z.string(),
|
reason: z.string(),
|
||||||
redditCode: z.string().nullable(),
|
redditCode: z.string().nullable(),
|
||||||
|
accountId: z.string().optional(),
|
||||||
|
redditUsername: z.string().optional(),
|
||||||
}).nullable(),
|
}).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,5 +127,7 @@ export const AiUsageSummaryResponseSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type StatsResponse = z.infer<typeof StatsResponseSchema>;
|
export type StatsResponse = z.infer<typeof StatsResponseSchema>;
|
||||||
|
export type AccountStatus = z.infer<typeof AccountStatusSchema>;
|
||||||
|
export type AccountSendingState = z.infer<typeof AccountSendingStateSchema>;
|
||||||
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
|
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
|
||||||
export type AiUsageSummaryResponse = z.infer<typeof AiUsageSummaryResponseSchema>;
|
export type AiUsageSummaryResponse = z.infer<typeof AiUsageSummaryResponseSchema>;
|
||||||
@@ -58,6 +58,12 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
subreddits: [{ name: 'example', type: 'image' }],
|
subreddits: [{ name: 'example', type: 'image' }],
|
||||||
targetGenders: ['female'],
|
targetGenders: ['female'],
|
||||||
recentActivity: [activity],
|
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,
|
invitationCooldown: null,
|
||||||
};
|
};
|
||||||
expect(StatsResponseSchema.parse(payload)).toEqual(payload);
|
expect(StatsResponseSchema.parse(payload)).toEqual(payload);
|
||||||
|
|||||||
@@ -84,11 +84,15 @@ export function createConcreteRedditAccountRuntimeFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildRuntime(
|
async function buildRuntime(
|
||||||
{ config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
|
{ config, logger: rootLogger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
|
||||||
session: VerifiedAccountSession,
|
session: VerifiedAccountSession,
|
||||||
recipientDeduplicator: RecipientDeduplicator,
|
recipientDeduplicator: RecipientDeduplicator,
|
||||||
redditRequestCoordinator: RedditRequestCoordinator,
|
redditRequestCoordinator: RedditRequestCoordinator,
|
||||||
): Promise<RedditAccountRuntime> {
|
): Promise<RedditAccountRuntime> {
|
||||||
|
const logger = rootLogger.child({
|
||||||
|
accountId: session.accountId,
|
||||||
|
redditUsername: session.redditUsername,
|
||||||
|
});
|
||||||
const browserProfileDir = accountBrowserProfileDir(
|
const browserProfileDir = accountBrowserProfileDir(
|
||||||
path.resolve(process.cwd(), config.browser.sessionDir),
|
path.resolve(process.cwd(), config.browser.sessionDir),
|
||||||
session.accountId,
|
session.accountId,
|
||||||
|
|||||||
+57
-2
@@ -26,18 +26,64 @@ export function createStatsRouter(ctx: WebContext): Router {
|
|||||||
|
|
||||||
router.get('/stats', (_req, res) => {
|
router.get('/stats', (_req, res) => {
|
||||||
const runtimes = runningRuntimes(ctx);
|
const runtimes = runningRuntimes(ctx);
|
||||||
|
const runtimeSnapshots = ctx.services.accountRuntimes?.list() ?? [];
|
||||||
const messages = runtimes.length > 0
|
const messages = runtimes.length > 0
|
||||||
? runtimes.flatMap((runtime) => runtime.resources.messagesRepo.getRecent(1000))
|
? runtimes.flatMap((runtime) => runtime.resources.messagesRepo.getRecent(1000))
|
||||||
: ctx.repos.messages.getRecent(1000);
|
: ctx.repos.messages.getRecent(1000);
|
||||||
const totalSent = messages.length;
|
const totalSent = messages.length;
|
||||||
const totalResponses = messages.filter((m) => m.gotResponse).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()
|
?? ctx.services.chat?.getInvitationCooldown()
|
||||||
?? null;
|
?? null;
|
||||||
const messagesThisHour = runtimes.length > 0
|
const messagesThisHour = runtimes.length > 0
|
||||||
? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0)
|
? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0)
|
||||||
: ctx.getMessagesThisHour();
|
: 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 = {
|
const stats: BotStats = {
|
||||||
isRunning: ctx.getBotRunning(),
|
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 })),
|
subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })),
|
||||||
targetGenders: ctx.config.reddit.targetGenders,
|
targetGenders: ctx.config.reddit.targetGenders,
|
||||||
recentActivity: recentActivity(10),
|
recentActivity: recentActivity(10),
|
||||||
|
accounts,
|
||||||
invitationCooldown: cooldown
|
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,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,28 @@ export interface BotStats {
|
|||||||
subreddits: { name: string; type: 'image' | 'chat' }[];
|
subreddits: { name: string; type: 'image' | 'chat' }[];
|
||||||
targetGenders: string[];
|
targetGenders: string[];
|
||||||
recentActivity: ActivityItem[];
|
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. */
|
/** Non-null when Reddit has throttled new DM invitations. */
|
||||||
invitationCooldown: {
|
invitationCooldown: {
|
||||||
until: number;
|
until: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
redditCode: string | null;
|
redditCode: string | null;
|
||||||
|
accountId?: string;
|
||||||
|
redditUsername?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
|
|||||||
interface TestStatsOverrides {
|
interface TestStatsOverrides {
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
recentActivity?: readonly unknown[];
|
recentActivity?: readonly unknown[];
|
||||||
|
accounts?: readonly unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = setupServer();
|
const server = setupServer();
|
||||||
@@ -30,6 +31,7 @@ function createStats(overrides: TestStatsOverrides = {}) {
|
|||||||
subreddits: [{ name: 'casualconversation', type: 'chat' }],
|
subreddits: [{ name: 'casualconversation', type: 'chat' }],
|
||||||
targetGenders: ['female'],
|
targetGenders: ['female'],
|
||||||
recentActivity: overrides.recentActivity ?? [],
|
recentActivity: overrides.recentActivity ?? [],
|
||||||
|
accounts: overrides.accounts ?? [],
|
||||||
invitationCooldown: null,
|
invitationCooldown: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -224,11 +226,51 @@ describe('dashboard foundation', () => {
|
|||||||
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
|
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
|
||||||
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
|
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Dry run')).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(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('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded');
|
||||||
expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
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 () => {
|
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
|
||||||
let timerRequests = 0;
|
let timerRequests = 0;
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -132,7 +132,12 @@ describe('OperationsPage', () => {
|
|||||||
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
|
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
|
||||||
return HttpResponse.json([
|
return HttpResponse.json([
|
||||||
{ timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' },
|
{ 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', () =>
|
http.get('/api/chats/sync', () =>
|
||||||
@@ -171,10 +176,14 @@ describe('OperationsPage', () => {
|
|||||||
|
|
||||||
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
|
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
|
||||||
expect(consoleRegion).toHaveTextContent('Sync complete');
|
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');
|
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
|
||||||
expect(consoleRegion).not.toHaveTextContent('Sync complete');
|
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');
|
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
|
||||||
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
||||||
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import winston from 'winston';
|
||||||
import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js';
|
import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js';
|
||||||
import { LogBroadcaster } from '../src/web/log-broadcaster.js';
|
import { LogBroadcaster } from '../src/web/log-broadcaster.js';
|
||||||
|
import { attachLogSink } from '../src/utils/logger.js';
|
||||||
|
|
||||||
describe('LogBroadcaster realtime bridge', () => {
|
describe('LogBroadcaster realtime bridge', () => {
|
||||||
it('publishes each log through the typed event hub', () => {
|
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('must-not-leak');
|
||||||
expect(serializedRecent).not.toContain('"request"');
|
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<void>((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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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<ReturnType<express.Express['listen']>> = [];
|
||||||
|
|
||||||
|
async function request(app: express.Express, path: string): Promise<Response> {
|
||||||
|
const server = app.listen(0);
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((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<void>((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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user