Zakirullin Files Telegram Bot Architecture: A Deep Dive into the Go Implementation
The Zakirullin Files Telegram bot is a single-binary Go server that runs three concurrent subsystems—Telegram update polling, an HTTP sync server, and a background worker—routing each user’s updates through dedicated goroutines to a per-user filesystem store.
This open-source project ([zakirullin/files.md](https://github.com/zakirullin/files.md)) implements a unique approach to Telegram bot architecture, treating user data as a filesystem of markdown files rather than a traditional database. Understanding its architecture reveals how to build scalable, stateful bots with strong concurrency guarantees and seamless web integration.
Core Architecture Overview
The bot operates as a monolithic Go binary that simultaneously manages three long-running subsystems:
- Telegram Update Loop: Long-polls the Telegram Bot API and distributes updates to per-user channels ([
cmd/server/server.go](https://github.com/zakirullin/files.md/blob/main/cmd/server/server.go#L48-L66)) - HTTP Sync Server: Serves the Progressive Web App (PWA) and handles filename synchronization, file uploads, and web UI notifications (
server/sync) - Worker Ticker: Executes every 5 seconds to process scheduled tasks and prune completed checklist items ([
cmd/server/server.go](https://github.com/zakirullin/files.md/blob/main/cmd/server/server.go#L59-L74))
All subsystems share a unified storage layer. The UserFS (storage/<userID>/*.md) serves as the single source of truth for both Telegram interactions and web-based edits.
Data Storage and State Management
The architecture separates data into three distinct persistence layers:
UserFS: Filesystem as Database
User-generated content lives in a per-user filesystem tree managed by the UserFS abstraction. Each user receives an isolated directory (storage/<userID>/) containing markdown files. This design enables git-like versioning and allows the HTTP server to read and write notes using standard file operations ([server/fs/fs.go](https://github.com/zakirullin/files.md/blob/main/server/fs/fs.go)).
In-Memory Transient State
Runtime state—including last keyboard message IDs, recent commands, and temporary buffers—resides in an in-memory DB package (server/db). This layer provides fast access to ephemeral data that does not require persistence across restarts.
Static Configuration
System-wide preferences and bot settings are loaded from config.json via the server/config package, while per-user preferences (timezones, quick-buttons) are managed by [server/userconfig/userconfig.go](https://github.com/zakirullin/files.md/blob/main/server/userconfig/userconfig.go).
The Bot Core and Request Flow
At the heart of the system lies the Bot type defined in [server/bot.go](https://github.com/zakirullin/files.md/blob/main/server/bot.go). This struct implements the main Reply method, which processes incoming Update objects through a deterministic decision tree:
- Inline Query Handling: Responds to inline mode requests immediately
- Plugin Execution: Routes text through optional plugins (e.g., world-clock) that can answer without UI interaction
- Command Dispatch: Extracts commands and dispatches to a handler map (e.g.,
/help,/start) - Content Persistence: Saves plain text or image messages to the user's markdown files
The Update abstraction in [server/pkg/tg/upd.go](https://github.com/zakirullin/files.md/blob/main/server/pkg/tg/upd.go) normalizes raw tgbotapi.Update structs into a consistent interface used throughout the bot logic.
Concurrency Model and Goroutine Architecture
The bot achieves per-user sequential consistency through a channel-based worker pattern:
Per-User Supervisor Pattern
When the main update loop receives a message, it resolves the userID and checks for an existing channel. If absent, it spawns a new supervisor goroutine with panic recovery:
ch, ok := userChannels[userID]
if !ok {
ch = make(chan tgbotapi.Update, 100)
userChannels[userID] = ch
go supervisor(userID, ch, telegram) // panic-recovering worker
}
ch <- upd
This pattern ensures that a single user’s updates are processed sequentially, eliminating race conditions on shared markdown files. Different users process updates in parallel, each with isolated UserFS and in-memory DB instances.
Thread-Safe File Access
Because the bot never writes concurrently for the same user, the HTTP sync server can safely read and write the same markdown files without locking mechanisms. This architecture simplifies the concurrency model while maintaining data integrity.
Key Components and Source Files
Implementation Examples
Initializing the Update Loop and Supervisors
The main function in [cmd/server/server.go](https://github.com/zakirullin/files.md/blob/main/cmd/server/server.go#L48-L66) demonstrates the startup sequence and long-polling mechanism:
func main() {
_ = godotenv.Load()
_ = config.LoadBotConfig()
api, err := tgbotapi.NewBotAPI(config.ServerCfg.BotAPIToken)
if err != nil {
fmt.Printf("No Telegram bot token: %s\n", err)
select {} // web-only mode
}
telegram := tg.NewTG(api)
tgConfig := tgbotapi.NewUpdate(0)
tgConfig.Timeout = 60
updates := api.GetUpdatesChan(tgConfig)
userChannels := map[int64]chan tgbotapi.Update{}
for upd := range updates {
tgUpd := tg.NewTGUpd(upd)
userID, _ := resolveUserID(tgUpd, telegram)
ch, ok := userChannels[userID]
if !ok {
ch = make(chan tgbotapi.Update, 100)
userChannels[userID] = ch
go supervisor(userID, ch, telegram)
}
ch <- upd
}
}
Sending Messages with Inline Keyboards
The [server/pkg/tg/tg.go](https://github.com/zakirullin/files.md/blob/main/server/pkg/tg/tg.go#L34-L46) wrapper simplifies message composition:
func (tg *TG) SendHTML(userID int64, text string, kb *Keyboard) (int, error) {
return tg.Send(userID, text, kb, MarkupHTML)
}
// Usage in a command handler
func (b *Bot) showHelp(_ []string) error {
kb := tg.NewInlineKeyboard(
tg.Button{Text: "Docs", URL: "https://github.com/zakirullin/files.md"},
tg.Button{Text: "Close", CallbackData: CmdDoNothing},
)
_, err := b.tg.SendHTML(b.userID, "🤖 Welcome to Files Bot!", kb)
return err
}
Registering a New Command Handler
Adding commands requires three steps in [server/bot.go](https://github.com/zakirullin/files.md/blob/main/server/bot.go#L24-L33):
// 1. Define constant
const CmdPing = "ping"
// 2. Register in handler map
handlers[CmdPing] = b.handlePing
// 3. Implement handler
func (b *Bot) handlePing(_ []string) error {
_, err := b.tg.Send(b.userID, "🏓 Pong!", nil, tg.MarkupHTML)
return err
}
Summary
- The Zakirullin Files bot uses a single-binary architecture combining Telegram polling, HTTP serving, and background workers in one Go process.
- Per-user goroutines with supervisor patterns ensure sequential update processing while enabling parallel user handling.
- UserFS treats markdown files as the primary data store, creating a natural bridge between the Telegram bot and the web-based PWA.
- The Bot.Reply method serves as the central router, handling inline queries, plugins, commands, and content persistence through a deterministic decision tree.
- All file operations flow through the
server/fspackage, maintaining consistency between chat interactions and sync API operations.
Frequently Asked Questions
How does the bot handle concurrent updates from multiple users?
Each user receives a dedicated goroutine with a buffered channel. When an update arrives, the main loop routes it to the user's specific channel, ensuring sequential processing per user while allowing parallel execution across different users. This eliminates file corruption risks without requiring complex locking mechanisms.
What storage backend does the bot use for notes and messages?
The bot uses the local filesystem as its primary storage backend. User data resides in storage/<userID>/*.md markdown files accessed through the UserFS abstraction. Transient runtime state stays in an in-memory map, and user preferences are stored in JSON configuration files.
Can the bot function without a Telegram token?
Yes. If the Telegram Bot API token is missing or invalid, the binary enters "web-only mode" by blocking indefinitely with select {}, allowing the HTTP sync server and PWA to operate independently of the Telegram integration.
How are commands processed within the bot architecture?
Commands flow through the Bot.Reply method in server/bot.go, which extracts the command string and dispatches to a predefined handler map. Developers register new commands by defining a constant, adding an entry to the handler map, and implementing a method with the signature func ([]string) error.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →