Skip to content
Alita Robot
Esc
navigateopen⌘Jpreview
On this page

Module Pattern

How to create new feature modules for Alita Robot.

This guide explains how to add new feature modules to Alita Robot, following the established patterns and conventions.

Module Structure Template

Every module follows this structure:

package modules

import (
    "fmt"
    "strings"

    "github.com/PaulSonOfLars/gotgbot/v2"
    "github.com/PaulSonOfLars/gotgbot/v2/ext"
    "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
    "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery"
    log "github.com/sirupsen/logrus"

    "github.com/divkix/Alita_Robot/alita/db"
    "github.com/divkix/Alita_Robot/alita/i18n"
    "github.com/divkix/Alita_Robot/alita/utils/chat_status"
    "github.com/divkix/Alita_Robot/alita/utils/extraction"
    "github.com/divkix/Alita_Robot/alita/utils/formatting"
)

// Module struct with name for help system
var exampleModule = moduleStruct{moduleName: "Example"}

// Command handler method
func (m moduleStruct) exampleCommand(b *gotgbot.Bot, ctx *ext.Context) error {
    chat := ctx.EffectiveChat
    user := ctx.EffectiveSender.User
    msg := ctx.EffectiveMessage
    tr := i18n.MustNewTranslator(db.GetLanguage(ctx))

    // Permission checks
    if !chat_status.RequireGroup(b, ctx, nil) {
        return ext.EndGroups
    }
    if !chat_status.RequireUserAdmin(b, ctx, nil, user.Id) {
        return ext.EndGroups
    }

    // Business logic here
    text, _ := tr.GetString("example_success_message")
    _, err := msg.Reply(b, text, formatting.Shtml())
    if err != nil {
        log.Error(err)
        // Return ext.EndGroups after user notification, not the error
        return ext.EndGroups
    }

    return ext.EndGroups
}

// Callback handler for inline buttons
func (m moduleStruct) exampleCallback(b *gotgbot.Bot, ctx *ext.Context) error {
    query, ok := callbackQueryFromContext(ctx)
    if !ok {
        return ext.EndGroups
    }
    tr := i18n.MustNewTranslator(db.GetLanguage(ctx))

    // Parse callback data using the modules package wrapper
    // (versioned codec only — legacy dot-notation callbacks are rejected)
    decoded, ok := decodeCallbackData(query.Data, "example")
    if !ok {
        log.Warn("[ExampleCallback] Invalid callback data format")
        _, _ = query.Answer(b, nil)
        return ext.EndGroups
    }
    action, _ := decoded.Field("action")

    // Handle action
    var responseText string
    switch action {
    case "confirm":
        responseText, _ = tr.GetString("example_confirmed")
    case "cancel":
        responseText, _ = tr.GetString("example_cancelled")
    }

    // Answer callback
    _, err := query.Answer(b, &gotgbot.AnswerCallbackQueryOpts{
        Text: responseText,
    })
    if err != nil {
        log.Error(err)
        return err
    }

    return ext.EndGroups
}

// LoadExample registers all handlers for this module
func LoadExample(dispatcher *ext.Dispatcher) {
    // Register in help system
    DefaultHelpRegistry().AbleMap[exampleModule.moduleName] = true

    // Register command handlers
    dispatcher.AddHandler(handlers.NewCommand("example", exampleModule.exampleCommand))

    // Register callback handlers
    dispatcher.AddHandler(handlers.NewCallback(
        callbackquery.Prefix("example"),
        exampleModule.exampleCallback,
    ))
}

// Register the module in init() so LoadAllModules picks it up
func init() {
    RegisterLegacyModule("Example", 100, LoadExample)
}

Step-by-Step Guide

Step 1: Create Database Model (If Needed)

Create a new file alita/db/example/repository.go (following the domain-package pattern):

package db

import (
    "gorm.io/gorm"
)

// ExampleSettings stores per-chat example settings
type ExampleSettings struct {
    ID        uint   `gorm:"primaryKey;autoIncrement"`
    ChatID    int64  `gorm:"uniqueIndex;not null"`
    Enabled   bool   `gorm:"default:false"`
    Value     string `gorm:"type:text"`
    CreatedAt int64  `gorm:"autoCreateTime"`
    UpdatedAt int64  `gorm:"autoUpdateTime"`
}

