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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
| `src/utils/debugFooter.js` | Debug stats footer builder and Discord embed wrapper for AI responses |
| `src/utils/duration.js` | Duration parsing — "1h", "7d" ↔ ms with human-readable formatting |
| `src/commands/announce.js` | Scheduled message command — `/announce` with create/list/delete subcommands (moderator-only); stores schedules to `scheduled_messages` table |
| `src/commands/afk.js` | AFK command — `/afk set [reason]` and `/afk clear`; exports `buildPingSummary` used by the handler module |
| `src/modules/afkHandler.js` | AFK message handler — detects AFK mentions, sends inline notices (rate-limited), auto-clears AFK on return, DMs ping summary |
| `src/modules/scheduler.js` | Scheduled message poller — cron expression parser (`parseCron`, `getNextCronRun`), due-message dispatcher via `safeSend`, 60s interval started/stopped via `startScheduler`/`stopScheduler` |
| `migrations/002_scheduled-messages.cjs` | Migration — creates `scheduled_messages` table (id, guild_id, channel_id, content, cron_expression, next_run, is_one_time, created_by) |
| `config.json` | Default configuration (seeded to DB on first run) |
Expand Down Expand Up @@ -137,6 +139,8 @@ Duration-based commands (timeout, tempban, slowmode) use `parseDuration()` from
|-------|---------|
| `mod_cases` | All moderation actions — warn, kick, ban, timeout, etc. One row per action per guild |
| `mod_scheduled_actions` | Scheduled operations (tempban expiry). Polled every 60s by the tempban scheduler |
| `afk_status` | Active AFK records — one row per (guild_id, user_id); upserted on `/afk set`, deleted on return or `/afk clear` |
| `afk_pings` | Pings logged while a user is AFK — accumulated until the user returns, then DM-summarised and deleted |

## How to Add a Module

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ AI-powered Discord bot for the [Volvox](https://volvox.dev) developer community.
- **⚔️ Moderation Suite** — Full-featured mod toolkit: warn, kick, ban, tempban, softban, timeout, purge, lock/unlock, slowmode. Includes case management, mod log routing, DM notifications, auto-escalation, and tempban scheduling.
- **⚙️ Config Management** — All settings stored in PostgreSQL with live `/config` slash command for runtime changes.
- **📊 Health Monitoring** — Built-in health checks and `/status` command for uptime, memory, and latency stats.
- **💤 AFK System** — Members can set an AFK status with `/afk set [reason]`; the bot notifies mentioners inline and DMs a ping summary on return.
- **🎤 Voice Activity Tracking** — Tracks voice channel activity for community insights.
- **🌐 Web Dashboard** — Next.js-based admin dashboard with Discord OAuth2 login, server selector, and guild management UI.

Expand Down
6 changes: 5 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@
"slowmode": "admin",
"modlog": "moderator",
"announce": "moderator",
"tldr": "everyone"
"tldr": "everyone",
"afk": "everyone"
}
},
"help": {
Expand All @@ -165,5 +166,8 @@
"defaultMessages": 50,
"maxMessages": 200,
"cooldownSeconds": 300
},
"afk": {
"enabled": false
}
Comment thread
BillChirico marked this conversation as resolved.
Comment thread
BillChirico marked this conversation as resolved.
}
37 changes: 37 additions & 0 deletions migrations/006_afk.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Migration 006 — AFK system tables
Comment thread
BillChirico marked this conversation as resolved.
*/

'use strict';

exports.up = (pgm) => {
pgm.createTable(
'afk_status',
{
id: { type: 'serial', primaryKey: true },
guild_id: { type: 'text', notNull: true },
user_id: { type: 'text', notNull: true },
reason: { type: 'text', notNull: true, default: 'AFK' },
set_at: { type: 'timestamptz', default: pgm.func('NOW()') },
},
{ constraints: { unique: ['guild_id', 'user_id'] } },
);
Comment thread
BillChirico marked this conversation as resolved.

pgm.createTable('afk_pings', {
id: { type: 'serial', primaryKey: true },
guild_id: { type: 'text', notNull: true },
afk_user_id: { type: 'text', notNull: true },
pinger_id: { type: 'text', notNull: true },
channel_id: { type: 'text', notNull: true },
message_preview: { type: 'text' },
pinged_at: { type: 'timestamptz', default: pgm.func('NOW()') },
});

pgm.createIndex('afk_pings', ['guild_id', 'afk_user_id'], { name: 'idx_afk_pings_user' });
};

exports.down = (pgm) => {
pgm.dropIndex('afk_pings', ['guild_id', 'afk_user_id'], { name: 'idx_afk_pings_user' });
pgm.dropTable('afk_pings');
Comment thread
BillChirico marked this conversation as resolved.
pgm.dropTable('afk_status');
};
47 changes: 32 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/api/utils/configAllowlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const SAFE_CONFIG_KEYS = new Set([
'snippet',
'poll',
'tldr',
'afk',
]);

