Files
go-telegram-bot/bot.go
T
HugeFrog24 401b263991 Upgrade dependencies and toolchain; migrate to Claude API 2026-10-21
Toolchain and CI:
- Go 1.27 (go directive 1.27.0, CI 1.27.3)
- actions/checkout v7, actions/setup-go v7
- golangci-lint v2.15.0, gosec v2.30.0
- Dockerfile: drop obsolete -a -installsuffix cgo build flags

Go modules:
- anthropic-sdk-go v1.71.0 -> v2.3.0 (new /v2 module path, API version
  2026-10-21); go-telegram/bot v1.27.0; x/sync v0.24.0; indirects to latest
- BetaMessageParam is now a per-role union: build assistant turns with
  OfAssistant and read content through the role in the cache helper
- Pass the Anthropic API key only when set: v2 sends an explicitly passed
  credential as is, so an empty key became an empty bearer token instead of
  falling back to the SDK's default credential chain as v1 did

API 2026-10-21 / deprecations:
- Stop sending temperature (rejected on every model); the config key is
  still parsed and a startup notice flags it
- Name refusals in the empty-response error instead of reporting an
  unexpected response format
- ElevenLabs STT: scribe_v1 (deprecated) -> scribe_v2
- Template config: claude-haiku-5-5 with effort low, temperature removed

Docs: replace the unsupported `go get -u` install step, document the ignored
temperature key, default-on thinking, and per-model effort defaults.
2026-10-25 13:34:39 +01:00