// GetExampleSettings retrieves settings for a chat
func GetExampleSettings(chatID int64) *ExampleSettings {
    var settings ExampleSettings
    tx := db.Session(&gorm.Session{}).Where("chat_id = ?", chatID).First(&settings)
    if tx.Error != nil {
        return &ExampleSettings{ChatID: chatID, Enabled: false}
    }
    return &settings
}

// SetExampleSettings saves settings for a chat
func SetExampleSettings(chatID int64, enabled bool, value string) error {
    settings := ExampleSettings{
        ChatID:  chatID,
        Enabled: enabled,
        Value:   value,
    }

    tx := db.Session(&gorm.Session{}).Where("chat_id = ?", chatID).
        Assign(settings).FirstOrCreate(&settings)

    if tx.Error != nil {
        return tx.Error
    }

    // Invalidate cache
    deleteCache(exampleSettingsCacheKey(chatID))
    return nil
}

:::caution[Never ignore DB errors] Always check err returns from database operations. Assigning to _ causes nil pointer panics when the result is used downstream. This is the single most common source of production crashes. :::

Step 2: Create Migration File

Create migrations/XXX_add_example_settings.sql:

-- Create example_settings table
CREATE TABLE IF NOT EXISTS example_settings (
    id SERIAL PRIMARY KEY,
    chat_id BIGINT NOT NULL UNIQUE,
    enabled BOOLEAN DEFAULT FALSE,
    value TEXT,
    created_at BIGINT,
    updated_at BIGINT
);

-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_example_settings_chat_id ON example_settings(chat_id);

:::note[Surrogate key pattern] Always use auto-increment id as the primary key. External IDs (chat_id, user_id) should be unique constraints, not primary keys. This decouples internal schema from Telegram’s ID space. :::

Step 3: Implement Database Operations

Add cache helpers to alita/db/cache/ttl.go and alita/db/cache/keys.go using the CacheKey helper:

const (
    CacheTTLExampleSettings = 30 * time.Minute
)

// Use the CacheKey helper for consistent key formatting
func exampleSettingsCacheKey(chatID int64) string {
    return CacheKey("example_settings", chatID)
}

Update the database operations to use caching with singleflight protection:

func GetExampleSettings(chatID int64) *ExampleSettings {
    result, err := getFromCacheOrLoad(
        exampleSettingsCacheKey(chatID),
        CacheTTLExampleSettings,
        func() (*ExampleSettings, error) {
            var settings ExampleSettings
            tx := db.Session(&gorm.Session{}).Where("chat_id = ?", chatID).First(&settings)
            if tx.Error != nil {
                return &ExampleSettings{ChatID: chatID, Enabled: false}, nil
            }
            return &settings, nil
        },
    )
    if err != nil {
        return &ExampleSettings{ChatID: chatID, Enabled: false}
    }
    return result
}

:::tip[CacheKey helper] The CacheKey() function in alita/db/cache/keys.go provides consistent key formatting as alita:{module}:{id}. Always use it instead of manual string formatting. :::

:::tip[Cache invalidation is mandatory] Every function that writes to the database MUST invalidate the corresponding cache key. Forgetting this causes stale data that persists until TTL expiry, which can be up to 1 hour. :::

Step 4: Add Translations

Add to locales/en.yml:

# Example module
example_help: |
  <b>Example Module</b>

  Commands:
  - /example: Run the example command
  - /exampleset <value>: Set the example value

example_success_message: "Example command executed successfully!"
example_value_set: "Example value set to: %s"
example_not_enabled: "Example feature is not enabled in this chat."
example_confirmed: "Action confirmed!"
example_cancelled: "Action cancelled."

Add to other locale files (es.yml, fr.yml, hi.yml, id.yml, pt.yml, ru.yml) with appropriate translations.

:::caution[Translation completeness] You must add keys to ALL locale files, not just en.yml. Missing keys cause runtime panics or empty strings. Run make check-translations to detect missing keys before committing. :::

