Commands - gabrielg2020/10000-beers GitHub Wiki
This guide covers all available bot commands, how to use them, and how to add new commands.
Commands available to all users in the group.
Aliases: None
Description: Undo your last beer submission (within 10 minutes)
Usage: !undo
Behaviour:
- Removes the last beer submission made by the user
- Only works within 10 minutes of submission
- Deletes the beer record from the database
- Deletes the associated image file
- Cannot undo other users' submissions
Example:
User: !undo
Bot: Beer #42 undone! 🔄
Error cases:
- No beers submitted: "You have no beers submitted in the last 10 minutes to undo"
- User not found: "You have not submitted any beers yet"
- Outside time window: "You have no beers submitted in the last 10 minutes to undo"
Commands restricted to users listed in ADMIN_IDS environment variable.
Aliases: !lb, !top
Description: Show beer leaderboard for all users
Usage: !leaderboard
Admin Only: Yes
Behaviour:
- Displays all users ranked by total beer count
- Shows medals for top 3 positions (🥇🥈🥉)
- Includes total beer count across all users
- Sorted by beer count (descending)
Example output:
🏆 *Beer Leaderboard* 🍺
🥇 1. John - 45 beers
🥈 2. Sarah - 38 beers
🥉 3. Mike - 32 beers
4. Emma - 28 beers
5. Tom - 15 beers
Total: 158 beers
Error cases:
- No beers logged: "No beers have been logged yet! 🍺"
- Non-admin user: "This command is admin only"
Aliases: !rl, !removelast
Description: Remove the last beer submission from a user
Usage: !removeLast @user
Admin Only: Yes
Behaviour:
- Removes the most recent beer submission for the mentioned user
- No time restriction (unlike
!undo) - Deletes the beer record from the database
- Deletes the associated image file
- Requires mentioning exactly one user
Example:
Admin: !removeLast @John
Bot: Removed beer #42 for John ✅
Error cases:
- No user mentioned: "Please mention a user to remove their last beer (e.g., !removeLast @user)"
- Multiple users mentioned: "Please mention only one user at a time"
- User has no beers: "This user has no beers to remove"
- User not found: "User not found"
- Non-admin user: "This command is admin only"
Aliases: !rel
Description: Fetch and display the latest GitHub release
Usage: !release [version]
Admin Only: Yes
Behaviour:
- Fetches the latest release from GitHub (or specific version if provided)
- Displays release title and release notes with markdown formatting removed (bullet points preserved)
- Uses GitHub API (no authentication required for public repos)
- Configurable repository via
GITHUB_REPO_OWNERandGITHUB_REPO_NAME
Examples:
Admin: !release
Bot: 🚀 New Release 🎉
v1.2.0
What's New
- Added AI beer classification
- Improved image processing
- Bug fixes and performance improvements
🔗 https://github.com/gabrielg2020/10000-beers/releases/tag/v1.2.0
Admin: !release v1.0.0
Bot: [Fetches and displays v1.0.0 release]
Error cases:
- No release found: "No release found. Check the repository or version tag."
- GitHub API error: "Failed to fetch release information. Please try again."
- Non-admin user: "This command is admin only"
Configuration:
See Configuration - GitHub for GITHUB_REPO_OWNER and GITHUB_REPO_NAME.
The command system uses a registry pattern for flexible command management:
User sends: !command args
↓
MessageHandler detects command prefix (!)
↓
CommandHandler.handleCommand()
↓
CommandRegistry.get(commandName)
↓
Check admin permission
↓
Command.execute(context)
↓
Reply sent to WhatsApp
All commands implement the Command interface:
interface Command {
readonly name: string;
readonly aliases: string[];
readonly description: string;
readonly adminOnly?: boolean;
execute(context: CommandContext): Promise<CommandResult>;
}Fields:
-
name- Primary command name (e.g.,"undo") -
aliases- Alternative names (e.g.,["lb", "top"]) -
description- Human-readable description -
adminOnly- Whether command requires admin privileges -
execute()- Command execution logic
Commands receive a context object with:
interface CommandContext {
message: Message; // WhatsApp message object
args: string[]; // Command arguments (split by space)
whatsappId: string; // Sender's WhatsApp ID
displayName: string; // Sender's display name
}Commands return a result object:
interface CommandResult {
success: boolean;
reply?: string; // Message to send to WhatsApp
error?: string; // Error message (if any)
}All commands start with ! (exclamation mark):
!undo
!leaderboard
!removeLast @user
Commands are case-insensitive and parsed by whitespace:
!undo → command: "undo", args: []
!removeLast @user → command: "removeLast", args: ["@user"]
Admin-only commands check the sender's WhatsApp ID against ADMIN_IDS:
const isAdmin = config.whatsapp.adminIds.includes(context.whatsappId);
if (command.adminOnly && !isAdmin) {
await message.reply('This command is admin only');
return;
}Commands can throw CommandError for user-facing errors:
throw new CommandError(
'Failed to undo beer', // Technical message (logged)
'UNDO_FAILED', // Error code
'Failed to undo beer. Please try again' // User message (sent to WhatsApp)
);Admins are configured via the ADMIN_IDS environment variable:
ADMIN_IDS=[email protected],[email protected]Format:
- Country code + phone number without
+or spaces - Append
@c.us - Comma-separated (no spaces)
Examples:
- UK:
+44 7123 456789→[email protected] - US:
+1 555 123 4567→[email protected]
- Send a message to the group
- Check the bot logs
- Look for your WhatsApp ID in the format
[email protected] - Add it to
ADMIN_IDSin.env
When a non-admin tries to use an admin command:
User: !leaderboard
Bot: This command is admin only
When ADMIN_IDS is empty, all admin commands are disabled.
Create src/commands/myCommand.ts:
import type { Command, CommandContext, CommandResult } from './types';
import { logger } from '../utils/logger';
import { CommandError } from '../types/statistics';
export class MyCommand implements Command {
readonly name = 'mycommand';
readonly aliases = ['mc', 'mycmd'];
readonly description = 'Description of my command';
readonly adminOnly = false;
async execute(context: CommandContext): Promise<CommandResult> {
try {
logger.debug(
{ whatsappId: context.whatsappId, args: context.args },
'Executing mycommand',
);
// Command logic here
const result = await doSomething(context.args);
return {
success: true,
reply: `Command result: ${result}`,
};
} catch (error) {
logger.error({ error, context }, 'My command failed');
throw new CommandError(
'Failed to execute command',
'MY_COMMAND_FAILED',
'Failed to execute command. Please try again',
);
}
}
}Edit src/commands/index.ts:
import { UndoCommand } from './undoCommand';
import { LeaderbaordCommand } from './leaderboardCommand';
import { RemoveLastCommand } from './removeLastCommand';
import { MyCommand } from './myCommand'; // Add import
export function registerCommands(): void {
commandRegistry.register(new UndoCommand());
commandRegistry.register(new LeaderbaordCommand());
commandRegistry.register(new RemoveLastCommand());
commandRegistry.register(new MyCommand()); // Register command
}Create tests/unit/commands/myCommand.test.ts:
import { MyCommand } from '../../../src/commands/myCommand';
import type { CommandContext } from '../../../src/commands/types';
describe('MyCommand', () => {
let command: MyCommand;
let mockContext: CommandContext;
beforeEach(() => {
command = new MyCommand();
mockContext = {
message: {} as any,
args: [],
whatsappId: '[email protected]',
displayName: 'Test User',
};
});
it('should execute successfully', async () => {
const result = await command.execute(mockContext);
expect(result.success).toBe(true);
expect(result.reply).toBeDefined();
});
it('should handle errors gracefully', async () => {
mockContext.args = ['invalid'];
await expect(command.execute(mockContext)).rejects.toThrow();
});
});Run the bot and test in WhatsApp:
npm run devSend !mycommand in your group and verify the response.
Do:
- Use descriptive command names (e.g.,
stats,leaderboard) - Provide helpful aliases (e.g.,
lbforleaderboard) - Include clear error messages for users
- Log command execution with context
- Validate arguments before processing
- Use
CommandErrorfor user-facing errors
Don't:
- Use ambiguous command names (e.g.,
cmd,do) - Forget to register the command in
index.ts - Return raw error stack traces to users
- Perform long-running operations without feedback
- Ignore admin permission checks for sensitive operations
Good names:
-
stats- Show user statistics -
leaderboard- Show rankings -
undo- Undo last action -
help- Show help message
Bad names:
-
s- Too short, ambiguous -
doStats- Redundant "do" prefix -
showLeaderboardForAllUsers- Too long
Commands receive arguments as a string array:
// User sends: !command arg1 arg2 arg3
context.args = ['arg1', 'arg2', 'arg3']Parse arguments:
async execute(context: CommandContext): Promise<CommandResult> {
const [firstArg, secondArg] = context.args;
if (!firstArg) {
return {
success: false,
reply: 'Usage: !mycommand <arg1> <arg2>',
};
}
// Use arguments
const result = await processCommand(firstArg, secondArg);
return {
success: true,
reply: result,
};
}Commands have access to the full WhatsApp message object:
async execute(context: CommandContext): Promise<CommandResult> {
// Get mentioned users
const mentions = await context.message.getMentions();
// Get message timestamp
const timestamp = context.message.timestamp;
// Get chat information
const chat = await context.message.getChat();
// Reply with formatting
return {
success: true,
reply: '*Bold text*\n_Italic text_\n~Strikethrough~',
};
}Register a command:
commandRegistry.register(new MyCommand());Get a command:
const command = commandRegistry.get('mycommand');
// Also works with aliases:
const command = commandRegistry.get('mc');Check if command exists:
if (commandRegistry.has('mycommand')) {
// Command exists
}Get all commands:
const allCommands = commandRegistry.getAllCommands();
// Returns array of unique Command objects- Commands are registered by name and all aliases
- Command names are case-insensitive
- Later registrations with the same name override earlier ones
- Registry is a singleton shared across the application
messageHandler.handleMessage(message)if (message.body && commandHandler.isCommand(message.body)) {
await commandHandler.handleCommand(message);
return;
}const parts = message.body.slice(1).split(/\s+/);
const commandName = parts[0].toLowerCase();
const args = parts.slice(1);const command = commandRegistry.get(commandName);
if (!command) {
await message.reply('Unknown command');
return;
}if (command.adminOnly && !isAdmin) {
await message.reply('This command is admin only');
return;
}const context: CommandContext = {
message,
args,
whatsappId: contact.id._serialized,
displayName: contact.pushname || contact.name || 'Unknown',
};
const result = await command.execute(context);if (result.reply) {
await message.reply(result.reply);
}class CommandError extends Error {
constructor(
message: string, // Technical message (logged)
code: string, // Error code (UPPER_SNAKE_CASE)
userMessage?: string, // User-facing message
) {
super(message);
this.name = 'CommandError';
}
}throw new CommandError(
'Database query failed',
'DATABASE_ERROR',
'Failed to load data. Please try again',
);Use descriptive, UPPER_SNAKE_CASE error codes:
-
UNDO_FAILED- Undo operation failed -
USER_NOT_FOUND- User not found in database -
NO_BEERS- User has no beers to remove -
INVALID_ARGUMENTS- Invalid command arguments -
LEADERBOARD_FAILED- Leaderboard generation failed
- Getting Started - Set up the bot
- Configuration - Configure admin IDs
- Development Guide - Learn coding standards
- Architecture - Understand the command system architecture