843 lines
24 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/anthropics/anthropic-sdk-go/v2"
"github.com/anthropics/anthropic-sdk-go/v2/option"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"gorm.io/gorm"
)
type Bot struct {
tgBot TelegramClient
db *gorm.DB
anthropicClient anthropic.Client
chatMemories map[int64]*ChatMemory
memorySize int
chatMemoriesMu sync.RWMutex
config BotConfig
userLimiters map[int64]*userLimiter
userLimitersMu sync.RWMutex
clock Clock
botID uint
albumBuffers map[string]*pendingAlbum
albumBuffersMu sync.Mutex
intakeBuffers map[int64]*pendingIntake
intakeBuffersMu sync.Mutex
intakeSeq uint64
turns map[int64]*chatTurn
turnsMu sync.Mutex
turnSeq uint64
}
func messageType(msg *models.Message) string {
if msg.Sticker != nil {
return "sticker"
}
return "text"
}
func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient) (*Bot, error) {
var botEntry BotModel
err := db.Where("identifier = ?", config.ID).First(&botEntry).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
botEntry = BotModel{Identifier: config.ID, Name: config.ID}
if err := db.Create(&botEntry).Error; err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
var owner User
err = db.Where("telegram_id = ? AND bot_id = ?", config.OwnerTelegramID, botEntry.ID).First(&owner).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
var ownerRole Role
err := db.Where("name = ?", "owner").First(&ownerRole).Error
if err != nil {
return nil, fmt.Errorf("owner role not found: %w", err)
}
owner = User{
BotID: botEntry.ID,
TelegramID: config.OwnerTelegramID,
Username: "",
RoleID: ownerRole.ID,
IsOwner: true,
}
if err := db.Create(&owner).Error; err != nil {
if strings.Contains(err.Error(), "unique index") {
return nil, fmt.Errorf("an owner already exists for this bot")
}
return nil, fmt.Errorf("failed to create owner user: %w", err)
}
} else if err != nil {
return nil, err
}
// An explicitly passed credential is used as is, so an empty key would go
// out as an empty bearer token; leave it out to use the SDK's default chain.
var anthropicOpts []option.RequestOption
if config.AnthropicAPIKey != "" {
anthropicOpts = append(anthropicOpts, option.WithAPIKey(config.AnthropicAPIKey))
}
anthropicClient := anthropic.NewClient(anthropicOpts...)
b := &Bot{
db: db,
anthropicClient: anthropicClient,
chatMemories: make(map[int64]*ChatMemory),
memorySize: config.MemorySize,
config: config,
userLimiters: make(map[int64]*userLimiter),
clock: clock,
botID: botEntry.ID,
tgBot: tgClient,
albumBuffers: make(map[string]*pendingAlbum),
intakeBuffers: make(map[int64]*pendingIntake),
turns: make(map[int64]*chatTurn),
}
if tgClient == nil {
var err error
tgClient, err = initTelegramBot(config.TelegramToken, b)
if err != nil {
return nil, fmt.Errorf("failed to initialize Telegram bot: %w", err)
}
b.tgBot = tgClient
}
return b, nil
}
func (b *Bot) Start(ctx context.Context) {
b.tgBot.Start(ctx)
}
func (b *Bot) getOrCreateUser(userID int64, username string, isOwner bool) (User, error) {
var user User
err := b.db.Preload("Role").Where("telegram_id = ? AND bot_id = ?", userID, b.botID).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if isOwner {
var existingOwner User
err := b.db.Where("bot_id = ? AND is_owner = ?", b.botID, true).First(&existingOwner).Error
if err == nil {
return User{}, fmt.Errorf("an owner already exists for this bot")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return User{}, fmt.Errorf("failed to check existing owner: %w", err)
}
}
var role Role
var roleName string
if isOwner {
roleName = "owner"
} else {
roleName = "user"
}
err := b.db.Where("name = ?", roleName).First(&role).Error
if err != nil {
return User{}, fmt.Errorf("failed to get role: %w", err)
}
user = User{
BotID: b.botID,
TelegramID: userID,
Username: username,
RoleID: role.ID,
Role: role,
IsOwner: isOwner,
}
if err := b.db.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "unique index") {
return User{}, fmt.Errorf("an owner already exists for this bot")
}
return User{}, fmt.Errorf("failed to create user: %w", err)
}
} else {
return User{}, err
}
} else {
if isOwner && !user.IsOwner {
return User{}, fmt.Errorf("cannot change existing user to owner")
}
}
return user, nil
}
func (b *Bot) getRoleByName(roleName string) (Role, error) {
var role Role
err := b.db.Where("name = ?", roleName).First(&role).Error
return role, err
}
func (b *Bot) createMessage(chatID, userID int64, username, userRole, text string, isUser bool) Message {
message := Message{
ChatID: chatID,
UserRole: userRole,
Text: text,
Timestamp: time.Now(),
IsUser: isUser,
}
if isUser {
message.UserID = userID
message.Username = username
} else {
message.UserID = 0
message.Username = "AI Assistant"
}
return message
}
func (b *Bot) storeMessage(message *Message) error {
message.BotID = b.botID
return b.db.Create(message).Error
}
func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
b.chatMemoriesMu.RLock()
chatMemory, exists := b.chatMemories[chatID]
b.chatMemoriesMu.RUnlock()
if !exists {
b.chatMemoriesMu.Lock()
defer b.chatMemoriesMu.Unlock()
chatMemory, exists = b.chatMemories[chatID]
if !exists {
var count int64
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
isNewChat := count == 0
var messages []Message
if !isNewChat {
err := b.db.Where("chat_id = ? AND bot_id = ?", chatID, b.botID).
Order("timestamp desc").
Limit(b.memorySize * 2).
Find(&messages).Error
if err != nil {
ErrorLogger.Printf("Error fetching messages from database: %v", err)
messages = []Message{}
} else {
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
}
} else {
messages = []Message{}
}
chatMemory = &ChatMemory{
Messages: messages,
Size: b.memorySize * 2,
}
b.chatMemories[chatID] = chatMemory
}
}
return chatMemory
}
func (b *Bot) stripDeadFileIDFromMemory(chatID int64, deadFileID string) {
b.chatMemoriesMu.Lock()
defer b.chatMemoriesMu.Unlock()
cm, exists := b.chatMemories[chatID]
if !exists {
return
}
for i := range cm.Messages {
if len(cm.Messages[i].ImageFileIDs) == 0 {
continue
}
survivors := make([]string, 0, len(cm.Messages[i].ImageFileIDs))
for _, fid := range cm.Messages[i].ImageFileIDs {
if fid != deadFileID {
survivors = append(survivors, fid)
}
}
cm.Messages[i].ImageFileIDs = survivors
}
}
func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
b.chatMemoriesMu.Lock()
defer b.chatMemoriesMu.Unlock()
chatMemory.Messages = append(chatMemory.Messages, message)
if len(chatMemory.Messages) > chatMemory.Size {
chatMemory.Messages = chatMemory.Messages[len(chatMemory.Messages)-chatMemory.Size:]
}
}
// prepareContextMessages converts chat memory into API messages.
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock()
return b.buildContextMessages(chatMemory)
}
// snapshotForTurn builds the model context for turn and records on it the
// newest user message that context includes, both read under one lock so they
// cannot disagree. The recorded id is what lets a later flush tell whether the
// running turn already answers it.
func (b *Bot) snapshotForTurn(turn *chatTurn, chatMemory *ChatMemory) []anthropic.BetaMessageParam {
b.chatMemoriesMu.RLock()
contextMessages := b.buildContextMessages(chatMemory)
covered := newestUserMessageID(chatMemory)
b.chatMemoriesMu.RUnlock()
b.markCovered(turn, covered)
return contextMessages
}
// newestUserMessageID returns the highest database id among the user messages
// in memory. Ids are monotonic, so "newer" and "higher" agree.
func newestUserMessageID(chatMemory *ChatMemory) uint {
var newest uint
for _, msg := range chatMemory.Messages {
if msg.IsUser && msg.ID > newest {
newest = msg.ID
}
}
return newest
}
func (b *Bot) newestUserMessageIDInChat(chatID int64) uint {
chatMemory := b.getOrCreateChatMemory(chatID)
b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock()
return newestUserMessageID(chatMemory)
}
// buildContextMessages is prepareContextMessages without the lock.
func (b *Bot) buildContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages))
if b.config.DebugScreening {
for i, msg := range chatMemory.Messages {
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs))
}
}
var contextMessages []anthropic.BetaMessageParam
for _, msg := range chatMemory.Messages {
blocks := contentBlocksForMessage(msg)
if len(blocks) == 0 {
continue
}
var param anthropic.BetaMessageParam
if msg.IsUser {
param = anthropic.NewBetaUserMessage(blocks...)
} else {
param = anthropic.BetaMessageParam{OfAssistant: &anthropic.BetaAssistantMessageParam{
Content: blocks,
}}
}
contextMessages = append(contextMessages, param)
}
if b.config.CacheHistoryEnabled() {
markTrailingCacheBreakpoint(contextMessages)
}
return contextMessages
}
// markTrailingCacheBreakpoint puts a cache_control breakpoint on the final
// content block of the conversation, so the next turn reads the whole prefix
// from cache instead of reprocessing it. The system prompt keeps its own
// breakpoint; tools and system render ahead of messages, so the two compose.
//
// Caveat worth knowing when reading [usage] lines: chat memory is a sliding
// window. Once it is full, each new turn evicts the oldest message, which
// changes the prefix and forces a miss. Until then, and for any chat shorter
// than the window, this converts a full-price reread into a cache read.
func markTrailingCacheBreakpoint(messages []anthropic.BetaMessageParam) {
if len(messages) == 0 {
return
}
var blocks []anthropic.BetaContentBlockParamUnion
switch last := messages[len(messages)-1]; {
case last.OfUser != nil:
blocks = last.OfUser.Content
case last.OfAssistant != nil:
blocks = last.OfAssistant.Content
}
if len(blocks) == 0 {
return
}
switch last := &blocks[len(blocks)-1]; {
case last.OfText != nil:
last.OfText.CacheControl = anthropic.NewBetaCacheControlEphemeralParam()
case last.OfImage != nil:
last.OfImage.CacheControl = anthropic.NewBetaCacheControlEphemeralParam()
}
}
func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion {
var blocks []anthropic.BetaContentBlockParamUnion
if msg.IsUser && len(msg.ImageFileIDs) > 0 {
multi := len(msg.ImageFileIDs) > 1
for i, fileID := range msg.ImageFileIDs {
if multi {
blocks = append(blocks, anthropic.NewBetaTextBlock(fmt.Sprintf("Image %d:", i+1)))
}
blocks = append(blocks, anthropic.NewBetaImageBlock(anthropic.BetaFileImageSourceParam{FileID: fileID}))
}
}
if textContent := strings.TrimSpace(msg.Text); textContent != "" {
blocks = append(blocks, anthropic.NewBetaTextBlock(textContent))
}
return blocks
}
func roleHasScope(role Role, scope string) bool {
for _, s := range role.Scopes {
if s.Name == scope {
return true
}
}
return false
}
func (b *Bot) hasScope(userID int64, scope string) bool {
var user User
if err := b.db.Preload("Role.Scopes").
Where("telegram_id = ? AND bot_id = ?", userID, b.botID).
First(&user).Error; err != nil {
return false
}
if user.IsOwner {
return true
}
return roleHasScope(user.Role, scope)
}
var publicBotCommands = []models.BotCommand{
{Command: "stats", Description: "Get bot statistics. Usage: /stats or /stats user [user_id]"},
{Command: "whoami", Description: "Get your user information"},
{Command: "clear", Description: "Clear chat history (soft delete). Admins: /clear [user_id]"},
}
var adminBotCommands = []models.BotCommand{
{Command: "clear_hard", Description: "Clear chat history (permanently delete). Admins: /clear_hard [user_id]"},
{Command: "set_model", Description: "Switch the AI model (admin/owner only). Usage: /set_model <model-id>"},
}
func (b *Bot) registerAdminCommandsForUser(ctx context.Context, telegramID int64) {
allCommands := make([]models.BotCommand, 0, len(publicBotCommands)+len(adminBotCommands))
allCommands = append(allCommands, publicBotCommands...)
allCommands = append(allCommands, adminBotCommands...)
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: telegramID},
})
if err != nil {
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
}
}
func setElevatedCommands(tgBot TelegramClient, users []User) {
allCommands := make([]models.BotCommand, 0, len(publicBotCommands)+len(adminBotCommands))
allCommands = append(allCommands, publicBotCommands...)
allCommands = append(allCommands, adminBotCommands...)
for _, u := range users {
if u.TelegramID == 0 {
continue
}
if !u.IsOwner && !roleHasScope(u.Role, ScopeModelSet) {
continue
}
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: u.TelegramID},
})
if err != nil {
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
}
}
}
func initTelegramBot(token string, b *Bot) (TelegramClient, error) {
opts := []bot.Option{
bot.WithDefaultHandler(b.handleUpdate),
}
tgBot, err := bot.New(token, opts...)
if err != nil {
return nil, err
}
_, err = tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
Commands: publicBotCommands,
Scope: &models.BotCommandScopeDefault{},
})
if err != nil {
ErrorLogger.Printf("Error setting default bot commands: %v", err)
return nil, err
}
var allUsers []User
if err := b.db.Preload("Role.Scopes").Where("bot_id = ?", b.botID).Find(&allUsers).Error; err != nil {
ErrorLogger.Printf("Warning: could not query users for command scoping: %v", err)
} else {
setElevatedCommands(tgBot, allUsers)
}
return tgBot, nil
}
func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, businessConnectionID string) error {
_, err := b.screenOutgoingMessage(chatID, text)
if err != nil {
ErrorLogger.Printf("Error storing assistant message: %v", err)
return err
}
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
_, err = b.tgBot.SendMessage(ctx, params)
if err != nil {
ErrorLogger.Printf("[%s] Error sending message to chat %d with BusinessConnectionID %s: %v",
b.config.ID, chatID, businessConnectionID, err)
return err
}
return nil
}
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
b.config.ID, chatID, businessConnectionID, err)
return err
}
return nil
}
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
if targetUserID == 0 {
totalUsers, totalMessages, err := b.getStats()
if err != nil {
ErrorLogger.Printf("Error fetching stats: %v\n", err)
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't retrieve the stats at this time.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
statsMessage := fmt.Sprintf(
"📊 Bot Statistics:\n\n"+
"- Total Users: %d\n"+
"- Total Messages: %d",
totalUsers,
totalMessages,
)
if b.hasScope(userID, ScopeStatsViewAny) {
type topEntry struct {
UserID int64
MsgCount int64
}
var top []topEntry
if err := b.db.Model(&Message{}).
Select("user_id, COUNT(*) as msg_count").
Where("bot_id = ? AND is_user = ? AND deleted_at IS NULL", b.botID, true).
Group("user_id").
Order("msg_count DESC").
Limit(3).
Scan(&top).Error; err != nil {
ErrorLogger.Printf("Error fetching top users: %v", err)
} else if len(top) > 0 {
statsMessage += "\n\n🏆 Most Active Users:"
for i, entry := range top {
var u User
if err := b.db.Select("username").Where("telegram_id = ? AND bot_id = ?", entry.UserID, b.botID).First(&u).Error; err != nil {
u.Username = fmt.Sprintf("ID:%d", entry.UserID)
}
name := u.Username
if name == "" {
name = fmt.Sprintf("ID:%d", entry.UserID)
}
statsMessage += fmt.Sprintf("\n%d. @%s — %d messages", i+1, name, entry.MsgCount)
}
}
}
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending stats message: %v", err)
}
return
}
if targetUserID != userID {
if !b.hasScope(userID, ScopeStatsViewAny) {
InfoLogger.Printf("User %d attempted to view stats for user %d without permission", userID, targetUserID)
if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can view other users' statistics.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
}
username, messagesIn, messagesOut, totalMessages, err := b.getUserStats(targetUserID)
if err != nil {
ErrorLogger.Printf("Error fetching user stats: %v\n", err)
if err := b.sendResponse(ctx, chatID, fmt.Sprintf("Sorry, I couldn't retrieve statistics for user ID %d.", targetUserID), businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
userInfo := fmt.Sprintf("@%s (ID: %d)", username, targetUserID)
if username == "" {
userInfo = fmt.Sprintf("User ID: %d", targetUserID)
}
statsMessage := fmt.Sprintf(
"👤 User Statistics for %s:\n\n"+
"- Messages Sent: %d\n"+
"- Messages Received: %d\n"+
"- Total Messages: %d",
userInfo,
messagesIn,
messagesOut,
totalMessages,
)
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending user stats message: %v", err)
}
}
func (b *Bot) getStats() (int64, int64, error) {
var totalUsers int64
if err := b.db.Model(&User{}).Where("bot_id = ?", b.botID).Count(&totalUsers).Error; err != nil {
return 0, 0, err
}
var totalMessages int64
if err := b.db.Model(&Message{}).Where("bot_id = ?", b.botID).Count(&totalMessages).Error; err != nil {
return 0, 0, err
}
return totalUsers, totalMessages, nil
}
func (b *Bot) getUserStats(userID int64) (string, int64, int64, int64, error) {
var user User
err := b.db.Where("telegram_id = ? AND bot_id = ?", userID, b.botID).First(&user).Error
if err != nil {
return "", 0, 0, 0, fmt.Errorf("user not found: %w", err)
}
var messagesIn int64
if err := b.db.Model(&Message{}).Where("user_id = ? AND bot_id = ? AND is_user = ?",
userID, b.botID, true).Count(&messagesIn).Error; err != nil {
return "", 0, 0, 0, err
}
var messagesOut int64
if err := b.db.Model(&Message{}).Where("chat_id IN (SELECT DISTINCT chat_id FROM messages WHERE user_id = ? AND bot_id = ? AND deleted_at IS NULL) AND bot_id = ? AND is_user = ?",
userID, b.botID, b.botID, false).Count(&messagesOut).Error; err != nil {
return "", 0, 0, 0, err
}
totalMessages := messagesIn + messagesOut
return user.Username, messagesIn, messagesOut, totalMessages, nil
}
func isOnlyEmojis(s string) bool {
for _, r := range s {
if !isEmoji(r) {
return false
}
}
return true
}
func isEmoji(r rune) bool {
return (r >= 0x1F600 && r <= 0x1F64F) ||
(r >= 0x1F300 && r <= 0x1F5FF) ||
(r >= 0x1F680 && r <= 0x1F6FF) ||
(r >= 0x2600 && r <= 0x26FF) ||
(r >= 0x2700 && r <= 0x27BF)
}
func (b *Bot) sendWhoAmI(ctx context.Context, chatID int64, userID int64, username string, businessConnectionID string) {
user, err := b.getOrCreateUser(userID, username, false)
if err != nil {
ErrorLogger.Printf("Error getting or creating user: %v", err)
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't retrieve your information.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
role, err := b.getRoleByName(user.Role.Name)
if err != nil {
ErrorLogger.Printf("Error getting role by name: %v", err)
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't retrieve your role information.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
whoAmIMessage := fmt.Sprintf(
"👤 Your Information:\n\n"+
"- Username: %s\n"+
"- Role: %s",
user.Username,
role.Name,
)
if err := b.sendResponse(ctx, chatID, whoAmIMessage, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending /whoami message: %v", err)
}
}
func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
if b.config.DebugScreening {
start := time.Now()
defer func() {
InfoLogger.Printf(
"[Screen] Incoming: chat=%d user=%d type=%s memory_size=%d duration=%v",
message.Chat.ID,
message.From.ID,
messageType(message),
len(b.getOrCreateChatMemory(message.Chat.ID).Messages),
time.Since(start),
)
}()
}
userRole := "user"
messageText := message.Text
if message.Sticker != nil {
if message.Sticker.Emoji != "" {
messageText = fmt.Sprintf("Sent a sticker: %s", message.Sticker.Emoji)
} else {
messageText = "Sent a sticker."
}
}
if message.Voice != nil {
messageText = "[Voice message]"
}
userMessage := b.createMessage(message.Chat.ID, message.From.ID, message.From.Username, userRole, messageText, true)
userMessage.TelegramMessageID = message.ID
if message.Sticker != nil {
userMessage.StickerFileID = message.Sticker.FileID
userMessage.StickerEmoji = message.Sticker.Emoji
if message.Sticker.Thumbnail != nil {
userMessage.StickerPNGFile = message.Sticker.Thumbnail.FileID
}
}
chatMemory := b.getOrCreateChatMemory(message.Chat.ID)
if err := b.storeMessage(&userMessage); err != nil {
return Message{}, err
}
b.addMessageToChatMemory(chatMemory, userMessage)
return userMessage, nil
}
func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, error) {
if b.config.DebugScreening {
start := time.Now()
defer func() {
InfoLogger.Printf(
"[Screen] Outgoing: chat=%d len=%d memory_size=%d duration=%v",
chatID,
len(response),
len(b.getOrCreateChatMemory(chatID).Messages),
time.Since(start),
)
}()
}
assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false)
if err := b.storeMessage(&assistantMessage); err != nil {
return Message{}, err
}
// Mark every outstanding user message in the chat, not just the newest one.
// A coalesced turn answers the whole batch, so a single-row update would
// leave the earlier messages permanently unanswered. This also drops an
// UPDATE ... ORDER BY ... LIMIT, which stock SQLite builds do not support.
now := time.Now()
err := b.db.Model(&Message{}).
Where("chat_id = ? AND bot_id = ? AND is_user = ? AND answered_on IS NULL",
chatID, b.botID, true).
Update("answered_on", now).Error
if err != nil {
ErrorLogger.Printf("Error marking user messages as answered: %v", err)
}
chatMemory := b.getOrCreateChatMemory(chatID)
b.addMessageToChatMemory(chatMemory, assistantMessage)
return assistantMessage, nil
}
func (b *Bot) promoteUserToAdmin(promoterID, userToPromoteID int64) error {
if !b.hasScope(promoterID, ScopeUserPromote) {
return errors.New("only admins or owners can promote users to admin")
}
userToPromote, err := b.getOrCreateUser(userToPromoteID, "", false)
if err != nil {
return err
}
var adminRole Role
if err := b.db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
return err
}
userToPromote.RoleID = adminRole.ID
userToPromote.Role = adminRole
if err := b.db.Save(&userToPromote).Error; err != nil {
return err
}
b.registerAdminCommandsForUser(context.Background(), userToPromoteID)
return nil
}