Step 5: Register Module

Modules self-register via init() using the registry system. The LoadAllModules function in alita/main.go loads all registered modules in priority order:

func LoadModules(dispatcher *ext.Dispatcher) {
    modules.DefaultHelpRegistry().AbleMap = make(map[string]bool)
    defer modules.LoadHelp(dispatcher)

    // Loads all modules registered via RegisterLegacyModule
    modules.LoadAllModules(dispatcher)
}

There is one registration pattern:

  • RegisterLegacyModule(name, priority, loadFunc) in init() — wraps existing LoadXxx(dispatcher) functions. There is no newer RegisterModule interface; RegisterLegacyModule is the only registry entry point.

:::note[Load order matters] Priority determines load order (lower numbers load first). Place your module at a priority after any modules it depends on (e.g., after Users if you need user lookups). LoadHelp is deferred and always loads last. :::

Permission Check Functions

Use these functions to validate permissions before executing commands:

Function Description Returns
RequireGroup(b, ctx, chat) Ensures command is in group bool
RequirePrivate(b, ctx, chat) Ensures command is in PM bool
RequireUserAdmin(b, ctx, chat, userId) User must be admin bool
RequireBotAdmin(b, ctx, chat) Bot must be admin bool
RequireUserOwner(b, ctx, chat, userId) User must be creator bool
CanUserRestrict(b, ctx, chat, userId) User can ban/mute bool
CanBotRestrict(b, ctx, chat) Bot can ban/mute bool
CanUserDelete(b, ctx, chat, userId) User can delete messages bool
CanBotDelete(b, ctx, chat) Bot can delete messages bool
CanUserPin(b, ctx, chat, userId) User can pin messages bool
CanBotPin(b, ctx, chat) Bot can pin messages bool
CanUserPromote(b, ctx, chat, userId) User can promote/demote bool
CanBotPromote(b, ctx, chat) Bot can promote/demote bool
CanUserChangeInfo(b, ctx, chat, userId) User can change chat info bool
CanInvite(b, ctx, chat, msg) Can generate invite links bool

Permission Error Handling

Permission checking functions are pure and return only boolean values without sending any error messages to users. When error messaging is desired on failure, the caller must explicitly call PermissionResponder to send the response.

Example usage:

if !chat_status.RequireUserAdmin(b, ctx, nil, user.Id) {
	chat_status.NewPermissionResponder(b).Respond(ctx, "chat_status_user_admin_cmd_error", "chat_status_user_admin_button_error", chat_status.WithReplyFallback())
	return ext.EndGroups
}

Common Permission Patterns

// Admin-only command
if !chat_status.RequireGroup(b, ctx, nil) {
    return ext.EndGroups
}
if !chat_status.RequireUserAdmin(b, ctx, nil, user.Id) {
    return ext.EndGroups
}

// Command requiring bot to have restrict permissions
if !chat_status.RequireBotAdmin(b, ctx, nil) {
    return ext.EndGroups
}
if !chat_status.CanBotRestrict(b, ctx, nil) {
    return ext.EndGroups
}

// Owner-only command
if !chat_status.RequireUserOwner(b, ctx, nil, user.Id) {
    return ext.EndGroups
}

Handler Return Values

// Stop processing - no more handlers will run
return ext.EndGroups

// Continue to next handler in same group
return ext.ContinueGroups

// Error - propagates to dispatcher error handler
return err

// Success with no error
return nil

When to Use Each

Return Use When
ext.EndGroups Command handled successfully, stop processing
ext.ContinueGroups Allow other handlers to also process this update
err Something went wrong, let error handler deal with it
nil Same as ext.EndGroups for most purposes

:::tip[When to use ContinueGroups] Use ext.ContinueGroups only for monitoring/watcher handlers (handler groups 4-10) that observe traffic without consuming it. Standard command handlers should always return ext.EndGroups to prevent duplicate processing. :::

Translation Best Practices

Parameter Passing

Use positional formatters in YAML with named parameters in code:

# locales/en.yml
example_user_action: "User %s performed action: %s"
// In handler
text, _ := tr.GetString("example_user_action")
formattedText := fmt.Sprintf(text, userName, actionName)

