diff --git a/apps/bot/prisma/migrations/20260725170000_tag_cooldown/migration.sql b/apps/bot/prisma/migrations/20260725170000_tag_cooldown/migration.sql new file mode 100644 index 0000000..a311b4e --- /dev/null +++ b/apps/bot/prisma/migrations/20260725170000_tag_cooldown/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Tag" ADD COLUMN "cooldownSeconds" INTEGER NOT NULL DEFAULT 15; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index c685304..e821793 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -584,21 +584,22 @@ model SelfRolePanel { } model Tag { - id String @id @default(cuid()) - guildId String - name String - content String? - embed Json? - components Json? - responseType String @default("TEXT") - triggerWord String? - allowedRoleIds String[] @default([]) + id String @id @default(cuid()) + guildId String + name String + content String? + embed Json? + components Json? + responseType String @default("TEXT") + triggerWord String? + cooldownSeconds Int @default(15) + allowedRoleIds String[] @default([]) allowedChannelIds String[] @default([]) - createdById String - useCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + createdById String + useCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, name]) @@index([guildId]) diff --git a/apps/bot/src/modules/stats/commands.ts b/apps/bot/src/modules/stats/commands.ts index f3dfa51..dcd1829 100644 --- a/apps/bot/src/modules/stats/commands.ts +++ b/apps/bot/src/modules/stats/commands.ts @@ -7,8 +7,8 @@ import { invitesCommandData, statsCommandData } from './command-definitions.js'; import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js'; import { getStatsConfig, + refreshGuildStatsChannels, StatsSetupSchema, - updateStatsChannels, upsertStatsConfig } from './service.js'; @@ -47,7 +47,7 @@ const statsCommand: SlashCommand = { } await upsertStatsConfig(context.prisma, guildId, parsed.data); - await updateStatsChannels(context); + await refreshGuildStatsChannels(context, guildId); await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true }); return; @@ -64,7 +64,7 @@ const statsCommand: SlashCommand = { return; } - await updateStatsChannels(context); + await refreshGuildStatsChannels(context, guildId); await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true }); } } diff --git a/apps/bot/src/modules/stats/index.ts b/apps/bot/src/modules/stats/index.ts index edb2b4b..e7dcaa1 100644 --- a/apps/bot/src/modules/stats/index.ts +++ b/apps/bot/src/modules/stats/index.ts @@ -4,6 +4,9 @@ export { ensureStatsRecurringJobs, startStatsWorker, updateStatsChannels, + refreshGuildStatsChannels, + runStatsGuildRefreshJob, STATS_REFRESH_CRON, - STATS_REFRESH_JOB_NAME + STATS_REFRESH_JOB_NAME, + STATS_GUILD_REFRESH_JOB_NAME } from './service.js'; diff --git a/apps/bot/src/modules/stats/service.ts b/apps/bot/src/modules/stats/service.ts index bad7e13..52d39f9 100644 --- a/apps/bot/src/modules/stats/service.ts +++ b/apps/bot/src/modules/stats/service.ts @@ -21,6 +21,8 @@ export type StatsSetupInput = z.infer; export const STATS_REFRESH_CRON = '*/10 * * * *'; export const STATS_REFRESH_JOB_NAME = 'statsRefresh'; +/** One-shot refresh for a single guild (dashboard save / slash refresh). */ +export const STATS_GUILD_REFRESH_JOB_NAME = 'statsRefreshGuild'; export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) { await ensureGuild(prisma, guildId); @@ -97,13 +99,17 @@ async function renameStatsChannel( }); } -async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise { +export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise { const config = await context.prisma.statsConfig.findUnique({ where: { guildId } }); if (!config?.enabled) { return; } - const guild = await context.client.guilds.fetch(guildId).catch(() => null); + // withCounts populates approximatePresenceCount / approximateMemberCount. + // GuildPresences is not privileged for us — presence cache would stay empty. + const guild = await context.client.guilds + .fetch({ guild: guildId, withCounts: true }) + .catch(() => null); if (!guild) { return; } @@ -114,9 +120,7 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string): return; } - await guild.members.fetch().catch(() => undefined); - - const memberCount = guild.memberCount; + const memberCount = guild.approximateMemberCount ?? guild.memberCount; const onlineCount = estimateOnlineCount(guild); const boostCount = guild.premiumSubscriptionCount ?? 0; @@ -127,6 +131,13 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string): ]); } +export async function runStatsGuildRefreshJob( + context: BotContext, + data: { guildId: string } +): Promise { + await refreshGuildStatsChannels(context, data.guildId); +} + export async function updateStatsChannels(context: BotContext): Promise { const configs = await context.prisma.statsConfig.findMany({ where: { enabled: true } @@ -159,6 +170,15 @@ export function startStatsWorker(context: BotContext): Worker { async (job) => { if (job.name === STATS_REFRESH_JOB_NAME) { await updateStatsChannels(context); + return; + } + if (job.name === STATS_GUILD_REFRESH_JOB_NAME) { + const guildId = typeof job.data?.guildId === 'string' ? job.data.guildId : null; + if (!guildId) { + logger.warn({ jobId: job.id }, 'statsRefreshGuild missing guildId'); + return; + } + await runStatsGuildRefreshJob(context, { guildId }); } }, { connection: redis } diff --git a/apps/bot/src/modules/tags/commands.ts b/apps/bot/src/modules/tags/commands.ts index 2ee8c39..cced794 100644 --- a/apps/bot/src/modules/tags/commands.ts +++ b/apps/bot/src/modules/tags/commands.ts @@ -172,6 +172,9 @@ const tagCommand: SlashCommand = { tag.triggerWord ? tf(locale, 'tag.info.trigger', { word: tag.triggerWord }) : t(locale, 'tag.info.noTrigger'), + tag.cooldownSeconds > 0 + ? tf(locale, 'tag.info.cooldown', { seconds: tag.cooldownSeconds }) + : t(locale, 'tag.info.noCooldown'), tag.allowedRoleIds.length > 0 ? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') }) : t(locale, 'tag.info.allRoles'), @@ -189,8 +192,12 @@ const tagCommand: SlashCommand = { } } catch (error) { if (error instanceof TagError) { + const content = + error.code === 'cooldown' && error.retryAfterSeconds !== undefined + ? tf(locale, 'tag.error.cooldown', { seconds: error.retryAfterSeconds }) + : t(locale, `tag.error.${error.code}`); await interaction.reply({ - content: t(locale, `tag.error.${error.code}`), + content, ephemeral: true }); return; diff --git a/apps/bot/src/modules/tags/events.ts b/apps/bot/src/modules/tags/events.ts index 2a9ed4d..d494355 100644 --- a/apps/bot/src/modules/tags/events.ts +++ b/apps/bot/src/modules/tags/events.ts @@ -4,8 +4,10 @@ import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { logger } from '../../logger.js'; import { + applyTagCooldown, buildTagReply, findTriggeredTag, + getTagCooldownRemaining, memberCanUseTag, TagError } from './service.js'; @@ -31,6 +33,18 @@ export function registerTagEvents(client: Client, context: BotContext): void { return; } + // Channel-scoped cooldown: silent skip to avoid spam replies. + if (tag.cooldownSeconds > 0) { + const remaining = await getTagCooldownRemaining( + message.guild.id, + tag.id, + message.channel.id + ); + if (remaining > 0) { + return; + } + } + await context.prisma.tag.update({ where: { id: tag.id }, data: { useCount: { increment: 1 } } @@ -38,6 +52,7 @@ export function registerTagEvents(client: Client, context: BotContext): void { const payload = buildTagReply(tag, member, message.guild, ''); await message.reply(payload); + await applyTagCooldown(message.guild.id, tag.id, message.channel.id, tag.cooldownSeconds); } catch (error) { if (error instanceof TagError) { const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null); diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts index 9734cb2..f3f4cec 100644 --- a/apps/bot/src/modules/tags/service.ts +++ b/apps/bot/src/modules/tags/service.ts @@ -14,15 +14,44 @@ import type { Tag } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { redis } from '../../redis.js'; import type { BotContext } from '../../types.js'; import { assertPremiumLimit, PremiumLimitError } from '../../premium.js'; +import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js'; + +export { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js'; export class TagError extends Error { - constructor(public readonly code: string) { + constructor( + public readonly code: string, + public readonly retryAfterSeconds?: number + ) { super(code); } } +/** Returns remaining cooldown seconds, or 0 if ready. */ +export async function getTagCooldownRemaining( + guildId: string, + tagId: string, + scopeId: string +): Promise { + const ttl = await redis.ttl(tagCooldownKey(guildId, tagId, scopeId)); + return ttl > 0 ? ttl : 0; +} + +export async function applyTagCooldown( + guildId: string, + tagId: string, + scopeId: string, + cooldownSeconds: number +): Promise { + if (cooldownSeconds <= 0) { + return; + } + await redis.set(tagCooldownKey(guildId, tagId, scopeId), '1', 'EX', cooldownSeconds); +} + export function normalizeTagName(name: string): string { return name.trim().toLowerCase(); } @@ -147,6 +176,7 @@ export async function createTag( embed?: WelcomeEmbed | null; components?: MessageComponentsV2 | null; triggerWord?: string | null; + cooldownSeconds?: number; allowedRoleIds?: string[]; allowedChannelIds?: string[]; createdById: string; @@ -190,6 +220,7 @@ export async function createTag( embed: params.embed ?? undefined, components: params.components ?? undefined, triggerWord: params.triggerWord?.trim() || null, + cooldownSeconds: params.cooldownSeconds ?? 15, allowedRoleIds: params.allowedRoleIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [], createdById: params.createdById @@ -208,6 +239,7 @@ export async function updateTag( embed?: WelcomeEmbed | null; components?: MessageComponentsV2 | null; triggerWord?: string | null; + cooldownSeconds?: number; allowedRoleIds?: string[]; allowedChannelIds?: string[]; } @@ -255,6 +287,8 @@ export async function updateTag( components: components ?? undefined, triggerWord: updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord, + cooldownSeconds: + updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds } @@ -294,18 +328,22 @@ export async function executeTag( throw new TagError('not_allowed'); } + const scopeId = member?.id ?? channelId; + if (tag.cooldownSeconds > 0) { + const remaining = await getTagCooldownRemaining(guildId, tag.id, scopeId); + if (remaining > 0) { + throw new TagError('cooldown', remaining); + } + } + await context.prisma.tag.update({ where: { id: tag.id }, data: { useCount: { increment: 1 } } }); - return buildTagReply(tag, member, guild, args); -} + await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds); -export function matchesTriggerWord(content: string, triggerWord: string): boolean { - const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`\\b${escaped}\\b`, 'i'); - return pattern.test(content); + return buildTagReply(tag, member, guild, args); } export async function findTriggeredTag( diff --git a/apps/bot/src/modules/tags/tag-helpers.test.ts b/apps/bot/src/modules/tags/tag-helpers.test.ts new file mode 100644 index 0000000..055a9c1 --- /dev/null +++ b/apps/bot/src/modules/tags/tag-helpers.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js'; + +describe('tagCooldownKey', () => { + it('builds a stable redis key', () => { + expect(tagCooldownKey('g1', 't1', 'c1')).toBe('tag:cooldown:g1:t1:c1'); + }); +}); + +describe('matchesTriggerWord', () => { + it('matches whole words case-insensitively', () => { + expect(matchesTriggerWord('Hello world', 'hello')).toBe(true); + expect(matchesTriggerWord('say helloworld', 'hello')).toBe(false); + }); +}); diff --git a/apps/bot/src/modules/tags/tag-helpers.ts b/apps/bot/src/modules/tags/tag-helpers.ts new file mode 100644 index 0000000..cf49f22 --- /dev/null +++ b/apps/bot/src/modules/tags/tag-helpers.ts @@ -0,0 +1,10 @@ +/** Redis key for tag cooldowns (guild + tag + user or channel scope). */ +export function tagCooldownKey(guildId: string, tagId: string, scopeId: string): string { + return `tag:cooldown:${guildId}:${tagId}:${scopeId}`; +} + +export function matchesTriggerWord(content: string, triggerWord: string): boolean { + const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`\\b${escaped}\\b`, 'i'); + return pattern.test(content); +} diff --git a/apps/webui/src/components/modules/tags-manager.tsx b/apps/webui/src/components/modules/tags-manager.tsx index 79d69dd..d520e0c 100644 --- a/apps/webui/src/components/modules/tags-manager.tsx +++ b/apps/webui/src/components/modules/tags-manager.tsx @@ -42,6 +42,7 @@ const EMPTY_DRAFT = { components: null as MessageComponentsV2 | null, responseType: 'TEXT' as TagResponseType, triggerWord: '', + cooldownSeconds: 15, allowedRoleIds: [] as string[], allowedChannelIds: [] as string[] }; @@ -56,6 +57,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { const [tags, setTags] = useState( initialTags.map((tag, index) => ({ ...tag, + cooldownSeconds: tag.cooldownSeconds ?? 15, embed: tag.embed ?? null, components: tag.components ?? null, clientKey: tag.id ?? `existing-${index}` @@ -100,6 +102,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { components: validated.components, responseType: draft.responseType, triggerWord: draft.triggerWord.trim() || null, + cooldownSeconds: draft.cooldownSeconds, allowedRoleIds: draft.allowedRoleIds, allowedChannelIds: draft.allowedChannelIds }) @@ -142,6 +145,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { components: validated.components, responseType: tag.responseType, triggerWord: tag.triggerWord, + cooldownSeconds: tag.cooldownSeconds, allowedRoleIds: tag.allowedRoleIds, allowedChannelIds: tag.allowedChannelIds }) @@ -255,6 +259,22 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} /> +
+ + + setDraft((prev) => ({ + ...prev, + cooldownSeconds: Math.min(3600, Math.max(0, Number(event.target.value) || 0)) + })) + } + /> +

