Implement maintenance mode and presence updates in bot and WebUI

- Added maintenance mode functionality in the bot, allowing for a custom message and DND status when enabled.
- Updated the OwnerPresencePage to include read-only access for VIEWER roles, with save permissions restricted to ADMIN roles.
- Enhanced the PresenceForm component to support immediate application of changes and added a confirmation for maintenance mode activation.
- Introduced localization updates for presence-related messages in both English and German, improving user guidance.
- Implemented a presence refresh queue to handle updates efficiently after configuration changes.
This commit is contained in:
smueller
2026-07-24 11:11:58 +02:00
parent 211403d54d
commit e45642b6f9
13 changed files with 414 additions and 153 deletions

View File

@@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise<BotPresen
export async function applyBotPresence(context: BotContext): Promise<void> {
const config = await readPresenceConfig(context);
if (config.maintenanceMode) {
const text =
config.maintenanceMessage?.trim() ||
'Maintenance mode — please try again later.';
await context.client.user?.setPresence({
status: 'dnd',
activities: [
{
name: text.slice(0, 128),
type: ActivityType.Watching
}
]
});
return;
}
const messages =
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
const index = Math.floor(Date.now() / 60_000) % messages.length;

View File

@@ -1,9 +1,10 @@
import { notFound } from 'next/navigation';
import { buildWelcomePreviewVars, WelcomeForm } from '@/components/modules/welcome-form';
import { WelcomeForm } from '@/components/modules/welcome-form';
import { requireGuildAccessOrRedirect } from '@/lib/auth';
import { getManageableGuild } from '@/lib/guilds';
import { getLocale, t } from '@/lib/i18n';
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
interface WelcomePageProps {
params: Promise<{ guildId: string }>;

View File

@@ -1,16 +1,21 @@
import { PresenceForm } from '@/components/owner/owner-forms';
import { ownerRoleAtLeast } from '@nexumi/shared';
import { PresenceForm } from '@/components/owner/presence-form';
import { getLocale, t } from '@/lib/i18n';
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
import { getPresenceConfig } from '@/lib/owner-presence';
export default async function OwnerPresencePage() {
const session = await requireOwnerOrRedirect('VIEWER');
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
</div>
<PresenceForm initialValue={config} />
<PresenceForm initialValue={config} canEdit={canEdit} />
</div>
);
}

View File

@@ -1,12 +1,6 @@
'use client';
import type {
DashboardGuild,
MessageComponentsV2,
SessionUser,
WelcomeConfigDashboard,
WelcomeEmbed
} from '@nexumi/shared';
import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -31,45 +25,6 @@ import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
function discordDefaultAvatarUrl(userId: string): string {
try {
const index = Number((BigInt(userId) >> 22n) % 6n);
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
} catch {
return 'https://cdn.discordapp.com/embed/avatars/0.png';
}
}
function userAvatarUrl(user: SessionUser): string {
return user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=256`
: discordDefaultAvatarUrl(user.id);
}
function guildIconUrl(guild: DashboardGuild): string {
return guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`
: discordDefaultAvatarUrl(guild.id);
}
/** Preview placeholders from the logged-in dashboard user and current guild. */
export function buildWelcomePreviewVars(
user: SessionUser,
guild: DashboardGuild
): Record<string, string> {
const displayName = user.globalName?.trim() || user.username;
return {
user: `@${displayName}`,
'user.name': displayName,
'user.tag': user.username,
'user.id': user.id,
'user.avatar': userAvatarUrl(user),
server: guild.name,
'server.icon': guildIconUrl(guild),
memberCount: '—'
};
}
interface LocalWelcomeValue extends Omit<
WelcomeConfigDashboard,
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'

View File

@@ -3,115 +3,18 @@
import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react';
import { toast } from 'sonner';
import type { BotPresenceConfig, OwnerGuildListItem, OwnerRole } from '@nexumi/shared';
import type { OwnerGuildListItem, OwnerRole } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { SettingsForm } from '@/components/settings/settings-form';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
async function readError(response: Response): Promise<string> {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
return data?.error ?? 'Request failed';
}
export function PresenceForm({ initialValue }: { initialValue: BotPresenceConfig }) {
const t = useTranslations();
return (
<SettingsForm
initialValue={initialValue}
onSave={async (value) => {
const response = await fetch('/api/owner/presence', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
return { ok: false, error: await readError(response) };
}
return { ok: true };
}}
>
{({ value, setValue, error }) => (
<Card>
<CardContent className="space-y-4 pt-6">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('owner.presence.status')}</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={value.status}
onChange={(e) => setValue({ ...value, status: e.target.value as BotPresenceConfig['status'] })}
>
{['online', 'idle', 'dnd', 'invisible'].map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityType')}</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={value.activityType}
onChange={(e) =>
setValue({ ...value, activityType: e.target.value as BotPresenceConfig['activityType'] })
}
>
{['Playing', 'Listening', 'Watching', 'Competing'].map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityText')}</Label>
<Input value={value.activityText} onChange={(e) => setValue({ ...value, activityText: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('owner.presence.rotating')}</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={value.rotatingMessages.join('\n')}
onChange={(e) =>
setValue({
...value,
rotatingMessages: e.target.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
})
}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value.maintenanceMode}
onChange={(e) => setValue({ ...value, maintenanceMode: e.target.checked })}
/>
{t('owner.presence.maintenanceMode')}
</label>
<div className="space-y-2">
<Label>{t('owner.presence.maintenanceMessage')}</Label>
<Input
value={value.maintenanceMessage ?? ''}
onChange={(e) => setValue({ ...value, maintenanceMessage: e.target.value || null })}
/>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}
export function UsersManager({
initialUsers
}: {

View File

@@ -0,0 +1,249 @@
'use client';
import type { BotPresenceConfig } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { SettingsForm } from '@/components/settings/settings-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
const STATUS_OPTIONS = ['online', 'idle', 'dnd', 'invisible'] as const;
const ACTIVITY_OPTIONS = ['Playing', 'Listening', 'Watching', 'Competing'] as const;
const ACTIVITY_TEXT_MAX = 128;
const MAINTENANCE_MESSAGE_MAX = 500;
const ROTATING_MAX = 20;
const STATUS_DOT: Record<BotPresenceConfig['status'], string> = {
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-red-500',
invisible: 'bg-zinc-500'
};
async function readError(response: Response): Promise<string> {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
return data?.error ?? 'Request failed';
}
function PresencePreview({ value }: { value: BotPresenceConfig }) {
const t = useTranslations();
const previewStatus = value.maintenanceMode ? 'dnd' : value.status;
const previewType = value.maintenanceMode
? t('owner.presence.activityTypeLabel.Watching')
: t(`owner.presence.activityTypeLabel.${value.activityType}`);
const previewText = value.maintenanceMode
? value.maintenanceMessage?.trim() || t('owner.presence.maintenanceFallbackActivity')
: value.rotatingMessages[0] ?? value.activityText;
return (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('owner.presence.previewTitle')}
</p>
{value.maintenanceMode ? (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
{t('owner.presence.maintenancePreviewBanner')}
</p>
) : null}
<div className="flex items-start gap-3">
<div className="relative mt-0.5 size-10 shrink-0 rounded-full bg-zinc-700">
<span
className={cn(
'absolute bottom-0 right-0 size-3 rounded-full border-2 border-zinc-900',
STATUS_DOT[previewStatus]
)}
title={t(`owner.presence.statusLabel.${previewStatus}`)}
/>
</div>
<div className="min-w-0">
<p className="font-medium">Nexumi</p>
<p className="truncate text-sm text-muted-foreground">
{previewType} {previewText}
</p>
{!value.maintenanceMode && value.rotatingMessages.length > 1 ? (
<p className="mt-1 text-xs text-muted-foreground">{t('owner.presence.previewRotatingHint')}</p>
) : null}
</div>
</div>
</div>
);
}
export function PresenceForm({
initialValue,
canEdit
}: {
initialValue: BotPresenceConfig;
canEdit: boolean;
}) {
const t = useTranslations();
const readOnly = !canEdit;
return (
<SettingsForm
initialValue={initialValue}
readOnly={readOnly}
onSave={async (value) => {
if (readOnly) {
return { ok: false, error: t('owner.presence.readOnly') };
}
const response = await fetch('/api/owner/presence', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value)
});
if (!response.ok) {
return { ok: false, error: await readError(response) };
}
return { ok: true };
}}
>
{({ value, setValue, error }) => (
<div className="space-y-6">
{!canEdit ? (
<p className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t('owner.presence.readOnly')}
</p>
) : null}
<Card>
<CardHeader>
<CardTitle>{t('owner.presence.editorTitle')}</CardTitle>
<CardDescription>{t('owner.presence.editorDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<PresencePreview value={value} />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('owner.presence.status')}</Label>
<Select
value={value.status}
disabled={readOnly || value.maintenanceMode}
onValueChange={(status) =>
setValue((prev) => ({ ...prev, status: status as BotPresenceConfig['status'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{t(`owner.presence.statusLabel.${status}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('owner.presence.activityType')}</Label>
<Select
value={value.activityType}
disabled={readOnly || value.maintenanceMode}
onValueChange={(activityType) =>
setValue((prev) => ({
...prev,
activityType: activityType as BotPresenceConfig['activityType']
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVITY_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{t(`owner.presence.activityTypeLabel.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('owner.presence.activityText')}</Label>
<span className="text-xs text-muted-foreground">
{value.activityText.length}/{ACTIVITY_TEXT_MAX}
</span>
</div>
<Input
value={value.activityText}
disabled={readOnly || value.maintenanceMode}
maxLength={ACTIVITY_TEXT_MAX}
onChange={(e) => setValue((prev) => ({ ...prev, activityText: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">{t('owner.presence.activityTextHint')}</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('owner.presence.rotating')}</Label>
<span className="text-xs text-muted-foreground">
{value.rotatingMessages.length}/{ROTATING_MAX}
</span>
</div>
<Textarea
className="min-h-24"
disabled={readOnly || value.maintenanceMode}
value={value.rotatingMessages.join('\n')}
onChange={(e) =>
setValue((prev) => ({
...prev,
rotatingMessages: e.target.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.slice(0, ACTIVITY_TEXT_MAX))
.slice(0, ROTATING_MAX)
}))
}
/>
<p className="text-xs text-muted-foreground">{t('owner.presence.rotatingHint')}</p>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-1 pr-4">
<Label>{t('owner.presence.maintenanceMode')}</Label>
<p className="text-xs text-muted-foreground">{t('owner.presence.maintenanceHint')}</p>
</div>
<Switch
checked={value.maintenanceMode}
disabled={readOnly}
onCheckedChange={(checked) => {
if (checked && !window.confirm(t('owner.presence.maintenanceConfirm'))) {
return;
}
setValue((prev) => ({ ...prev, maintenanceMode: checked }));
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('owner.presence.maintenanceMessage')}</Label>
<span className="text-xs text-muted-foreground">
{(value.maintenanceMessage ?? '').length}/{MAINTENANCE_MESSAGE_MAX}
</span>
</div>
<Input
value={value.maintenanceMessage ?? ''}
disabled={readOnly || !value.maintenanceMode}
maxLength={MAINTENANCE_MESSAGE_MAX}
onChange={(e) =>
setValue((prev) => ({
...prev,
maintenanceMessage: e.target.value || null
}))
}
/>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}

View File

@@ -20,6 +20,8 @@ interface SettingsFormProps<T> {
onSave: (value: T) => Promise<SettingsSaveResult>;
children: (props: SettingsFormRenderProps<T>) => ReactNode;
isEqual?: (a: T, b: T) => boolean;
/** When true, draft editing UI still works locally but the sticky save bar is hidden. */
readOnly?: boolean;
}
function defaultIsEqual<T>(a: T, b: T): boolean {
@@ -31,14 +33,20 @@ function defaultIsEqual<T>(a: T, b: T): boolean {
* local draft against the last-saved baseline, shows a sticky save bar while
* dirty, and surfaces inline + toast errors on save failure.
*/
export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defaultIsEqual }: SettingsFormProps<T>) {
export function SettingsForm<T>({
initialValue,
onSave,
children,
isEqual = defaultIsEqual,
readOnly = false
}: SettingsFormProps<T>) {
const t = useTranslations();
const [baseline, setBaseline] = useState(initialValue);
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const isDirty = !isEqual(baseline, value);
const isDirty = !readOnly && !isEqual(baseline, value);
const handleDiscard = useCallback(() => {
setValue(baseline);
@@ -46,6 +54,9 @@ export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defa
}, [baseline]);
const handleSave = useCallback(() => {
if (readOnly) {
return;
}
setError(null);
startTransition(async () => {
try {
@@ -62,10 +73,10 @@ export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defa
toast.error(t('common.saveError'));
}
});
}, [onSave, t, value]);
}, [onSave, readOnly, t, value]);
return (
<div className="space-y-6 pb-20">
<div className={readOnly ? 'space-y-6' : 'space-y-6 pb-20'}>
{children({ value, setValue, isDirty, isSaving: isPending, error })}
<SaveBar visible={isDirty} saving={isPending} onSave={handleSave} onDiscard={handleDiscard} />
</div>

View File

@@ -5,6 +5,7 @@ import {
type BotPresenceConfig
} from '@nexumi/shared';
import { prisma } from './prisma';
import { getPresenceQueue } from './queues';
import { redis } from './redis';
function mapRow(row: {
@@ -63,5 +64,13 @@ export async function updatePresenceConfig(patch: unknown): Promise<BotPresenceC
});
const mapped = mapRow(row);
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
await getPresenceQueue().add(
'presenceRefresh',
{},
{
removeOnComplete: 5,
removeOnFail: 5
}
);
return mapped;
}

View File

@@ -21,6 +21,7 @@ export const suggestionsQueueName = 'suggestions';
export const messagesQueueName = 'messages';
export const verificationQueueName = 'verification';
export const automodQueueName = 'automod';
export const presenceQueueName = 'presence';
const globalForQueues = globalThis as unknown as {
__nexumiGiveawayQueue?: Queue;
@@ -30,6 +31,7 @@ const globalForQueues = globalThis as unknown as {
__nexumiMessagesQueue?: Queue;
__nexumiVerificationQueue?: Queue;
__nexumiAutomodQueue?: Queue;
__nexumiPresenceQueue?: Queue;
__nexumiGiveawayQueueEvents?: QueueEvents;
__nexumiGuildBackupQueueEvents?: QueueEvents;
__nexumiSuggestionsQueueEvents?: QueueEvents;
@@ -94,6 +96,13 @@ export function getAutomodQueue(): Queue {
return globalForQueues.__nexumiAutomodQueue;
}
export function getPresenceQueue(): Queue {
globalForQueues.__nexumiPresenceQueue ??= new Queue(presenceQueueName, {
connection: queueConnection()
});
return globalForQueues.__nexumiPresenceQueue;
}
/**
* QueueEvents instances are only needed for jobs where the dashboard needs
* to wait for the bot to finish (e.g. ending a giveaway so the winners list

View File

@@ -0,0 +1,40 @@
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
function discordDefaultAvatarUrl(userId: string): string {
try {
const index = Number((BigInt(userId) >> 22n) % 6n);
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
} catch {
return 'https://cdn.discordapp.com/embed/avatars/0.png';
}
}
function userAvatarUrl(user: SessionUser): string {
return user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=256`
: discordDefaultAvatarUrl(user.id);
}
function guildIconUrl(guild: DashboardGuild): string {
return guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`
: discordDefaultAvatarUrl(guild.id);
}
/** Preview placeholders from the logged-in dashboard user and current guild. */
export function buildWelcomePreviewVars(
user: SessionUser,
guild: DashboardGuild
): Record<string, string> {
const displayName = user.globalName?.trim() || user.username;
return {
user: `@${displayName}`,
'user.name': displayName,
'user.tag': user.username,
'user.id': user.id,
'user.avatar': userAvatarUrl(user),
server: guild.name,
'server.icon': guildIconUrl(guild),
memberCount: '—'
};
}

View File

@@ -1034,12 +1034,35 @@
"presence": {
"title": "Bot-Präsenz",
"subtitle": "Status, Aktivität und Wartungsmodus.",
"editorTitle": "Präsenz bearbeiten",
"editorDescription": "Änderungen gelten nach dem Speichern sofort für den Bot.",
"previewTitle": "Vorschau",
"previewRotatingHint": "Vorschau zeigt die erste rotierende Zeile; Discord wechselt ca. jede Minute.",
"status": "Status",
"activityType": "Aktivitätstyp",
"activityText": "Aktivitätstext",
"activityTextHint": "Fallback, wenn keine rotierenden Nachrichten gesetzt sind.",
"rotating": "Rotierende Nachrichten (eine pro Zeile)",
"rotatingHint": "Max. 20 Zeilen à 128 Zeichen. Wechsel ca. jede Minute.",
"maintenanceMode": "Wartungsmodus aktiv",
"maintenanceMessage": "Wartungsnachricht"
"maintenanceMessage": "Wartungsnachricht",
"maintenanceHint": "Slash-Commands antworten nur noch mit dem Wartungshinweis.",
"maintenanceConfirm": "Wartungsmodus wirklich aktivieren? Nutzer können dann keine Commands mehr nutzen.",
"maintenancePreviewBanner": "Wartungsmodus: Discord zeigt DND und den Wartungstext.",
"maintenanceFallbackActivity": "Wartungsmodus",
"readOnly": "Nur Ansicht Speichern erfordert die Owner-Rolle Admin oder höher.",
"statusLabel": {
"online": "Online",
"idle": "Abwesend",
"dnd": "Bitte nicht stören",
"invisible": "Unsichtbar"
},
"activityTypeLabel": {
"Playing": "Spielt",
"Listening": "Hört",
"Watching": "Schaut",
"Competing": "Tritt an in"
}
},
"team": {
"title": "Team",

View File

@@ -1034,12 +1034,35 @@
"presence": {
"title": "Bot presence",
"subtitle": "Status, activity, and maintenance mode.",
"editorTitle": "Edit presence",
"editorDescription": "Changes apply to the bot immediately after saving.",
"previewTitle": "Preview",
"previewRotatingHint": "Preview shows the first rotating line; Discord rotates about every minute.",
"status": "Status",
"activityType": "Activity type",
"activityText": "Activity text",
"activityTextHint": "Fallback when no rotating messages are set.",
"rotating": "Rotating messages (one per line)",
"rotatingHint": "Max. 20 lines of 128 characters. Rotates about every minute.",
"maintenanceMode": "Maintenance mode enabled",
"maintenanceMessage": "Maintenance message"
"maintenanceMessage": "Maintenance message",
"maintenanceHint": "Slash commands will only reply with the maintenance notice.",
"maintenanceConfirm": "Really enable maintenance mode? Users will not be able to use commands.",
"maintenancePreviewBanner": "Maintenance mode: Discord shows DND and the maintenance text.",
"maintenanceFallbackActivity": "Maintenance",
"readOnly": "View only — saving requires Admin owner role or higher.",
"statusLabel": {
"online": "Online",
"idle": "Idle",
"dnd": "Do not disturb",
"invisible": "Invisible"
},
"activityTypeLabel": {
"Playing": "Playing",
"Listening": "Listening to",
"Watching": "Watching",
"Competing": "Competing in"
}
},
"team": {
"title": "Team",