Escape Sequences

Always use double quotes for strings with escape sequences:

# Correct - double quotes interpret \n
example_multiline: "Line 1\nLine 2\nLine 3"

# Wrong - single quotes preserve \n literally
example_multiline: 'Line 1\nLine 2\nLine 3'

:::caution[Printf type safety] %d requires an int, not a strconv.Itoa() output (which is a string). Passing the wrong type causes runtime panics. Always match format verbs to argument types. :::

Key Naming Convention

Follow the pattern: module_feature_description

bans_ban_normal_ban: "Banned %s!"
bans_ban_ban_reason: "\nReason: %s"
bans_kick_kicked_user: "Kicked %s!"
bans_unban_unbanned_user: "Unbanned %s!"

:::note[Parse mode mismatch] Locale strings often use Markdown formatting but the bot sends messages in HTML parse mode. Use tgmd2html.MD2HTMLV2() to convert before sending. Forgetting this conversion results in raw Markdown symbols appearing in user messages. :::

Complete Example: Welcome Toggle Module

Here’s a complete example showing all patterns together:

package modules

import (
    "fmt"

    "github.com/PaulSonOfLars/gotgbot/v2"
    "github.com/PaulSonOfLars/gotgbot/v2/ext"
    "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
    log "github.com/sirupsen/logrus"

    "github.com/divkix/Alita_Robot/alita/db"
    "github.com/divkix/Alita_Robot/alita/i18n"
    "github.com/divkix/Alita_Robot/alita/utils/chat_status"
    "github.com/divkix/Alita_Robot/alita/utils/formatting"
)

var welcomeModule = moduleStruct{moduleName: "Welcome"}

// welcomestatus shows whether welcome messages are enabled
func (m moduleStruct) welcomestatus(b *gotgbot.Bot, ctx *ext.Context) error {
    chat := ctx.EffectiveChat
    msg := ctx.EffectiveMessage
    tr := i18n.MustNewTranslator(db.GetLanguage(ctx))

    if !chat_status.RequireGroup(b, ctx, nil) {
        return ext.EndGroups
    }

    settings := db.GetGreetingSettings(chat.Id)
    enabled := false
    if settings.WelcomeSettings != nil {
        enabled = settings.WelcomeSettings.ShouldWelcome
    }

    var text string
    if enabled {
        text, _ = tr.GetString("welcome_status_enabled")
    } else {
        text, _ = tr.GetString("welcome_status_disabled")
    }
    _, err := msg.Reply(b, text, formatting.Shtml())
    if err != nil {
        log.Error(err)
        return err
    }

    return ext.EndGroups
}

// togglewelcome enables or disables welcome messages (admin only)
func (m moduleStruct) togglewelcome(b *gotgbot.Bot, ctx *ext.Context) error {
    chat := ctx.EffectiveChat
    user := ctx.EffectiveSender.User
    msg := ctx.EffectiveMessage
    tr := i18n.MustNewTranslator(db.GetLanguage(ctx))

    if !chat_status.RequireGroup(b, ctx, nil) {
        return ext.EndGroups
    }
    if !chat_status.RequireUserAdmin(b, ctx, nil, user.Id) {
        return ext.EndGroups
    }

    settings := db.GetGreetingSettings(chat.Id)
    current := false
    if settings.WelcomeSettings != nil {
        current = settings.WelcomeSettings.ShouldWelcome
    }

    if err := db.SetWelcomeToggle(chat.Id, !current); err != nil {
        log.Error(err)
        return err
    }

    var text string
    if !current {
        text, _ = tr.GetString("welcome_toggle_enabled")
    } else {
        text, _ = tr.GetString("welcome_toggle_disabled")
    }
    _, err := msg.Reply(b, text, formatting.Shtml())
    if err != nil {
        log.Error(err)
        return err
    }

    return ext.EndGroups
}

func LoadWelcome(dispatcher *ext.Dispatcher) {
    DefaultHelpRegistry().AbleMap[welcomeModule.moduleName] = true

    dispatcher.AddHandler(handlers.NewCommand("welcomestatus", welcomeModule.welcomestatus))
    dispatcher.AddHandler(handlers.NewCommand("togglewelcome", welcomeModule.togglewelcome))
}