export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging'];
Expand Down
159 changes: 159 additions & 0 deletions src/commands/afk.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* AFK Command
* Let members set an AFK status. When mentioned while away, the bot
* sends a notice. On return, the user receives a ping summary.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/46
*/

import { SlashCommandBuilder } from 'discord.js';
import { getPool } from '../db.js';
import { info, error as logError } from '../logger.js';
import { getConfig } from '../modules/config.js';
import { safeReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
.setName('afk')
.setDescription('Set or clear your AFK status')
.addSubcommand((sub) =>
sub
.setName('set')
.setDescription('Mark yourself as AFK')
.addStringOption((opt) =>
opt
.setName('reason')
.setDescription('Why are you AFK? (default: AFK)')
.setRequired(false)
.setMaxLength(200),
),
)
.addSubcommand((sub) => sub.setName('clear').setDescription('Clear your AFK status manually'));

// ── Subcommand handlers ────────────────────────────────────────────

async function handleSet(interaction) {
const reason = interaction.options.getString('reason') || 'AFK';
const pool = getPool();

await pool.query(
`INSERT INTO afk_status (guild_id, user_id, reason, set_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (guild_id, user_id) DO UPDATE
SET reason = EXCLUDED.reason, set_at = NOW()`,
[interaction.guildId, interaction.user.id, reason],
);

info('AFK set', { guildId: interaction.guildId, userId: interaction.user.id, reason });

await safeReply(interaction, {
content: `💤 You are now AFK: *${reason}*`,
ephemeral: true,
});
}

async function handleClear(interaction) {
const pool = getPool();

const { rows: afkRows } = await pool.query(
'SELECT * FROM afk_status WHERE guild_id = $1 AND user_id = $2',
Comment thread
BillChirico marked this conversation as resolved.
[interaction.guildId, interaction.user.id],
Comment thread
BillChirico marked this conversation as resolved.
);
Comment thread
BillChirico marked this conversation as resolved.

if (afkRows.length === 0) {
return await safeReply(interaction, {
content: "ℹ️ You're not AFK right now.",
ephemeral: true,
});
}

// Fetch ping summary before deleting
const { rows: pings } = await pool.query(
`SELECT pinger_id, channel_id, message_preview, pinged_at
FROM afk_pings
WHERE guild_id = $1 AND afk_user_id = $2
ORDER BY pinged_at ASC`,
[interaction.guildId, interaction.user.id],
);

// Delete AFK record and pings atomically
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM afk_status WHERE guild_id = $1 AND user_id = $2', [
interaction.guildId,
interaction.user.id,
]);
await client.query('DELETE FROM afk_pings WHERE guild_id = $1 AND afk_user_id = $2', [
interaction.guildId,
interaction.user.id,
]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}

info('AFK cleared (manual)', { guildId: interaction.guildId, userId: interaction.user.id });

const summary = buildPingSummary(pings);
await safeReply(interaction, {
content: `✅ AFK cleared!${summary}`,
ephemeral: true,
});
}

/**
* Build a human-readable ping summary from ping rows.
* @param {Array} pings
* @returns {string}
*/
Comment thread
BillChirico marked this conversation as resolved.
export function buildPingSummary(pings) {
if (pings.length === 0) return '\n\nNo one pinged you while you were away.';

const lines = pings.slice(0, 10).map((p) => {
const time = `<t:${Math.floor(new Date(p.pinged_at).getTime() / 1000)}:R>`;
const preview = p.message_preview ? ` — "${p.message_preview}"` : '';
return `• <@${p.pinger_id}> in <#${p.channel_id}> ${time}${preview}`;
});

const extra = pings.length > 10 ? `\n…and ${pings.length - 10} more.` : '';
return `\n\n**Pings while AFK (${pings.length}):**\n${lines.join('\n')}${extra}`;
}

// ── Execute ────────────────────────────────────────────────────────

/**
* Execute the afk command.
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
export async function execute(interaction) {
const guildConfig = getConfig(interaction.guildId);

if (!guildConfig.afk?.enabled) {
return await safeReply(interaction, {
content: '❌ The AFK feature is not enabled on this server.',
ephemeral: true,
});
}

const subcommand = interaction.options.getSubcommand();

try {
switch (subcommand) {
case 'set':
await handleSet(interaction);
break;
case 'clear':
await handleClear(interaction);
break;
}
} catch (err) {
logError('AFK command failed', { error: err.message, stack: err.stack, subcommand });
await safeReply(interaction, {
content: '❌ Failed to execute AFK command.',
ephemeral: true,
});
}
}
Loading
Loading