{t('modulePages.tags.cooldownHint')}

+
{renderResponseEditor( draft.responseType, @@ -325,6 +345,28 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} /> +
+ + + setTags((prev) => + prev.map((entry) => + entry.clientKey === tag.clientKey + ? { + ...entry, + cooldownSeconds: Math.min(3600, Math.max(0, Number(event.target.value) || 0)) + } + : entry + ) + ) + } + /> +

{t('modulePages.tags.cooldownHint')}

+
{renderResponseEditor( tag.responseType, diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index 4cb3d93..d20a375 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -724,6 +724,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [ href: 'tags', hash: 'field-tags-list' }, + { + id: 'setting-tags-cooldown', + kind: 'setting', + labelKey: 'modulePages.tags.cooldownSeconds', + href: 'tags', + hash: 'field-tags-create' + }, // Starboard { id: 'setting-starboard-enabled', diff --git a/apps/webui/src/lib/module-configs/stats.ts b/apps/webui/src/lib/module-configs/stats.ts index 720e22f..86b5b34 100644 --- a/apps/webui/src/lib/module-configs/stats.ts +++ b/apps/webui/src/lib/module-configs/stats.ts @@ -1,5 +1,6 @@ import type { StatsConfigDashboard, StatsConfigDashboardPatch } from '@nexumi/shared'; import { prisma } from '../prisma'; +import { addJobAndAwait, getStatsQueue, getStatsQueueEvents } from '../queues'; async function ensureStatsConfig(guildId: string) { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); @@ -18,6 +19,46 @@ function toDashboard(config: Awaited>): Sta }; } +/** + * Dashboard has no discord.js Client, so channel renames are delegated to the bot + * via the `stats` BullMQ queue (`statsRefreshGuild`). + */ +async function enqueueStatsRefresh(guildId: string): Promise { + const { confirmed } = await addJobAndAwait( + getStatsQueue(), + getStatsQueueEvents(), + 'statsRefreshGuild', + { guildId }, + { + jobId: `stats-refresh-${guildId}-${Date.now()}`, + removeOnComplete: true, + removeOnFail: 25 + }, + 30_000 + ); + + if (!confirmed) { + console.warn('[stats] guild refresh not confirmed in time', { guildId }); + } +} + +async function maybeRefreshStatsChannels(guildId: string): Promise { + const config = await prisma.statsConfig.findUnique({ where: { guildId } }); + if ( + !config?.enabled || + (!config.membersChannelId && !config.onlineChannelId && !config.boostsChannelId) + ) { + return; + } + + // Config save must succeed even if Discord rename fails (permissions / bot down). + try { + await enqueueStatsRefresh(guildId); + } catch (error) { + console.warn('[stats] refresh after dashboard save failed', { guildId, error }); + } +} + export async function getStatsDashboard(guildId: string): Promise { const config = await ensureStatsConfig(guildId); return toDashboard(config); @@ -45,5 +86,7 @@ export async function updateStatsDashboard( : {}) } }); + + await maybeRefreshStatsChannels(guildId); return toDashboard(updated); } diff --git a/apps/webui/src/lib/module-configs/tags.ts b/apps/webui/src/lib/module-configs/tags.ts index d58a691..ad860fe 100644 --- a/apps/webui/src/lib/module-configs/tags.ts +++ b/apps/webui/src/lib/module-configs/tags.ts @@ -42,6 +42,7 @@ function toDashboard(tag: Tag): TagDashboard { components: parseComponents(tag.components), responseType: tag.responseType as TagDashboard['responseType'], triggerWord: tag.triggerWord, + cooldownSeconds: tag.cooldownSeconds, allowedRoleIds: tag.allowedRoleIds, allowedChannelIds: tag.allowedChannelIds }; @@ -83,6 +84,7 @@ export async function createTag( : (input.components as Prisma.InputJsonValue), responseType: input.responseType, triggerWord: input.triggerWord ?? null, + cooldownSeconds: input.cooldownSeconds ?? 15, allowedRoleIds: input.allowedRoleIds, allowedChannelIds: input.allowedChannelIds, createdById @@ -124,6 +126,7 @@ export async function updateTag( : {}), ...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}), ...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}), + ...(patch.cooldownSeconds !== undefined ? { cooldownSeconds: patch.cooldownSeconds } : {}), ...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}), ...(patch.allowedChannelIds !== undefined ? { allowedChannelIds: patch.allowedChannelIds } : {}) } diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index 679cb7d..7ae1c3f 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -24,6 +24,7 @@ export const automodQueueName = 'automod'; export const presenceQueueName = 'presence'; export const selfrolesQueueName = 'selfroles'; export const ticketsQueueName = 'tickets'; +export const statsQueueName = 'stats'; const globalForQueues = globalThis as unknown as { __nexumiGiveawayQueue?: Queue; @@ -36,6 +37,7 @@ const globalForQueues = globalThis as unknown as { __nexumiPresenceQueue?: Queue; __nexumiSelfrolesQueue?: Queue; __nexumiTicketsQueue?: Queue; + __nexumiStatsQueue?: Queue; __nexumiGiveawayQueueEvents?: QueueEvents; __nexumiGuildBackupQueueEvents?: QueueEvents; __nexumiSuggestionsQueueEvents?: QueueEvents; @@ -43,6 +45,7 @@ const globalForQueues = globalThis as unknown as { __nexumiSelfrolesQueueEvents?: QueueEvents; __nexumiVerificationQueueEvents?: QueueEvents; __nexumiTicketsQueueEvents?: QueueEvents; + __nexumiStatsQueueEvents?: QueueEvents; }; function queueConnection() { @@ -124,6 +127,13 @@ export function getTicketsQueue(): Queue { return globalForQueues.__nexumiTicketsQueue; } +export function getStatsQueue(): Queue { + globalForQueues.__nexumiStatsQueue ??= new Queue(statsQueueName, { + connection: queueConnection() + }); + return globalForQueues.__nexumiStatsQueue; +} + /** * 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 @@ -178,6 +188,13 @@ export function getTicketsQueueEvents(): QueueEvents { return globalForQueues.__nexumiTicketsQueueEvents; } +export function getStatsQueueEvents(): QueueEvents { + globalForQueues.__nexumiStatsQueueEvents ??= new QueueEvents(statsQueueName, { + connection: queueEventsConnection() + }); + return globalForQueues.__nexumiStatsQueueEvents; +} + const DEFAULT_WAIT_TIMEOUT_MS = 15_000; /** diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index e3f2331..499bafb 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -820,6 +820,8 @@ "COMPONENTS_V2": "Components V2" }, "triggerWord": "Auslöse-Wort (optional)", + "cooldownSeconds": "Cooldown (Sekunden)", + "cooldownHint": "Mindestabstand zwischen Auto-Antworten im selben Kanal. 0 = kein Cooldown. Standard: 15.", "content": "Inhalt", "embed": "Embed", "components": "Components-V2-Layout", @@ -899,13 +901,13 @@ }, "stats": { "title": "Server-Statistiken", - "description": "Automatisch aktualisierte Voice-Kanäle mit Live-Serverstatistiken.", + "description": "Voice-Kanäle mit Live-Werten. Nach dem Speichern (Modul aktiviert) werden die Namen sofort aktualisiert, danach alle 10 Minuten. Der Bot braucht „Kanäle verwalten“.", "enabledLabel": "Server-Statistiken aktiviert", - "membersChannelId": "Mitglieder-Kanal-ID", + "membersChannelId": "Mitglieder-Kanal", "membersTemplate": "Mitglieder-Vorlage", - "onlineChannelId": "Online-Kanal-ID", + "onlineChannelId": "Online-Kanal", "onlineTemplate": "Online-Vorlage", - "boostsChannelId": "Boosts-Kanal-ID", + "boostsChannelId": "Boosts-Kanal", "boostsTemplate": "Boosts-Vorlage", "templateHint": "Nutze {count} als Platzhalter für den Live-Wert." }, diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index e67dcd2..16fb14d 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -820,6 +820,8 @@ "COMPONENTS_V2": "Components V2" }, "triggerWord": "Trigger word (optional)", + "cooldownSeconds": "Cooldown (seconds)", + "cooldownHint": "Minimum time between auto-replies in the same channel. 0 = no cooldown. Default: 15.", "content": "Content", "embed": "Embed", "components": "Components V2 layout", @@ -899,13 +901,13 @@ }, "stats": { "title": "Server Stats", - "description": "Auto-updating voice channels showing live server statistics.", + "description": "Voice channels with live values. After saving (module enabled) names update immediately, then every 10 minutes. The bot needs Manage Channels.", "enabledLabel": "Server stats enabled", - "membersChannelId": "Members channel ID", + "membersChannelId": "Members channel", "membersTemplate": "Members template", - "onlineChannelId": "Online channel ID", + "onlineChannelId": "Online channel", "onlineTemplate": "Online template", - "boostsChannelId": "Boosts channel ID", + "boostsChannelId": "Boosts channel", "boostsTemplate": "Boosts template", "templateHint": "Use {count} as a placeholder for the live value." }, diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index a0968d5..fd01a75 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -567,3 +567,34 @@ - [ ] Alt-Check mit Block → User sieht Fehler-Ephemeral; Log-Kanal bekommt Embed - [ ] Alt-Check nur Flag (ohne Block) → User wird verifiziert, Log trotzdem +## Post-Phase – Tag Auto-Responder Cooldown (Status: implementiert) + +### Abgeschlossen (Code) + +- `Tag.cooldownSeconds` (Default 15, 0 = aus) inkl. Migration `20260725170000_tag_cooldown` +- Auto-Responder: kanalbezogener Redis-Cooldown, bei aktivem Cooldown stille Ignorierung (kein Spam) +- `/tag run`: nutzerbezogener Cooldown mit ephemeral Hinweis +- Dashboard Tags: Cooldown-Feld (DE/EN), Shared Zod + WebUI Persistenz + +### Manuell testen + +- [ ] Tag mit Trigger-Wort: zweimal schnell tippen → nur erste Antwort +- [ ] Cooldown im Dashboard auf 0 setzen → Bot antwortet wieder sofort +- [ ] `/tag run` während Cooldown → ephemeral mit Restzeit + +## Post-Phase – Server-Stats WebUI Refresh (Bugfix) + +### Abgeschlossen (Code) + +- Root cause: Dashboard speicherte nur `StatsConfig`, ohne Discord-Kanal-Rename; Cron erst alle 10 Min. Online-Count ohne `withCounts` oft 0 (kein GuildPresences-Intent) +- BullMQ-Job `statsRefreshGuild` (Queue `stats`); WebUI triggert nach Speichern bei enabled + mind. einem Kanal +- Guild-Fetch mit `withCounts: true` für `approximatePresenceCount` / Member-Approx +- `/stats setup|refresh` refresht nur den aktuellen Guild (nicht alle) + +### Manuell testen + +- [ ] WebUI: Stats aktivieren, Voice-Kanäle setzen, Speichern → Kanalnamen zeigen `{count}`-Werte innerhalb weniger Sekunden +- [ ] Toggle aus → Speichern → keine weiteren Renames (Cron überspringt disabled) +- [ ] Online-Kanal zeigt sinnvolle Zahl (nicht dauerhaft 0) +- [ ] Bot ohne „Kanäle verwalten“ → Config gespeichert; Bot-Log warnt; nach Permission-Fix erneut speichern + diff --git a/packages/shared/src/dashboard.test.ts b/packages/shared/src/dashboard.test.ts index e71e77f..d2cfa44 100644 --- a/packages/shared/src/dashboard.test.ts +++ b/packages/shared/src/dashboard.test.ts @@ -157,6 +157,19 @@ describe('dashboard schemas', () => { allowedChannelIds: [] }); expect(parsed.name).toBe('rules'); + expect(parsed.cooldownSeconds).toBe(15); + }); + + it('accepts custom tag cooldown', () => { + const parsed = TagDashboardCreateSchema.parse({ + name: 'rules', + content: 'Please read the rules', + responseType: 'TEXT', + cooldownSeconds: 30, + allowedRoleIds: [], + allowedChannelIds: [] + }); + expect(parsed.cooldownSeconds).toBe(30); }); it('validates starboard patches', () => { diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 68ee564..036e37e 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -516,6 +516,8 @@ export const TagDashboardSchema = z.object({ components: MessageComponentsV2Schema.nullable().optional(), responseType: TagResponseTypeSchema, triggerWord: z.string().max(100).nullable().optional(), + /** Seconds between auto-responder /tag replies; 0 disables. Default 15. */ + cooldownSeconds: z.number().int().min(0).max(3600).default(15), allowedRoleIds: z.array(z.string()), allowedChannelIds: z.array(z.string()) }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c11bfb9..d189ed3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -566,12 +566,15 @@ const de: Dictionary = { 'tag.info.uses': 'Aufrufe: {count}', 'tag.info.trigger': 'Trigger: `{word}`', 'tag.info.noTrigger': 'Kein Auto-Responder.', + 'tag.info.cooldown': 'Cooldown: {seconds}s', + 'tag.info.noCooldown': 'Cooldown: aus', 'tag.info.roles': 'Rollen: {roles}', 'tag.info.allRoles': 'Rollen: alle', 'tag.info.channels': 'Kanäle: {channels}', 'tag.info.allChannels': 'Kanäle: alle', 'tag.error.not_found': 'Tag nicht gefunden.', 'tag.error.not_allowed': 'Du darfst diesen Tag hier nicht nutzen.', + 'tag.error.cooldown': 'Cooldown aktiv. Versuche es in {seconds} Sekunden erneut.', 'tag.error.invalid_name': 'Ungültiger Tag-Name (1–32 Zeichen, a-z, 0-9, _, -).', 'tag.error.content_required': 'Text-Tags benötigen Inhalt.', 'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.', @@ -1179,12 +1182,15 @@ const en: Dictionary = { 'tag.info.uses': 'Uses: {count}', 'tag.info.trigger': 'Trigger: `{word}`', 'tag.info.noTrigger': 'No auto-responder.', + 'tag.info.cooldown': 'Cooldown: {seconds}s', + 'tag.info.noCooldown': 'Cooldown: off', 'tag.info.roles': 'Roles: {roles}', 'tag.info.allRoles': 'Roles: all', 'tag.info.channels': 'Channels: {channels}', 'tag.info.allChannels': 'Channels: all', 'tag.error.not_found': 'Tag not found.', 'tag.error.not_allowed': 'You are not allowed to use this tag here.', + 'tag.error.cooldown': 'Cooldown active. Try again in {seconds} seconds.', 'tag.error.invalid_name': 'Invalid tag name (1–32 chars, a-z, 0-9, _, -).', 'tag.error.content_required': 'Text tags require content.', 'tag.error.embed_required': 'Embed tags require a title or description.',