func init() {
    RegisterLegacyModule("Welcome", 210, LoadWelcome)
}

Security Considerations

HTML Escaping

When displaying user-controlled data in HTML-formatted messages, always escape it:

import "github.com/divkix/Alita_Robot/alita/utils/formatting"

// Escape chat titles, usernames, and user-supplied text
text := fmt.Sprintf("Settings for %s", formatting.HtmlEscape(chat.Title))

The formatting.MentionHtml() function already handles escaping for user names.

:::caution[XSS via Telegram HTML] Telegram supports a subset of HTML in messages. User-controlled strings (chat titles, usernames, filter keywords) must be escaped with formatting.HtmlEscape() before insertion into HTML-formatted messages. Unescaped angle brackets can break message formatting or inject unintended HTML tags. :::

Database Operations and User Feedback

Prefer synchronous operations when sending success confirmations:

// CORRECT: Synchronous operation before success message
db.SetWelcomeText(chat.Id, db.DefaultWelcome, "", nil, db.TEXT)
_, err := msg.Reply(b, "Welcome message reset successfully!", formatting.Shtml())
// AVOID: Async operation with premature success message
go func() {
    db.SetWelcomeText(chat.Id, db.DefaultWelcome, "", nil, db.TEXT) // May fail silently
}()
_, err := msg.Reply(b, "Success!") // User sees success even if DB write fails

:::caution[Sync before confirm] Never send a success confirmation to the user before the database write completes. If the write fails, the user has already been told it succeeded. This is a trust-breaking bug that is hard to debug in production. :::

When async operations are necessary, only use them for non-critical background tasks that don’t require user confirmation.

Handling Functions That Return Errors

Always check errors from database operations that can fail:

// CORRECT: Check error and handle nil case
captchaSettings, err := db.GetCaptchaSettings(chat.Id)
if err != nil {
    log.Errorf("Failed to get captcha settings: %v", err)
    captchaSettings = &db.CaptchaSettings{Enabled: false} // Use safe default
}
if captchaSettings != nil && captchaSettings.Enabled {
    // Safe to access
}
// AVOID: Ignoring errors can cause nil pointer panics
captchaSettings, _ := db.GetCaptchaSettings(chat.Id)
if captchaSettings.Enabled { // May panic if captchaSettings is nil!
    // ...
}

Async Database Operations (When Appropriate)

When running DB operations in goroutines for non-critical background tasks, follow this pattern:

// 1. Capture loop/closure variables
chatId := chat.Id

go func() {
    // 2. Add panic recovery
    defer error_handling.RecoverFromPanic("FunctionName", "module")

    // 3. Handle errors explicitly
    if err := db.SomeOperation(chatId); err != nil {
        log.Errorf("[Module] Operation failed for chat %d: %v", chatId, err)
    }
}()

:::tip[Closure variable capture] Always capture loop/closure variables into local variables before passing them to goroutines. Go closures capture variables by reference, so the value may change by the time the goroutine executes. :::

User Extraction

Use extraction.ExtractUserAndText() for consistent user identification. It handles:

  • Reply messages
  • Text mentions
  • Username lookups (with Telegram API fallback)
  • Numeric user IDs

:::note[Nil sender check] ctx.EffectiveSender can be nil for channel messages. Always check before accessing .User. Use chat_status.RequireUser() or chat_status.GetEffectiveUser() for safe extraction. :::

Checklist for New Modules

  • Create module struct with moduleName
  • Implement handler methods on module struct
  • Add appropriate permission checks
  • Use i18n.MustNewTranslator(db.GetLanguage(ctx)) for translations
  • Handle errors properly (log and return)
  • Escape user-controlled input with formatting.HtmlEscape()
  • Add panic recovery to goroutines
  • Create database models if needed
  • Create migration file if needed
  • Add cache helpers if needed
  • Add translations to all locale files
  • Register module in init() with RegisterLegacyModule
  • Store module in help system with DefaultHelpRegistry().AbleMap[name] = true
  • Test in development environment

Next Steps

Was this page helpful?