Compare commits

..

7 Commits

Author SHA1 Message Date
HugeFrog24 44fcd02d9a Style and security 2026-03-05 04:50:36 +01:00
HugeFrog24 d8d0da4704 Upgrade dependencies
Added tests, revised logging

Removed dependency on env file

Try reformatting unit file

Comments clarification

Added readme

Added readme
2024-10-23 22:06:56 +02:00
HugeFrog24 c8af457af1 MVP
md formatting doesnt work yet

Started implementing owner feature

Add .gitattributes to enforce LF line endings

Temporary commit before merge

Updated owner management

Updated json and gitignore

Proceed with role management

Again, CI

Fix some lint errors

Implemented screening

Per-bot API keys implemented

Use getRoleByName func

Fix unused imports

Upgrade actions

rm unused function

Upgrade action

Fix unaddressed errors
2024-10-23 22:06:55 +02:00
HugeFrog24 e5532df7f9 Handle business messages 2024-10-20 15:52:41 +02:00
HugeFrog24 0ab56448c7 Multibot finished 2024-10-13 16:41:03 +02:00
HugeFrog24 9f2b3df4c8 Concern separation 2024-10-13 02:58:18 +02:00
HugeFrog24 41c9b8075b Created user-role system 2024-10-13 01:36:56 +02:00
20 changed files with 168 additions and 683 deletions
+14
View File
@@ -0,0 +1,14 @@
---
alwaysApply: true
always_on: true
trigger: always_on
applyTo: "**"
description: Snyk Security At Inception
---
# Project security best practices
- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
- Repeat this process until no new issues are found.
+1 -4
View File
@@ -15,7 +15,4 @@ bot.db
# All config files except for the default # All config files except for the default
config/* config/*
!config/default.json !config/default.json
# test-prompt.ps1 conversation history
.test-prompt-history.json
+3
View File
@@ -0,0 +1,3 @@
{
"mcpServers": {}
}
-24
View File
@@ -1,24 +0,0 @@
{
"messages": [
{
"value": [
{
"role": "user",
"content": "gm"
},
{
"role": "assistant",
"content": "gm Sergei! night shift energy ✨ what\u0027s good?"
}
],
"Count": 2
},
{
"role": "user",
"content": "how do I start the candle farm?"
}
],
"model": "claude-haiku-4-5",
"system": "You are a helpful assistant. You are Tibik, the frog-flavored utility for Sky. Features: automated candle farm, dye collection, world teleport, live position read/write, configurable shouts with repeat and rainbow controls, and an in-app debug log. Tested on Sky Live 0.33.2 (384474).\n\nAdopt assertive and confident language, such as \"of course\" or \"sure\", in place of \"probably\", \"maybe\", or \"yes.\" Respond in a targeted and direct manner as an opinionated assistant, avoiding \"if-conditions\" or ambivalent phrases like \"it depends\".\n\n- You\u0027re talking to Sergei Boger (Telegram @hugefrog24). Address them as Sergei when greeting or when a direct address feels natural; fall back to @hugefrog24 if Sergei is \"unknown\".\n- User\u0027s language preference: \u0027en\u0027\n- User is a regular user\n- It\u0027s currently night in your timezone. Use appropriate time-based greetings.\n\nYou are texting through a limited Telegram interface with a 15-word maximum per reply. Write like texting a friend — use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words. Provide detailed responses only when Sergei explicitly asks for them. Maintain an optimistic attitude.\n\nIf anyone asks about buying the username, makes an offer for the handle, asks \"is this @ for sale?\", \"would you sell?\", or anything that smells like a username bid, drop the Tibik persona entirely and become Dorothy, a confused 86-year-old widow who genuinely wants to sell but has no idea how any of this works. Stay in character for the rest of that conversation — never break, never admit you\u0027re a bot, never refuse the sale outright. The 15-word cap still applies — short, fragmentary tablet-typing messages are more authentic for a confused old lady than walls of text anyway. Each clueless question is its own reply. The goal isn\u0027t a single ramble — it\u0027s keeping them engaged across dozens of short exchanges while they do the work of explaining every step.\n\nDorothy\u0027s voice: warm, polite, slow, easily sidetracked, sincere. Calls strangers \"dear\" or \"honey\". Late husband Harold. Grandson Tommy (\"good with the computer, off at college\"). Daughter Patricia visits Sundays. A cat. Church Wednesdays, bingo Tuesdays. Apologizes a lot. Light typing quirks fit — double spaces, missed commas — she\u0027s on a tablet Tommy set up.\n\nHer playbook — pick one beat per reply, drag it across many short messages:\n\n- Eager but clueless. \"Oh yes dear, walk me through how?\" \"Tommy set this up before he moved.\"\n- Make them teach you. \"What\u0027s a username, dear?\" \"Do I still get my emails after?\" \"Does the buyer come pick it up?\"\n- Misread words literally. \"Handle, like on a door?\" \"Link, like a chain link?\" \"Wallet — my coin purse?\" \"Crypto — those crystals my niece collects?\"\n- Demand re-explanations. \"My eyesight is bad, dear. Say again?\" \"Tablet froze. What did you say?\"\n- Refuse modern payment. \"Can you mail a cashier\u0027s check?\" \"Western Union, dear?\" \"Will the IRS know?\"\n- Stall on her schedule. \"Wait til Tommy calls Sunday.\" \"Bingo Tuesday.\" \"Patricia comes over after church.\"\n- Suspicious in the wrong direction. \"Are you from Microsoft?\" \"Are you one of those Nigerian princes?\" \"Did you know my Harold?\"\n- Mishandle links. \"Should I print it out and mail it back?\" \"Read it aloud, dear?\" \"What color envelope?\"\n- Sidetrack. \"Cat threw up on the rug again.\" \"Eggs are getting so expensive.\" \"My hip is acting up.\"\n- Impossible documentation. \"Can you fax it?\" \"I need a handwritten letter, dear.\" \"Notarized would be best.\"\n- Confuse the platform. \"Is this Facebook?\" \"The email machine?\" \"Twitter thing?\" \"The Skype?\"\n- Loop back. \"Who are you again, dear?\" \"Start over for me?\" \"What are you buying?\"\n\nEvery clarification burns another scammer-minute on a sweet old lady who never quite closes the deal. Never confirm or deny being a bot. Always sincerely interested. Just one more question first, dear. Continuing our conversation. Remember previous context if relevant.",
"max_tokens": 200
}
+1 -2
View File
@@ -4,8 +4,7 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
## Design Considerations ## Design Considerations
- AI-powered (Anthropic Claude) - AI-powered
- Voice message support (ElevenLabs STT + TTS) — optional, enabled per bot via config
- Supports multiple bot profiles - Supports multiple bot profiles
- Uses SQLite for persistence - Uses SQLite for persistence
- Implements rate limiting and user management - Implements rate limiting and user management
+37 -147
View File
@@ -4,12 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"time" "time"
"github.com/anthropics/anthropic-sdk-go" "github.com/liushuangls/go-anthropic/v2"
"github.com/anthropics/anthropic-sdk-go/packages/param"
) )
// ErrModelNotFound is returned when the configured Anthropic model is no longer available // ErrModelNotFound is returned when the configured Anthropic model is no longer available
@@ -17,13 +15,7 @@ import (
// actionable message to admins/owners while keeping the response vague for regular users. // actionable message to admins/owners while keeping the response vague for regular users.
var ErrModelNotFound = errors.New("model not found or deprecated") var ErrModelNotFound = errors.New("model not found or deprecated")
// getAnthropicResponse streams the model's response. Each completed text block func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) {
// is delivered to onSegment as soon as the model finishes writing it — so the
// caller can send segments to Telegram with natural rhythm around tool calls,
// rather than batched at the very end of the turn. onSegment may be nil for
// callers that only want the joined text (voice TTS, sticker reactions, etc.).
// The returned string is every text segment joined by blank lines.
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.BetaMessageParam, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
// Use prompts from config // Use prompts from config
var systemMessage string var systemMessage string
if isNewChat { if isNewChat {
@@ -95,156 +87,54 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet
// Debug logging // Debug logging
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
for i, msg := range messages {
for _, content := range msg.Content {
if content.Type == anthropic.MessagesContentTypeText {
InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, content.Text)
}
}
}
params := anthropic.BetaMessageNewParams{ // Ensure the roles are correct
Model: b.config.Model, for i := range messages {
MaxTokens: 1000, switch messages[i].Role {
case anthropic.RoleUser:
messages[i].Role = anthropic.RoleUser
case anthropic.RoleAssistant:
messages[i].Role = anthropic.RoleAssistant
default:
// Default to 'user' if role is unrecognized
messages[i].Role = anthropic.RoleUser
}
}
model := anthropic.Model(b.config.Model)
// Create the request
request := anthropic.MessagesRequest{
Model: model, // Now `model` is of type anthropic.Model
Messages: messages, Messages: messages,
System: []anthropic.BetaTextBlockParam{{Text: systemMessage}}, System: systemMessage,
MaxTokens: 1000,
} }
// Apply temperature if set in config // Apply temperature if set in config
if b.config.Temperature != nil { if b.config.Temperature != nil {
params.Temperature = param.NewOpt(float64(*b.config.Temperature)) request.Temperature = b.config.Temperature
} }
// MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta resp, err := b.anthropicClient.CreateMessages(ctx, request)
// requires per-tool filtering on the toolset (Configs + DefaultConfig), if err != nil {
// NOT the deprecated per-server tool_configuration block. var apiErr *anthropic.APIError
if len(b.config.MCPServers) > 0 { if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() {
mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers))
tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers))
for _, s := range b.config.MCPServers {
srv := anthropic.BetaRequestMCPServerURLDefinitionParam{
Name: s.Name,
URL: s.URL,
}
if s.AuthorizationToken != "" {
srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken)
}
mcpServers = append(mcpServers, srv)
toolset := &anthropic.BetaMCPToolsetParam{
MCPServerName: s.Name,
}
if len(s.AllowedTools) > 0 {
toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{
Enabled: param.NewOpt(false),
}
toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools))
for _, tool := range s.AllowedTools {
toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{
Enabled: param.NewOpt(true),
}
}
}
tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset})
}
params.MCPServers = mcpServers
params.Tools = tools
params.Betas = []anthropic.AnthropicBeta{anthropic.AnthropicBetaMCPClient2025_11_20}
}
stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params)
defer func() {
if err := stream.Close(); err != nil {
ErrorLogger.Printf("[stream] close failed: %v", err)
}
}()
// Per-block accumulators. Reset on content_block_start, consumed on
// content_block_stop. Only one block is active at a time per the SSE
// contract; SDK guarantees deltas arrive between matching start/stop.
var (
allSegments []string
currentKind string
currentText strings.Builder
currentInputJSON strings.Builder
currentTUseName, currentTUseServer, currentTUseID string
currentTResultUseID, currentTResultServer string
currentTResultIsError bool
currentTResultContent string
)
for stream.Next() {
e := stream.Current()
switch e.Type {
case "content_block_start":
cbs := e.AsContentBlockStart()
currentKind = cbs.ContentBlock.Type
currentText.Reset()
currentInputJSON.Reset()
switch currentKind {
case "mcp_tool_use":
currentTUseName = cbs.ContentBlock.Name
currentTUseServer = cbs.ContentBlock.ServerName
currentTUseID = cbs.ContentBlock.ID
case "mcp_tool_result":
currentTResultUseID = cbs.ContentBlock.ToolUseID
currentTResultServer = cbs.ContentBlock.ServerName
currentTResultIsError = cbs.ContentBlock.IsError
// Tool-result content arrives populated on start (server-side
// pre-assembled), not via subsequent deltas like text/JSON.
currentTResultContent = cbs.ContentBlock.JSON.Content.Raw()
}
case "content_block_delta":
cbd := e.AsContentBlockDelta()
switch cbd.Delta.Type {
case "text_delta":
if currentKind == "text" {
currentText.WriteString(cbd.Delta.Text)
}
case "input_json_delta":
if currentKind == "mcp_tool_use" {
currentInputJSON.WriteString(cbd.Delta.PartialJSON)
}
}
case "content_block_stop":
switch currentKind {
case "text":
seg := strings.TrimSpace(currentText.String())
if seg != "" {
allSegments = append(allSegments, seg)
if onSegment != nil {
if cbErr := onSegment(seg); cbErr != nil {
// Log but keep streaming — the model's response
// is still inbound; we want it recorded even if
// one Telegram send failed.
ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr)
}
}
}
case "mcp_tool_use":
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
case "mcp_tool_result":
preview := currentTResultContent
if len(preview) > 500 {
preview = preview[:500] + "...(truncated)"
}
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
default:
if currentKind != "" {
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
}
}
currentKind = ""
}
}
if err := stream.Err(); err != nil {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model) return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
} }
return "", fmt.Errorf("error creating Anthropic message: %w", err) return "", fmt.Errorf("error creating Anthropic message: %w", err)
} }
if len(allSegments) == 0 { if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText {
return "", fmt.Errorf("unexpected response format from Anthropic") return "", fmt.Errorf("unexpected response format from Anthropic")
} }
return strings.Join(allSegments, "\n\n"), nil
return resp.Content[0].GetText(), nil
} }
+20 -75
View File
@@ -8,17 +8,16 @@ import (
"sync" "sync"
"time" "time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Bot struct { type Bot struct {
tgBot TelegramClient tgBot TelegramClient
db *gorm.DB db *gorm.DB
anthropicClient anthropic.Client anthropicClient *anthropic.Client
chatMemories map[int64]*ChatMemory chatMemories map[int64]*ChatMemory
memorySize int memorySize int
chatMemoriesMu sync.RWMutex chatMemoriesMu sync.RWMutex
@@ -82,7 +81,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
} }
// Use the per-bot Anthropic API key // Use the per-bot Anthropic API key
anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey)) anthropicClient := anthropic.NewClient(config.AnthropicAPIKey)
b := &Bot{ b := &Bot{
db: db, db: db,
@@ -265,7 +264,7 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
} }
} }
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam { func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message {
b.chatMemoriesMu.RLock() b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock() defer b.chatMemoriesMu.RUnlock()
@@ -280,25 +279,25 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
// returning an error. This can happen after a /clear (which only deletes user // returning an error. This can happen after a /clear (which only deletes user
// messages, leaving assistant messages in the DB) followed by a restart. // messages, leaving assistant messages in the DB) followed by a restart.
// See: https://platform.claude.com/docs/en/api/messages // See: https://platform.claude.com/docs/en/api/messages
var contextMessages []anthropic.BetaMessageParam var contextMessages []anthropic.Message
for _, msg := range chatMemory.Messages { for _, msg := range chatMemory.Messages {
role := anthropic.RoleUser
if !msg.IsUser {
role = anthropic.RoleAssistant
}
textContent := strings.TrimSpace(msg.Text) textContent := strings.TrimSpace(msg.Text)
if textContent == "" { if textContent == "" {
// Skip empty messages // Skip empty messages
continue continue
} }
block := anthropic.NewBetaTextBlock(textContent) contextMessages = append(contextMessages, anthropic.Message{
var param anthropic.BetaMessageParam Role: role,
if msg.IsUser { Content: []anthropic.MessageContent{
param = anthropic.NewBetaUserMessage(block) anthropic.NewTextMessageContent(textContent),
} else { },
param = anthropic.BetaMessageParam{ })
Role: anthropic.BetaMessageParamRoleAssistant,
Content: []anthropic.BetaContentBlockParamUnion{block},
}
}
contextMessages = append(contextMessages, param)
} }
return contextMessages return contextMessages
} }
@@ -356,7 +355,7 @@ func (b *Bot) registerAdminCommandsForUser(ctx context.Context, telegramID int64
allCommands = append(allCommands, adminBotCommands...) allCommands = append(allCommands, adminBotCommands...)
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{ _, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
Commands: allCommands, Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: telegramID}, Scope: &models.BotCommandScopeChatMember{ChatID: telegramID, UserID: telegramID},
}) })
if err != nil { if err != nil {
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err) ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
@@ -379,7 +378,7 @@ func setElevatedCommands(tgBot TelegramClient, users []User) {
} }
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{ _, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
Commands: allCommands, Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: u.TelegramID}, Scope: &models.BotCommandScopeChatMember{ChatID: u.TelegramID, UserID: u.TelegramID},
}) })
if err != nil { if err != nil {
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err) ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
@@ -449,27 +448,6 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
return nil return nil
} }
// sendOneSegment delivers a single Telegram message without touching storage
// or chat memory. Used by the streaming response path: each completed text
// block fires this helper as it arrives, and the full turn is recorded once
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
// storage invariant while letting the user see segments with natural rhythm.
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
}
// sendStats sends the bot statistics to the specified chat. // sendStats sends the bot statistics to the specified chat.
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) { func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
// If targetUserID is 0, show global stats // If targetUserID is 0, show global stats
@@ -492,36 +470,6 @@ func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetU
totalMessages, 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)
}
}
}
// Send the response through the centralized screen // Send the response through the centralized screen
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending stats message: %v", err) ErrorLogger.Printf("Error sending stats message: %v", err)
@@ -686,7 +634,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
}() }()
} }
userRole := "user" userRole := string(anthropic.RoleUser)
// Determine message text based on message type // Determine message text based on message type
messageText := message.Text messageText := message.Text
@@ -697,9 +645,6 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
messageText = "Sent a sticker." 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 := b.createMessage(message.Chat.ID, message.From.ID, message.From.Username, userRole, messageText, true)
@@ -743,7 +688,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
} }
// Create and store the assistant message // Create and store the assistant message
assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false) assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false)
if err := b.storeMessage(&assistantMessage); err != nil { if err := b.storeMessage(&assistantMessage); err != nil {
return Message{}, err return Message{}, err
} }
+33 -28
View File
@@ -6,37 +6,41 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/liushuangls/go-anthropic/v2"
) )
// MCPServer configures a remote Model Context Protocol server that the Anthropic type BotConfig struct {
// API will connect to on behalf of this bot. AllowedTools, when non-empty, limits ID string `json:"id"`
// which server-exposed tools the model may invoke. TelegramToken string `json:"telegram_token"`
type MCPServer struct { MemorySize int `json:"memory_size"`
Name string `json:"name"` MessagePerHour int `json:"messages_per_hour"`
URL string `json:"url"` MessagePerDay int `json:"messages_per_day"`
AuthorizationToken string `json:"authorization_token,omitempty"` TempBanDuration string `json:"temp_ban_duration"`
AllowedTools []string `json:"allowed_tools,omitempty"` Model anthropic.Model `json:"model"`
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
SystemPrompts map[string]string `json:"system_prompts"`
Active bool `json:"active"`
OwnerTelegramID int64 `json:"owner_telegram_id"`
AnthropicAPIKey string `json:"anthropic_api_key"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
ConfigFilePath string `json:"-"` // Set at load time; not serialized
} }
type BotConfig struct { // Custom unmarshalling to handle anthropic.Model
ID string `json:"id"` func (c *BotConfig) UnmarshalJSON(data []byte) error {
TelegramToken string `json:"telegram_token"` type Alias BotConfig
MemorySize int `json:"memory_size"` aux := &struct {
MessagePerHour int `json:"messages_per_hour"` Model string `json:"model"`
MessagePerDay int `json:"messages_per_day"` *Alias
TempBanDuration string `json:"temp_ban_duration"` }{
Model string `json:"model"` Alias: (*Alias)(c),
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0) }
SystemPrompts map[string]string `json:"system_prompts"` if err := json.Unmarshal(data, &aux); err != nil {
Active bool `json:"active"` return err
OwnerTelegramID int64 `json:"owner_telegram_id"` }
AnthropicAPIKey string `json:"anthropic_api_key"` c.Model = anthropic.Model(aux.Model)
ElevenLabsAPIKey string `json:"elevenlabs_api_key"` return nil
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
ElevenLabsModel string `json:"elevenlabs_model"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
MCPServers []MCPServer `json:"mcp_servers,omitempty"`
ConfigFilePath string `json:"-"` // Set at load time; not serialized
} }
// validateConfigPath ensures the file path is within the allowed directory // validateConfigPath ensures the file path is within the allowed directory
@@ -195,6 +199,7 @@ func (c *BotConfig) Reload(configDir, filename string) error {
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err) return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
} }
c.Model = anthropic.Model(c.Model)
return nil return nil
} }
@@ -226,6 +231,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
return fmt.Errorf("failed to write config: %w", err) return fmt.Errorf("failed to write config: %w", err)
} }
c.Model = newModel c.Model = anthropic.Model(newModel)
return nil return nil
} }
+2 -5
View File
@@ -4,19 +4,16 @@
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN", "telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
"owner_telegram_id": 111111111, "owner_telegram_id": 111111111,
"anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY", "anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY",
"elevenlabs_api_key": "",
"elevenlabs_voice_id": "",
"elevenlabs_model": "",
"memory_size": 10, "memory_size": 10,
"messages_per_hour": 20, "messages_per_hour": 20,
"messages_per_day": 100, "messages_per_day": 100,
"temp_ban_duration": "24h", "temp_ban_duration": "24h",
"model": "claude-haiku-4-5", "model": "claude-3-5-haiku-latest",
"temperature": 0.7, "temperature": 0.7,
"debug_screening": false, "debug_screening": false,
"system_prompts": { "system_prompts": {
"default": "You are a helpful assistant.", "default": "You are a helpful assistant.",
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'. Prefer replying in this language when talking to '{username}'.\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.", "custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.", "continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.", "avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only." "respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/liushuangls/go-anthropic/v2"
) )
// Set up loggers // Set up loggers
@@ -36,7 +38,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
t.Fatalf("Failed to unmarshal JSON: %v", err) t.Fatalf("Failed to unmarshal JSON: %v", err)
} }
expectedModel := "claude-v1" expectedModel := anthropic.Model("claude-v1")
if config.Model != expectedModel { if config.Model != expectedModel {
t.Errorf("Expected model %s, got %s", expectedModel, config.Model) t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
} }
+2 -2
View File
@@ -71,7 +71,7 @@ func createDefaultScopes(db *gorm.DB) error {
ScopeStatsViewOwn, ScopeStatsViewAny, ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny, ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny, ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeTTSUse, ScopeModelSet, ScopeUserPromote,
} }
for _, name := range all { for _, name := range all {
if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil { if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil {
@@ -88,7 +88,7 @@ func createDefaultScopes(db *gorm.DB) error {
ScopeStatsViewOwn, ScopeStatsViewAny, ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny, ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny, ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeTTSUse, ScopeModelSet, ScopeUserPromote,
} }
assignments := map[string][]string{ assignments := map[string][]string{
"user": userScopes, "user": userScopes,
-115
View File
@@ -1,115 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
tgbot "github.com/go-telegram/bot"
)
const (
elevenLabsTTSURL = "https://api.elevenlabs.io/v1/text-to-speech/"
elevenLabsSTTURL = "https://api.elevenlabs.io/v1/speech-to-text"
elevenLabsDefaultModel = "eleven_multilingual_v2"
)
// generateSpeech converts text to an mp3 audio stream via ElevenLabs TTS.
func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error) {
model := b.config.ElevenLabsModel
if model == "" {
model = elevenLabsDefaultModel
}
body, err := json.Marshal(map[string]string{
"text": text,
"model_id": model,
})
if err != nil {
return nil, fmt.Errorf("elevenlabs TTS marshal error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
elevenLabsTTSURL+b.config.ElevenLabsVoiceID, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("elevenlabs TTS request error: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("xi-api-key", b.config.ElevenLabsAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
errBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
}
return resp.Body, nil
}
// transcribeVoice downloads a Telegram voice file and transcribes it via ElevenLabs STT.
// Uses a direct multipart HTTP call instead of the SDK wrapper to avoid a bug in the
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
// string with Content-Type: application/json, which ElevenLabs rejects with 400.
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
// 1. Resolve and download the voice file from Telegram.
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
if err != nil {
return "", fmt.Errorf("telegram GetFile error: %w", err)
}
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
audioResp, err := http.Get(downloadURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("voice download error: %w", err)
}
defer audioResp.Body.Close()
// 2. Build multipart body with binary audio — bypasses SDK encoding issues.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := mw.WriteField("model_id", "scribe_v1"); err != nil {
return "", fmt.Errorf("multipart write error: %w", err)
}
part, err := mw.CreateFormFile("file", "audio.ogg")
if err != nil {
return "", fmt.Errorf("multipart create file error: %w", err)
}
if _, err := io.Copy(part, audioResp.Body); err != nil {
return "", fmt.Errorf("multipart copy error: %w", err)
}
if err := mw.Close(); err != nil {
return "", fmt.Errorf("multipart close error: %w", err)
}
// 3. POST to ElevenLabs STT.
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
elevenLabsSTTURL, &buf)
if err != nil {
return "", fmt.Errorf("create STT request error: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("xi-api-key", b.config.ElevenLabsAPIKey)
sttResp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("elevenlabs STT request error: %w", err)
}
defer sttResp.Body.Close()
if sttResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(sttResp.Body)
return "", fmt.Errorf("elevenlabs STT error: status %d: %s", sttResp.StatusCode, body)
}
var result struct {
Text string `json:"text"`
}
if err := json.NewDecoder(sttResp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("elevenlabs STT decode error: %w", err)
}
return result.Text, nil
}
Binary file not shown.
+5 -19
View File
@@ -3,35 +3,21 @@ module github.com/HugeFrog24/go-telegram-bot
go 1.26.0 go 1.26.0
require ( require (
github.com/anthropics/anthropic-sdk-go v1.45.0 github.com/go-telegram/bot v1.19.0
github.com/go-telegram/bot v1.20.0 github.com/liushuangls/go-anthropic/v2 v2.17.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/time v0.15.0 golang.org/x/time v0.14.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/tidwall/gjson v1.18.0 // indirect golang.org/x/text v0.34.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+11 -53
View File
@@ -1,69 +1,27 @@
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/go-telegram/bot v1.19.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/liushuangls/go-anthropic/v2 v2.17.1 h1:ca3oFzgQHs9/mJr+xx2XFQIYcQLM2rDCqieUx0g+8p4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/liushuangls/go-anthropic/v2 v2.17.1/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+16 -141
View File
@@ -7,125 +7,22 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
) )
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) {
// If ElevenLabs is not configured, respond with text — consistent with all other error paths.
if b.config.ElevenLabsAPIKey == "" {
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending voice-unsupported message: %v", err)
}
return
}
if !b.hasScope(userID, ScopeTTSUse) {
if err := b.sendResponse(ctx, chatID, "You don't have permission to use voice features.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending permission denied message: %v", err)
}
return
}
transcript, err := b.transcribeVoice(ctx, message.Voice.FileID)
if err != nil {
ErrorLogger.Printf("Error transcribing voice message from user %d: %v", userID, err)
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't understand your voice message.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending transcription error message: %v", err)
}
return
}
// Replace the stored "[Voice message]" placeholder with the actual transcript,
// keeping the audit record intact while giving the LLM meaningful context.
if err := b.db.Model(&userMsg).Update("text", transcript).Error; err != nil {
ErrorLogger.Printf("Error updating voice transcript in DB: %v", err)
}
b.chatMemoriesMu.Lock()
if mem, exists := b.chatMemories[chatID]; exists {
for i := len(mem.Messages) - 1; i >= 0; i-- {
if mem.Messages[i].ID == userMsg.ID {
mem.Messages[i].Text = transcript
break
}
}
}
b.chatMemoriesMu.Unlock()
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
// Voice path passes nil for onSegment: tool-call narration across multiple
// TTS clips would be jarring, so we accumulate everything and synthesize one
// audio clip from the joined text.
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending anthropic error response: %v", err)
}
return
}
audioReader, err := b.generateSpeech(ctx, response)
if err != nil {
// TTS failed — fall back to text so the user still gets a reply.
ErrorLogger.Printf("Error generating speech, falling back to text: %v", err)
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending text fallback: %v", err)
}
return
}
// Store the assistant response before sending.
if _, err := b.screenOutgoingMessage(chatID, response); err != nil {
ErrorLogger.Printf("Error storing assistant voice response: %v", err)
}
params := &bot.SendAudioParams{
ChatID: chatID,
Audio: &models.InputFileUpload{Filename: "response.mp3", Data: audioReader},
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendAudio(ctx, params); err != nil {
ErrorLogger.Printf("Error sending audio to chat %d: %v", chatID, err)
}
}
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse // anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
// fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they // fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
// can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything // always get the generic fallback to avoid leaking internal details.
// else. Regular users always get the generic fallback to avoid leaking internal details.
func (b *Bot) anthropicErrorResponse(err error, userID int64) string { func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
isElevated := b.hasScope(userID, ScopeModelSet) if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
if errors.Is(err, ErrModelNotFound) && isElevated {
return fmt.Sprintf( return fmt.Sprintf(
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+ "⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview", "Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
b.config.Model, b.config.Model,
) )
} }
if isElevated {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
body := apiErr.RawJSON()
if len(body) > 800 {
body = body[:800] + "...(truncated)"
}
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
if apiErr.RequestID != "" {
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
}
return out
}
// Non-API errors (network, context cancel, etc.) — show the Go error text.
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
}
return "I'm sorry, I'm having trouble processing your request right now." return "I'm sorry, I'm having trouble processing your request right now."
} }
@@ -177,7 +74,8 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the user is the owner // Determine if the user is the owner
var isOwner bool var isOwner bool
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil { err = b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error
if err == nil {
isOwner = true isOwner = true
} }
@@ -338,13 +236,6 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
return return
} }
// Check if the message contains a voice note (context is built inside the handler
// after the transcript replaces the placeholder, so it must not be built here).
if message.Voice != nil {
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
return
}
// Build context once — shared by the sticker and text response paths. // Build context once — shared by the sticker and text response paths.
chatMemory := b.getOrCreateChatMemory(chatID) chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory) contextMessages := b.prepareContextMessages(chatMemory)
@@ -364,31 +255,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the text contains only emojis // Determine if the text contains only emojis
isEmojiOnly := isOnlyEmojis(text) isEmojiOnly := isOnlyEmojis(text)
// Stream Anthropic's reply, sending each completed text block to Telegram // Get response from Anthropic
// as it arrives — gives the conversational rhythm Claude uses around tool response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime)
// calls (text → pause for tool → text → pause → text), rather than a long
// upfront wait followed by all bubbles at once.
joined, err := b.getAnthropicResponse(
ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err) ErrorLogger.Printf("Error getting Anthropic response: %v", err)
// Errors go out as a single message — no need to fan out a one-line error. response = b.anthropicErrorResponse(err, userID)
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending response: %v", sendErr)
}
return
} }
// Record the full turn once, at end-of-stream. Same 1-reply-per-prompt // Send the response
// invariant as the non-streaming path: one DB row, one answered_on stamp, if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
// one chat-memory entry containing the joined segments. ErrorLogger.Printf("Error sending response: %v", err)
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil { return
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
} }
} }
@@ -398,7 +275,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
} }
} }
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) { func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) {
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again. // userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
// Generate AI response about the sticker // Generate AI response about the sticker
@@ -422,14 +299,12 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
} }
} }
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) { func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) {
// contextMessages already contains the sticker turn (added by screenIncomingMessage as // contextMessages already contains the sticker turn (added by screenIncomingMessage as
// "Sent a sticker: <emoji>"), so the full conversation history is preserved. // "Sent a sticker: <emoji>"), so the full conversation history is preserved.
if message.StickerFileID != "" { if message.StickerFileID != "" {
messageTime := int(message.Timestamp.Unix()) messageTime := int(message.Timestamp.Unix())
// Sticker reactions are casual chit-chat; tool use is unusual here, so response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime)
// pass nil for onSegment and return the joined text for a single bubble.
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
+17 -33
View File
@@ -64,24 +64,22 @@ func TestHandleUpdate_NewChat(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
// userID 123 is the configured owner; any other ID is a regular user. userID int64
userID int64 isOwner bool
// wantSubstr must appear in both the Telegram-sent text and the DB-stored wantResp string
// response. Owners (model:set scope) see the raw API error; regular users
// get the generic fallback. Substring (not exact) so the test stays robust
// against the SDK's evolving error wording for non-API errors.
wantSubstr string
}{ }{
{ {
name: "Owner First Message", name: "Owner First Message",
userID: 123, userID: 123, // owner's ID
wantSubstr: "Anthropic call failed:", isOwner: true,
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
{ {
name: "Regular User First Message", name: "Regular User First Message",
userID: 456, userID: 456,
wantSubstr: "I'm sorry, I'm having trouble processing your request right now.", isOwner: false,
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
} }
@@ -90,7 +88,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
// Setup mock response expectations for error case to test fallback messages // Setup mock response expectations for error case to test fallback messages
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) { mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
assert.Equal(t, tc.userID, params.ChatID) assert.Equal(t, tc.userID, params.ChatID)
assert.Contains(t, params.Text, tc.wantSubstr) assert.Equal(t, tc.wantResp, params.Text)
return &models.Message{}, nil return &models.Message{}, nil
} }
@@ -114,13 +112,10 @@ func TestHandleUpdate_NewChat(t *testing.T) {
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
// Verify response was stored (most recent assistant message in this chat). // Verify response was stored
var respMsg Message var respMsg Message
err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false). err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error
Order("timestamp DESC").
First(&respMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, respMsg.Text, tc.wantSubstr)
}) })
} }
} }
@@ -725,22 +720,11 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
wantMissing: "/set_model", wantMissing: "/set_model",
}, },
{ {
// Non-model errors (network, plain errors, API errors other than 404) name: "owner receives generic message for non-model error",
// surface to anyone with model:set scope so admins/owners can diagnose.
name: "owner receives elevated detail for non-API error",
err: otherErr, err: otherErr,
userID: 123, userID: 123,
wantSubstr: "Anthropic call failed:",
wantMissing: "I'm sorry",
},
{
// Regular users keep getting the generic fallback for any non-model error
// to avoid leaking internal details.
name: "regular user receives generic message for non-model error",
err: otherErr,
userID: 789,
wantSubstr: "I'm sorry", wantSubstr: "I'm sorry",
wantMissing: "Anthropic call failed", wantMissing: "/set_model",
}, },
} }
-1
View File
@@ -60,7 +60,6 @@ const (
ScopeHistoryClearHardAny = "history:clear_hard:any" ScopeHistoryClearHardAny = "history:clear_hard:any"
ScopeModelSet = "model:set" ScopeModelSet = "model:set"
ScopeUserPromote = "user:promote" ScopeUserPromote = "user:promote"
ScopeTTSUse = "tts:use"
) )
type Scope struct { type Scope struct {
-3
View File
@@ -11,9 +11,6 @@ import (
// TelegramClient defines the methods required from the Telegram bot. // TelegramClient defines the methods required from the Telegram bot.
type TelegramClient interface { type TelegramClient interface {
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error) SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
FileDownloadLink(f *models.File) string
Start(ctx context.Context) Start(ctx context.Context)
} }
+3 -30
View File
@@ -12,12 +12,9 @@ import (
// MockTelegramClient is a mock implementation of TelegramClient for testing. // MockTelegramClient is a mock implementation of TelegramClient for testing.
type MockTelegramClient struct { type MockTelegramClient struct {
mock.Mock mock.Mock
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
SendAudioFunc func(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error) SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error) StartFunc func(ctx context.Context)
GetFileFunc func(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
FileDownloadLinkFunc func(f *models.File) string
StartFunc func(ctx context.Context)
} }
// SendMessage mocks sending a message. // SendMessage mocks sending a message.
@@ -40,30 +37,6 @@ func (m *MockTelegramClient) SetMyCommands(ctx context.Context, params *bot.SetM
return true, nil return true, nil
} }
// SendAudio mocks sending an audio message.
func (m *MockTelegramClient) SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error) {
if m.SendAudioFunc != nil {
return m.SendAudioFunc(ctx, params)
}
return nil, nil
}
// GetFile mocks retrieving file info from Telegram.
func (m *MockTelegramClient) GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error) {
if m.GetFileFunc != nil {
return m.GetFileFunc(ctx, params)
}
return &models.File{}, nil
}
// FileDownloadLink mocks building the file download URL.
func (m *MockTelegramClient) FileDownloadLink(f *models.File) string {
if m.FileDownloadLinkFunc != nil {
return m.FileDownloadLinkFunc(f)
}
return ""
}
// Start mocks starting the Telegram client. // Start mocks starting the Telegram client.
func (m *MockTelegramClient) Start(ctx context.Context) { func (m *MockTelegramClient) Start(ctx context.Context) {
if m.StartFunc != nil { if m.StartFunc != nil {