Commands - gabrielg2020/10000-beers GitHub Wiki

Commands

This guide covers all available bot commands, how to use them, and how to add new commands.

Table of Contents


Available Commands

User Commands

Commands available to all users in the group.

!undo

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"

Admin Commands

Commands restricted to users listed in ADMIN_IDS environment variable.

!leaderboard

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"

!removeLast

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"

!release

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_OWNER and GITHUB_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.


Command System Overview

Architecture

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

Command Structure

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

Command Context

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
}

Command Result

Commands return a result object:

interface CommandResult {
  success: boolean;
  reply?: string;          // Message to send to WhatsApp
  error?: string;          // Error message (if any)
}

Using Commands

Command Prefix

All commands start with ! (exclamation mark):

!undo
!leaderboard
!removeLast @user

Command Parsing

Commands are case-insensitive and parsed by whitespace:

!undo          → command: "undo", args: []
!removeLast @user  → command: "removeLast", args: ["@user"]

Admin Permission Check

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;
}

Error Handling

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)
);

Admin Commands

Setting Up Admins

Admins are configured via the ADMIN_IDS environment variable:

Format:

  • Country code + phone number without + or spaces
  • Append @c.us
  • Comma-separated (no spaces)

Examples:

Finding Your WhatsApp ID

  1. Send a message to the group
  2. Check the bot logs
  3. Look for your WhatsApp ID in the format [email protected]
  4. Add it to ADMIN_IDS in .env

Admin Command Behaviour

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.


Adding New Commands

Step-by-Step Guide

1. Create Command File

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',
			);
		}
	}
}

2. Register Command

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
}

3. Write Tests

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();
	});
});

4. Test Your Command

Run the bot and test in WhatsApp:

npm run dev

Send !mycommand in your group and verify the response.

Command Best Practices

Do:

  • Use descriptive command names (e.g., stats, leaderboard)
  • Provide helpful aliases (e.g., lb for leaderboard)
  • Include clear error messages for users
  • Log command execution with context
  • Validate arguments before processing
  • Use CommandError for 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

Command Naming Guidelines

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

Argument Parsing

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,
	};
}

Accessing WhatsApp Features

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~',
	};
}

Command Registry

Registry Methods

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

Registry Behaviour

  • 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

Command Execution Flow

1. Message Received

messageHandler.handleMessage(message)

2. Command Detection

if (message.body && commandHandler.isCommand(message.body)) {
	await commandHandler.handleCommand(message);
	return;
}

3. Command Parsing

const parts = message.body.slice(1).split(/\s+/);
const commandName = parts[0].toLowerCase();
const args = parts.slice(1);

4. Command Lookup

const command = commandRegistry.get(commandName);
if (!command) {
	await message.reply('Unknown command');
	return;
}

5. Admin Check

if (command.adminOnly && !isAdmin) {
	await message.reply('This command is admin only');
	return;
}

6. Command Execution

const context: CommandContext = {
	message,
	args,
	whatsappId: contact.id._serialized,
	displayName: contact.pushname || contact.name || 'Unknown',
};

const result = await command.execute(context);

7. Reply

if (result.reply) {
	await message.reply(result.reply);
}

Error Handling in Commands

CommandError Structure

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';
	}
}

Throwing Errors

throw new CommandError(
	'Database query failed',
	'DATABASE_ERROR',
	'Failed to load data. Please try again',
);

Error Codes

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

Next Steps

⚠️ **GitHub.com Fallback** ⚠️