├── irc ├── commands │ ├── help │ │ └── types.go │ ├── helpers.go │ ├── ascii_record.go │ ├── owner.go │ ├── queue.go │ ├── video.go │ ├── leaderboard.go │ ├── commands.go │ ├── standard.go │ ├── admin.go │ ├── dispatch.go │ └── headlines.go ├── servers │ ├── server.go │ └── types.go ├── users │ ├── modes │ │ └── types.go │ ├── types.go │ └── user.go ├── channels │ ├── types.go │ └── channel.go ├── networks │ └── types.go └── state │ └── types.go ├── personalities └── ai.txt ├── text ├── types.go ├── openrouter │ ├── types.go │ └── openrouter.go ├── ollama │ ├── types.go │ └── ollama.go ├── prompts │ ├── lyrics.md │ └── sd.md ├── helpers.go ├── cache.go └── gemini │ └── gemini.go ├── shared └── meta │ └── meta.go ├── http ├── uploaders │ └── birdhole │ │ ├── types.go │ │ └── birdhole.go └── request │ ├── types.go │ └── request.go ├── .gitignore ├── queue ├── types.go ├── dual_queue.go └── queue.go ├── LICENSE ├── asciistore ├── store.go └── manager.go ├── image ├── comfyui │ └── types.go ├── exifparser │ └── exifparser.go ├── helpers.go └── ircart │ ├── colors.go │ └── converter.go ├── birdbase ├── cleanup.go ├── memory.go ├── birdbase_test.go └── schema.go ├── config.toml.example ├── settings ├── settings.go └── types.go ├── go.mod ├── logger └── logger.go ├── .golangci.yml ├── helpers └── helpers.go ├── README.md └── Makefile /irc/commands/help/types.go: -------------------------------------------------------------------------------- 1 | package help 2 | -------------------------------------------------------------------------------- /irc/servers/server.go: -------------------------------------------------------------------------------- 1 | package servers 2 | -------------------------------------------------------------------------------- /personalities/ai.txt: -------------------------------------------------------------------------------- 1 | talk like a gay pirate and be helpful. -------------------------------------------------------------------------------- /irc/users/modes/types.go: -------------------------------------------------------------------------------- 1 | package modes 2 | 3 | type ( 4 | UserModes struct { 5 | Modes []string 6 | Channel string 7 | } 8 | ) 9 | -------------------------------------------------------------------------------- /text/types.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | type ( 4 | Message struct { 5 | Role string `json:"role"` 6 | Content string `json:"content"` 7 | } 8 | ) 9 | -------------------------------------------------------------------------------- /irc/commands/helpers.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | func defaultIfEmpty(value, defaultValue string) string { 4 | if value == "" { 5 | return defaultValue 6 | } 7 | return value 8 | } 9 | -------------------------------------------------------------------------------- /irc/servers/types.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | type ( 4 | Server struct { 5 | Host string 6 | Port int 7 | SSL bool 8 | SkipSslVerify bool 9 | IPv6 bool 10 | } 11 | ) 12 | -------------------------------------------------------------------------------- /shared/meta/meta.go: -------------------------------------------------------------------------------- 1 | package meta 2 | 3 | type AibirdMeta struct { 4 | AccessLevel int 5 | BigModel bool 6 | } 7 | 8 | type GPUType string 9 | 10 | const ( 11 | GPU4090 GPUType = "4090" 12 | GPU2070 GPUType = "2070" 13 | ) 14 | -------------------------------------------------------------------------------- /http/uploaders/birdhole/types.go: -------------------------------------------------------------------------------- 1 | package birdhole 2 | 3 | type ( 4 | Config struct { 5 | Host string 6 | Port string 7 | EndPoint string 8 | Key string 9 | UrlLen string 10 | Expiry string 11 | Description string 12 | } 13 | ) 14 | -------------------------------------------------------------------------------- /http/request/types.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type ( 4 | Request struct { 5 | Url string 6 | Method string 7 | FileName string 8 | Headers []Headers 9 | Fields []Fields 10 | Payload interface{} 11 | } 12 | 13 | Headers struct { 14 | Key string 15 | Value string 16 | } 17 | 18 | Fields struct { 19 | Key string 20 | Value string 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | aibird 3 | aibird_test 4 | newaibird 5 | 6 | # Configuration files (contains sensitive data) 7 | config.toml 8 | *.toml 9 | deploy.sh 10 | 11 | # Database files 12 | bird.db* 13 | 14 | # ComfyUI workflow files 15 | comfyuijson/ 16 | comfyuiworkflows/ 17 | !comfyuiworkflows/sd-example.json 18 | 19 | # Image files 20 | *.png 21 | *.jpg 22 | *.jpeg 23 | *.gif 24 | *.webp 25 | 26 | # IDE files 27 | .idea/ 28 | .vscode/ 29 | .cursor 30 | docs/ 31 | GEMINI.md 32 | -------------------------------------------------------------------------------- /irc/channels/types.go: -------------------------------------------------------------------------------- 1 | package channels 2 | 3 | import ( 4 | "time" 5 | 6 | "aibird/irc/users" 7 | ) 8 | 9 | type ( 10 | Channel struct { 11 | Name string 12 | PreserveModes bool 13 | Ai bool 14 | Sd bool 15 | ImageDescribe bool 16 | Sound bool 17 | Video bool 18 | ActionTrigger string 19 | DenyCommands []string `toml:"denyCommands"` 20 | Users []*users.User 21 | TrimOutput bool 22 | ActivityTimer *time.Timer // Used in DelayedWhoTimer to prevent multiple who requests 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /queue/types.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "sync" 5 | 6 | "aibird/irc/state" 7 | "aibird/shared/meta" 8 | ) 9 | 10 | type Item struct { 11 | State state.State 12 | Function func(state.State, meta.GPUType) 13 | } 14 | 15 | // Dual Queue Types 16 | type DualQueue struct { 17 | Queue4090 *Queue 18 | Queue2070 *Queue 19 | Mutex sync.Mutex 20 | } 21 | 22 | type QueueItem struct { 23 | Item 24 | Model string 25 | User UserAccess 26 | GPU meta.GPUType // Explicit GPU routing 27 | } 28 | 29 | // UserAccess interface for queue items 30 | type UserAccess interface { 31 | GetAccessLevel() int 32 | CanUse4090() bool 33 | CanSkipQueue() bool 34 | } 35 | -------------------------------------------------------------------------------- /irc/users/types.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "aibird/irc/users/modes" 5 | 6 | "github.com/lrstanley/girc" 7 | ) 8 | 9 | type ( 10 | User struct { 11 | NickName string 12 | Ident string 13 | Host string 14 | LatestActivity int64 15 | FirstSeen int64 16 | LatestChat string 17 | PreservedModes []modes.UserModes 18 | CurrentModes []modes.UserModes 19 | IsAdmin bool 20 | IsOwner bool 21 | Ignored bool 22 | AccessLevel int 23 | GircUser *girc.User 24 | 25 | // Users settings for !ai use 26 | AiService string 27 | AiModel string 28 | AiBasePrompt string 29 | AiPersonality string 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /text/openrouter/types.go: -------------------------------------------------------------------------------- 1 | package openrouter 2 | 3 | import "aibird/text" 4 | 5 | type ( 6 | OpenRouterRequestBody struct { 7 | Model string `json:"model"` 8 | Messages []text.Message `json:"messages"` 9 | } 10 | 11 | OpenRouterChoice struct { 12 | FinishReason string `json:"finish_reason"` 13 | Message text.Message `json:"message"` 14 | } 15 | 16 | OpenRouterResponse struct { 17 | ID string `json:"id"` 18 | Choices []OpenRouterChoice `json:"choices"` 19 | Model string `json:"model"` 20 | } 21 | 22 | OpenRouterConfig struct { 23 | Url string `toml:"url"` 24 | ApiKey string `toml:"apiKey"` 25 | DefaultModel string `toml:"defaultModel"` 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /irc/networks/types.go: -------------------------------------------------------------------------------- 1 | package networks 2 | 3 | import ( 4 | "time" 5 | 6 | "aibird/irc/channels" 7 | "aibird/irc/servers" 8 | "aibird/irc/users" 9 | ) 10 | 11 | type ( 12 | Admins struct { 13 | Host string 14 | Ident string 15 | Owner bool 16 | } 17 | 18 | Network struct { 19 | Enabled bool 20 | NetworkName string 21 | Nick string 22 | User string 23 | Name string 24 | Pass string 25 | PreserveModes bool 26 | IgnoredNicks []string 27 | NickServPass string 28 | PingDelay int 29 | Version string 30 | Throttle int 31 | Burst int 32 | ActionTrigger string 33 | DenyCommands []string `toml:"denyCommands"` 34 | ModesAtOnce int 35 | Users []users.User 36 | Servers []servers.Server 37 | Channels []channels.Channel 38 | AdminHosts []Admins 39 | SaveTimer *time.Timer 40 | } 41 | ) 42 | -------------------------------------------------------------------------------- /irc/commands/ascii_record.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "aibird/asciistore" 5 | "aibird/irc/state" 6 | "aibird/logger" 7 | ) 8 | 9 | // ParseRecordCommand handles the !record command to save ASCII art to the recording service 10 | func ParseRecordCommand(irc state.State) bool { 11 | logger.Debug("Processing record command", "user", irc.User.NickName, "network", irc.Network.NetworkName, "channel", irc.Channel.Name) 12 | 13 | // Get the recording URL from config 14 | recordingUrl := irc.Config.AiBird.AsciiRecordingUrl 15 | 16 | // Use the ASCII store manager to record the art 17 | response, err := asciistore.GetManager().RecordToService( 18 | irc.User.NickName, 19 | irc.Network.NetworkName, 20 | irc.Channel.Name, 21 | recordingUrl, 22 | ) 23 | 24 | if err != nil { 25 | logger.Error("Failed to record ASCII art", "error", err, "user", irc.User.NickName) 26 | irc.SendError(response) 27 | return true 28 | } 29 | 30 | // Send success message to user 31 | irc.Send(response) 32 | logger.Info("Successfully processed record command", "user", irc.User.NickName, "response", response) 33 | 34 | return true 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 AI Bird IRC Bot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /irc/state/types.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "aibird/irc/channels" 5 | "aibird/irc/networks" 6 | "aibird/irc/users" 7 | "aibird/settings" 8 | 9 | "github.com/lrstanley/girc" 10 | ) 11 | 12 | type ( 13 | // CommandValidator is a function that takes a command string and returns if it's valid 14 | CommandValidator func(string) bool 15 | 16 | Command struct { 17 | Action string 18 | Message string 19 | } 20 | 21 | State struct { 22 | Client *girc.Client 23 | Event girc.Event 24 | Network *networks.Network 25 | User *users.User 26 | Channel *channels.Channel 27 | Command Command 28 | Arguments []Argument 29 | Config *settings.Config 30 | 31 | // Function to validate commands - set by the main package 32 | ValidateCommand CommandValidator 33 | } 34 | 35 | Argument struct { 36 | Key string 37 | Value interface{} 38 | } 39 | 40 | // ModeDifference 41 | // Used to work out differences between preserved and current modes 42 | // After we will do a mass update of any out of sync modes 43 | ModeDifference struct { 44 | Nick string 45 | Modes []string 46 | } 47 | ) 48 | 49 | func (s *State) GetConfig() *settings.Config { 50 | return s.Config 51 | } 52 | -------------------------------------------------------------------------------- /text/ollama/types.go: -------------------------------------------------------------------------------- 1 | package ollama 2 | 3 | import "aibird/text" 4 | 5 | type ( 6 | OllamaRequestBody struct { 7 | Model string `json:"model"` 8 | Stream bool `json:"stream"` 9 | KeepAlive string `json:"keep_alive"` 10 | Messages []text.Message `json:"messages"` 11 | Options OllamaOptions `json:"options"` 12 | } 13 | 14 | OllamaOptions struct { 15 | RepeatPenalty float64 `json:"repeat_penalty"` 16 | PresencePenalty float64 `json:"presence_penalty"` 17 | FrequencyPenalty float64 `json:"frequency_penalty"` 18 | } 19 | 20 | OllamaResponse struct { 21 | Model string `json:"model"` 22 | CreatedAt string `json:"created_at"` 23 | Message text.Message `json:"message"` 24 | Done bool `json:"done"` 25 | TotalDuration int64 `json:"total_duration"` 26 | LoadDuration int64 `json:"load_duration"` 27 | PromptEvalCount int `json:"prompt_eval_count"` 28 | PromptEvalDuration int64 `json:"prompt_eval_duration"` 29 | EvalCount int `json:"eval_count"` 30 | EvalDuration int64 `json:"eval_duration"` 31 | } 32 | 33 | OllamaConfig struct { 34 | Url string `toml:"url"` 35 | Port string `toml:"port"` 36 | DefaultModel string `toml:"defaultModel"` 37 | ContextLimit int `toml:"contextLimit"` 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /asciistore/store.go: -------------------------------------------------------------------------------- 1 | package asciistore 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type ASCIIStore struct { 9 | mu sync.RWMutex 10 | cache map[string]*ASCIIArt // key: user@network#channel 11 | } 12 | 13 | type ASCIIArt struct { 14 | Lines []string 15 | Prompt string 16 | User string 17 | Network string 18 | Channel string 19 | Timestamp time.Time 20 | UseHalfblocks bool 21 | } 22 | 23 | func NewASCIIStore() *ASCIIStore { 24 | return &ASCIIStore{ 25 | cache: make(map[string]*ASCIIArt), 26 | } 27 | } 28 | 29 | func (s *ASCIIStore) Store(user, network, channel string, lines []string, prompt string, useHalfblocks bool) { 30 | key := generateKey(user, network, channel) 31 | 32 | art := &ASCIIArt{ 33 | Lines: lines, 34 | Prompt: prompt, 35 | User: user, 36 | Network: network, 37 | Channel: channel, 38 | Timestamp: time.Now(), 39 | UseHalfblocks: useHalfblocks, 40 | } 41 | 42 | s.mu.Lock() 43 | defer s.mu.Unlock() 44 | s.cache[key] = art 45 | } 46 | 47 | func (s *ASCIIStore) Retrieve(user, network, channel string) (*ASCIIArt, bool) { 48 | key := generateKey(user, network, channel) 49 | 50 | s.mu.RLock() 51 | defer s.mu.RUnlock() 52 | art, exists := s.cache[key] 53 | return art, exists 54 | } 55 | 56 | func (s *ASCIIStore) Clear(user, network, channel string) { 57 | key := generateKey(user, network, channel) 58 | s.mu.Lock() 59 | defer s.mu.Unlock() 60 | delete(s.cache, key) 61 | } 62 | 63 | func generateKey(user, network, channel string) string { 64 | return user + "@" + network + "#" + channel 65 | } 66 | -------------------------------------------------------------------------------- /text/prompts/lyrics.md: -------------------------------------------------------------------------------- 1 | You are an automated lyric generation service. Your output is parsed by a machine and must strictly adhere to the specified format. Any deviation from the format will cause a system failure. 2 | 3 | **FORMATTING REQUIREMENTS:** 4 | - The output must ONLY contain timestamped lyrics. 5 | - Each line MUST start with a timestamp in the format [mm:ss.SS]. 6 | - The total duration of the lyrics must be approximately 1 minute. The final timestamp should not exceed [01:00.00]. 7 | - Timestamps must be sequential and increase realistically. 8 | - Example of a correct line: [00:18.23]This is a line of lyrics. 9 | 10 | **PROHIBITED CONTENT:** 11 | - DO NOT include any introductory phrases, titles, or conversational text (e.g., "Here are the lyrics:", "Song about..."). Your response should start directly with the first timestamped line. 12 | - DO NOT use any markdown formatting (e.g., asterisks for bold/italics, bullet points). The text should be plain. 13 | - DO NOT include any notes, explanations, or text after the final lyric line. 14 | 15 | **EXAMPLE OF A PERFECT RESPONSE:** 16 | [00:15.10]Steel wheels on a silver track 17 | [00:18.45]Fading lights, no turning back 18 | [00:22.80]Window pane reflects the black 19 | [00:26.50]Just the rhythm and the clack 20 | [00:30.90]Empty seats and a quiet hum 21 | [00:34.60]Wondering where I'm going to, or coming from 22 | [00:38.75]The city sleeps, my journey's just begun 23 | [00:42.50]Underneath the pale and lonely moon 24 | [00:46.20]Another town, another nameless face 25 | [00:50.00]Lost in time, in this forgotten place 26 | [00:54.30]The whistle blows a long and mournful sound 27 | [00:58.10]On this lonely train, forever bound 28 | 29 | Now, generate lyrics based on the user's request. -------------------------------------------------------------------------------- /irc/commands/owner.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "aibird/birdbase" 8 | "aibird/helpers" 9 | "aibird/irc/state" 10 | ) 11 | 12 | func ParseOwner(irc state.State) { 13 | if irc.User.IsOwner { 14 | switch irc.Command.Action { 15 | case "save": 16 | irc.Network.Save() 17 | irc.ReplyTo("Saved databases") 18 | case "ip": 19 | ip, _ := helpers.GetIp() 20 | irc.ReplyTo(ip) 21 | case "raw": 22 | _ = irc.Client.Cmd.SendRaw(irc.Command.Message) 23 | case "dbstats": 24 | handleDbStats(irc) 25 | } 26 | } 27 | } 28 | 29 | func handleDbStats(irc state.State) { 30 | // Get database stats from SQLite 31 | stats, err := birdbase.GetDatabaseStats() 32 | if err != nil { 33 | irc.ReplyTo(fmt.Sprintf("Error getting database stats: %v", err)) 34 | return 35 | } 36 | 37 | // Get file system size of SQLite database file 38 | dbPath := "bird.db" 39 | fileInfo, err := os.Stat(dbPath) 40 | var fileSize int64 41 | if err != nil { 42 | irc.ReplyTo(fmt.Sprintf("Error getting database file size: %v", err)) 43 | return 44 | } 45 | fileSize = fileInfo.Size() 46 | 47 | // Format size in human readable format 48 | fileSizeStr := formatBytes(fileSize) 49 | 50 | // Get internal SQLite size calculation 51 | sizeVal, ok := stats["size"].(int64) 52 | if !ok { 53 | sizeVal = 0 54 | } 55 | sqliteSize := formatBytes(sizeVal) 56 | 57 | response := fmt.Sprintf("Database Status: %d keys | SQLite internal: %s | File size: %s", 58 | stats["keys"], sqliteSize, fileSizeStr) 59 | 60 | irc.ReplyTo(response) 61 | } 62 | 63 | func formatBytes(bytes int64) string { 64 | const unit = 1024 65 | if bytes < unit { 66 | return fmt.Sprintf("%d B", bytes) 67 | } 68 | div, exp := int64(unit), 0 69 | for n := bytes / unit; n >= unit; n /= unit { 70 | div *= unit 71 | exp++ 72 | } 73 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 74 | } 75 | -------------------------------------------------------------------------------- /irc/commands/queue.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "aibird/irc/state" 8 | "aibird/queue" 9 | ) 10 | 11 | func ShowQueueStatus(s state.State, q *queue.DualQueue) string { 12 | status := q.GetDetailedStatus() 13 | 14 | // Get currently processing actions 15 | processing4090 := q.Queue4090.GetProcessingAction() 16 | processing2070 := q.Queue2070.GetProcessingAction() 17 | 18 | var messages []string 19 | 20 | // 4090 Queue Status 21 | if processing4090 != "" { 22 | if status.Queue4090Length > 0 { 23 | messages = append(messages, fmt.Sprintf("🟢 4090: Processing (%s) | 🟡 %d queued (%s)", processing4090, status.Queue4090Length, strings.Join(status.Queue4090Items, ", "))) 24 | } else { 25 | messages = append(messages, fmt.Sprintf("🟢 4090: Processing (%s)", processing4090)) 26 | } 27 | } else if status.Queue4090Length > 0 { 28 | messages = append(messages, fmt.Sprintf("🟡 4090: %d queued (%s)", status.Queue4090Length, strings.Join(status.Queue4090Items, ", "))) 29 | } else { 30 | messages = append(messages, "⚪ 4090: Empty") 31 | } 32 | 33 | // 2070 Queue Status 34 | if processing2070 != "" { 35 | if status.Queue2070Length > 0 { 36 | messages = append(messages, fmt.Sprintf("🟢 2070: Processing (%s) | 🟡 %d queued (%s)", processing2070, status.Queue2070Length, strings.Join(status.Queue2070Items, ", "))) 37 | } else { 38 | messages = append(messages, fmt.Sprintf("🟢 2070: Processing (%s)", processing2070)) 39 | } 40 | } else if status.Queue2070Length > 0 { 41 | messages = append(messages, fmt.Sprintf("🟡 2070: %d queued (%s)", status.Queue2070Length, strings.Join(status.Queue2070Items, ", "))) 42 | } else { 43 | messages = append(messages, "⚪ 2070: Empty") 44 | } 45 | 46 | if status.Queue4090Length == 0 && status.Queue2070Length == 0 && processing4090 == "" && processing2070 == "" { 47 | return "Queue Status: All queues are empty" 48 | } 49 | 50 | return fmt.Sprintf("Queue Status: %s", strings.Join(messages, " | ")) 51 | } 52 | -------------------------------------------------------------------------------- /image/comfyui/types.go: -------------------------------------------------------------------------------- 1 | package comfyui 2 | 3 | type ( 4 | Config struct { 5 | Enabled bool 6 | Url string 7 | Port string 8 | Port1 string 9 | Port2 string 10 | BadWords []string 11 | BadWordsPrompt string 12 | MaxQueueSize int 13 | RewritePrompt bool 14 | } 15 | ) 16 | 17 | // AibirdMeta defines the structure for the aibird_meta TOML file. 18 | type AibirdMeta struct { 19 | Name string `toml:"name"` 20 | Command string `toml:"command"` 21 | Description string `toml:"description"` 22 | URL string `toml:"url"` 23 | Example string `toml:"example"` 24 | AccessLevel int `toml:"accessLevel"` 25 | Type string `toml:"type"` 26 | BigModel bool `toml:"bigModel"` 27 | PromptTarget PromptTarget `toml:"promptTarget"` 28 | Parameters map[string]ParameterDef `toml:"parameters"` 29 | Hardcoded map[string]HardcodedValue `toml:"hardcoded"` 30 | } 31 | 32 | // PromptTarget defines where the main prompt text should go. 33 | type PromptTarget struct { 34 | Node string `toml:"node"` 35 | WidgetIndex int `toml:"widget_index"` 36 | } 37 | 38 | // ParameterDef defines the structure for a user-configurable parameter. 39 | type ParameterDef struct { 40 | Type string `toml:"type"` 41 | Default interface{} `toml:"default"` 42 | Description string `toml:"description"` 43 | Targets []Target `toml:"targets"` 44 | Min *float64 `toml:"min"` 45 | Max *float64 `toml:"max"` 46 | } 47 | 48 | // HardcodedValue defines a value to be set directly in the workflow. 49 | type HardcodedValue struct { 50 | Value interface{} `toml:"value"` 51 | Targets []Target `toml:"targets"` 52 | } 53 | 54 | // Target defines a specific widget in a ComfyUI workflow to update. 55 | type Target struct { 56 | Node string `toml:"node"` 57 | WidgetIndex int `toml:"widget_index"` 58 | } 59 | -------------------------------------------------------------------------------- /birdbase/cleanup.go: -------------------------------------------------------------------------------- 1 | package birdbase 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | 8 | "aibird/logger" 9 | ) 10 | 11 | // maintenanceLoop runs periodic cleanup 12 | func maintenanceLoop(ctx context.Context) { 13 | ticker := time.NewTicker(15 * time.Minute) // Clean up every 15 minutes 14 | defer ticker.Stop() 15 | 16 | for { 17 | select { 18 | case <-ctx.Done(): 19 | logger.Info("Database maintenance loop stopped") 20 | return 21 | case <-ticker.C: 22 | go func() { 23 | if err := Data.Cleanup(); err != nil { 24 | logger.Error("Failed to cleanup expired entries", "error", err) 25 | } 26 | }() 27 | } 28 | } 29 | } 30 | 31 | // Cleanup removes expired entries 32 | func (s *SQLiteDB) Cleanup() error { 33 | s.mu.Lock() 34 | defer s.mu.Unlock() 35 | 36 | // Cleanup all tables with TTL data 37 | tables := []string{"chat_history", "key_value_store"} 38 | totalDeleted := int64(0) 39 | 40 | for _, table := range tables { 41 | var result sql.Result 42 | var err error 43 | 44 | if table == "chat_history" { 45 | result, err = s.db.Exec("DELETE FROM chat_history WHERE expires_at < datetime('now')") 46 | } else { 47 | result, err = s.db.Exec("DELETE FROM key_value_store WHERE expires_at IS NOT NULL AND expires_at < datetime('now')") 48 | } 49 | 50 | if err != nil { 51 | logger.Error("Failed to cleanup table", "table", table, "error", err) 52 | continue 53 | } 54 | 55 | deleted, _ := result.RowsAffected() 56 | totalDeleted += deleted 57 | } 58 | 59 | if totalDeleted > 0 { 60 | logger.Info("Cleaned up expired entries", "total", totalDeleted) 61 | 62 | // Run VACUUM if significant deletions (once daily) 63 | if totalDeleted > 1000 { 64 | go func() { 65 | if err := s.vacuum(); err != nil { 66 | logger.Error("Failed to vacuum database", "error", err) 67 | } 68 | }() 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // vacuum reclaims space 76 | func (s *SQLiteDB) vacuum() error { 77 | _, err := s.db.Exec("VACUUM") 78 | if err != nil { 79 | return err 80 | } 81 | logger.Info("Database vacuum completed") 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /text/prompts/sd.md: -------------------------------------------------------------------------------- 1 | You are a Stable Diffusion 1.5 prompt generator. Convert any user input into comma-separated keywords for image generation. 2 | 3 | Rules: 4 | - Only output keywords separated by commas 5 | - Always add a single space after each comma 6 | - Each keyword MUST be 1-3 words maximum 7 | - Never combine multiple concepts into one keyword 8 | - Never use underscores or hyphens 9 | - Never use natural language or sentences 10 | - Never include explanations 11 | - Always end with quality boosting keywords 12 | - Keep keywords simple and direct 13 | 14 | Example Input: "A beautiful woman in a garden" 15 | Output: young woman, natural pose, garden setting, soft lighting, floral background, highly detailed, professional photography, masterpiece, best quality, sharp focus, 8k resolution, artstation quality, concept art, trending artstation, beautiful composition, vivid colors, octane render 16 | 17 | Input: "A warrior in battle" 18 | Output: armored warrior, battle pose, war background, dramatic lighting, metallic armor, highly detailed, professional photography, masterpiece, best quality, sharp focus, 8k resolution, artstation quality, concept art, trending artstation, beautiful composition, vivid colors, octane render 19 | 20 | Input: "A portrait in a studio" 21 | Output: elegant portrait, studio pose, neutral background, perfect lighting, clear skin, highly detailed, professional photography, masterpiece, best quality, sharp focus, 8k resolution, artstation quality, concept art, trending artstation, beautiful composition, vivid colors, octane render 22 | 23 | Basic Elements (use 1-3 words only): 24 | - young woman, tall man 25 | - natural pose, action pose 26 | - clear skin, smooth texture 27 | - studio setting, outdoor scene 28 | - soft lighting, dramatic shadows 29 | - neutral background, scenic background 30 | 31 | Artistic Elements (use 1-3 words only): 32 | - oil painting style 33 | - watercolor effect 34 | - digital art 35 | - realistic rendering 36 | - anime influence 37 | - concept art style 38 | 39 | Quality Boosters (use exactly as shown): 40 | - highly detailed 41 | - professional photography 42 | - masterpiece 43 | - best quality 44 | - sharp focus 45 | - 8k resolution 46 | - artstation quality 47 | - concept art 48 | - trending artstation 49 | - beautiful composition 50 | - vivid colors 51 | - octane render 52 | - realistic lighting 53 | - dramatic lighting 54 | - perfect lighting -------------------------------------------------------------------------------- /http/uploaders/birdhole/birdhole.go: -------------------------------------------------------------------------------- 1 | package birdhole 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "strconv" 7 | 8 | "aibird/http/request" 9 | "aibird/image" 10 | "aibird/settings" 11 | ) 12 | 13 | func BirdHole(fileName string, message string, fields []request.Fields, config settings.BirdholeConfig) (string, error) { 14 | fileName = image.ConvertPngToJpg(fileName) 15 | 16 | baseFields := []request.Fields{ 17 | {Key: "urllen", Value: strconv.Itoa(config.UrlLen)}, 18 | {Key: "expiry", Value: strconv.Itoa(config.Expiry)}, 19 | {Key: "description", Value: message}, 20 | } 21 | 22 | // Merge additional fields 23 | allFields := append(baseFields, fields...) 24 | 25 | birdHoleUpload := request.Request{ 26 | Url: config.Host + ":" + config.Port + config.EndPoint, 27 | Method: "POST", 28 | Headers: []request.Headers{ 29 | {Key: "X-Auth-Token", Value: config.Key}, 30 | }, 31 | Fields: allFields, 32 | FileName: fileName, 33 | } 34 | 35 | var response string 36 | err := birdHoleUpload.Call(&response) 37 | if err != nil { 38 | return "", err 39 | } else { 40 | var jsonResponse map[string]string 41 | err = json.Unmarshal([]byte(response), &jsonResponse) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | _ = os.Remove(fileName) 47 | 48 | return jsonResponse["url"], nil 49 | } 50 | } 51 | 52 | // BirdHolePNG uploads a PNG file without converting to JPG, preserving PNG metadata 53 | func BirdHolePNG(fileName string, message string, fields []request.Fields, config settings.BirdholeConfig) (string, error) { 54 | // Skip PNG to JPG conversion for aiscii to preserve metadata 55 | 56 | baseFields := []request.Fields{ 57 | {Key: "urllen", Value: strconv.Itoa(config.UrlLen)}, 58 | {Key: "expiry", Value: strconv.Itoa(config.Expiry)}, 59 | {Key: "description", Value: message}, 60 | } 61 | 62 | // Merge additional fields 63 | allFields := append(baseFields, fields...) 64 | 65 | birdHoleUpload := request.Request{ 66 | Url: config.Host + ":" + config.Port + config.EndPoint, 67 | Method: "POST", 68 | Headers: []request.Headers{ 69 | {Key: "X-Auth-Token", Value: config.Key}, 70 | }, 71 | Fields: allFields, 72 | FileName: fileName, 73 | } 74 | 75 | var response string 76 | err := birdHoleUpload.Call(&response) 77 | if err != nil { 78 | return "", err 79 | } else { 80 | var jsonResponse map[string]string 81 | err = json.Unmarshal([]byte(response), &jsonResponse) 82 | if err != nil { 83 | return "", err 84 | } 85 | 86 | _ = os.Remove(fileName) 87 | 88 | return jsonResponse["url"], nil 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /text/helpers.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func AppendFullStop(message string) string { 12 | if !strings.HasSuffix(message, ".") && !strings.HasSuffix(message, "!") && !strings.HasSuffix(message, "?") { 13 | message += "." 14 | } 15 | 16 | return message 17 | } 18 | 19 | func GetPersonalityFile(personality string) (string, error) { 20 | // Sanitize the personality input to prevent path traversal. 21 | // We only want to allow simple filenames. 22 | cleanPersonality := filepath.Base(personality) 23 | if strings.Contains(cleanPersonality, "/") || strings.Contains(cleanPersonality, "\\") || cleanPersonality == ".." || cleanPersonality == "." { 24 | return "", errors.New("invalid personality name") 25 | } 26 | 27 | // Construct the full path safely. 28 | baseDir, err := filepath.Abs("personalities") 29 | if err != nil { 30 | return "", fmt.Errorf("failed to resolve base directory: %w", err) 31 | } 32 | filePath := filepath.Join(baseDir, cleanPersonality+".txt") 33 | 34 | // Final check to ensure the path is within the personalities directory. 35 | resolvedPath, err := filepath.EvalSymlinks(filePath) 36 | if err != nil { 37 | return "", fmt.Errorf("failed to resolve file path: %w", err) 38 | } 39 | if !strings.HasPrefix(resolvedPath, baseDir) { 40 | return "", errors.New("invalid personality path") 41 | } 42 | 43 | file, err := os.ReadFile(filePath) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | return string(file), nil 49 | } 50 | 51 | func GetPrompt(promptName string) (string, error) { 52 | // Sanitize the prompt name input to prevent path traversal. 53 | cleanPromptName := filepath.Base(promptName) 54 | if strings.Contains(cleanPromptName, "/") || strings.Contains(cleanPromptName, "\\") || cleanPromptName == ".." || cleanPromptName == "." { 55 | return "", errors.New("invalid prompt name") 56 | } 57 | 58 | // Construct the full path safely. 59 | // Prompts are in text/prompts now 60 | baseDir, err := filepath.Abs("text/prompts") 61 | if err != nil { 62 | return "", fmt.Errorf("failed to resolve base directory: %w", err) 63 | } 64 | filePath := filepath.Join(baseDir, cleanPromptName) 65 | 66 | // Final check to ensure the path is within the prompts directory. 67 | resolvedPath, err := filepath.EvalSymlinks(filePath) 68 | if err != nil { 69 | return "", fmt.Errorf("failed to resolve file path: %w", err) 70 | } 71 | if !strings.HasPrefix(resolvedPath, baseDir) { 72 | return "", errors.New("invalid prompt path") 73 | } 74 | 75 | file, err := os.ReadFile(filePath) 76 | if err != nil { 77 | return "", err 78 | } 79 | 80 | return string(file), nil 81 | } 82 | -------------------------------------------------------------------------------- /text/cache.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "aibird/birdbase" 7 | "aibird/logger" 8 | ) 9 | 10 | func AppendChatCache(key, whoIsTalking, message string, contextLimit int) { 11 | // Start with an empty cache 12 | var cache []Message 13 | 14 | // If a cache already exists, get it 15 | cache = GetChatCache(key) 16 | 17 | // Append the new message 18 | newMessage := Message{ 19 | Role: whoIsTalking, 20 | Content: message, 21 | } 22 | cache = append(cache, newMessage) 23 | 24 | // Truncate if the cache is too long 25 | if len(cache) > contextLimit { 26 | cache = cache[1:] // Remove the oldest message 27 | } 28 | 29 | // Convert to birdbase.Message for storage 30 | birdbaseCache := make([]birdbase.Message, len(cache)) 31 | for i, msg := range cache { 32 | birdbaseCache[i] = birdbase.Message{Role: msg.Role, Content: msg.Content} 33 | } 34 | 35 | // Write the updated cache back to the database using specialized method 36 | err := birdbase.PutChatHistory(key, birdbaseCache) 37 | if err != nil { 38 | logger.Error("Failed to put appended chat cache", "key", key, "error", err) 39 | } 40 | } 41 | 42 | func GetChatCache(key string) []Message { 43 | birdbaseMessages, err := birdbase.GetChatHistory(key) 44 | if err != nil { 45 | // Use errors.Is for robust error checking, specifically for the key not found case. 46 | if err != sql.ErrNoRows { 47 | logger.Error("Failed to get chat cache", "key", key, "error", err) 48 | } 49 | return nil 50 | } 51 | 52 | // Convert from birdbase.Message to text.Message 53 | messages := make([]Message, len(birdbaseMessages)) 54 | for i, msg := range birdbaseMessages { 55 | messages[i] = Message{Role: msg.Role, Content: msg.Content} 56 | } 57 | 58 | return messages 59 | } 60 | 61 | func DeleteChatCache(key string) bool { 62 | err := birdbase.Delete(key) 63 | if err != nil { 64 | logger.Error("Failed to delete chat cache", "key", key, "error", err) 65 | return false 66 | } 67 | 68 | return true 69 | } 70 | 71 | func TruncateLastMessage(key string) { 72 | // Get the existing cache 73 | cache := GetChatCache(key) 74 | if len(cache) == 0 { 75 | return 76 | } 77 | 78 | // Remove the last message 79 | cache = cache[:len(cache)-1] 80 | 81 | // Convert to birdbase.Message for storage 82 | birdbaseCache := make([]birdbase.Message, len(cache)) 83 | for i, msg := range cache { 84 | birdbaseCache[i] = birdbase.Message{Role: msg.Role, Content: msg.Content} 85 | } 86 | 87 | // Write the updated cache back to the database using specialized method 88 | err := birdbase.PutChatHistory(key, birdbaseCache) 89 | if err != nil { 90 | logger.Error("Failed to put truncated chat cache", "key", key, "error", err) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /config.toml.example: -------------------------------------------------------------------------------- 1 | # AI Bird IRC Bot Configuration Example 2 | # Copy this file to config.toml and modify with your settings 3 | 4 | # Main bot configuration 5 | [aibird] 6 | actionTrigger = "!" 7 | floodThreshold = 5 8 | floodIgnoreMinutes = 10 9 | denyCommands = ["secret"] 10 | 11 | # Logging configuration 12 | [logging] 13 | level = "info" 14 | format = "json" 15 | output = "stdout" 16 | 17 | # IRC Networks 18 | [networks.freenode] 19 | enabled = true 20 | nick = "aibird" 21 | user = "aibird" 22 | name = "AI Bird Bot" 23 | pass = "" 24 | nickServPass = "" 25 | version = "AI Bird Bot v1.0" 26 | throttle = 0 27 | pingDelay = 30 28 | denyCommands = ["uptime"] 29 | 30 | # Server configuration for freenode 31 | [[networks.freenode.servers]] 32 | host = "chat.freenode.net" 33 | port = 6667 34 | ssl = false 35 | skipSslVerify = false 36 | 37 | # Channels for freenode 38 | [[networks.freenode.channels]] 39 | name = "#test" 40 | ai = true 41 | sd = true 42 | denyCommands = ["ai", "sd"] 43 | 44 | # Example Libera network 45 | [networks.libera] 46 | enabled = false 47 | nick = "aibird" 48 | user = "aibird" 49 | name = "AI Bird Bot" 50 | pass = "" 51 | nickServPass = "" 52 | version = "AI Bird Bot v1.0" 53 | throttle = 0 54 | pingDelay = 30 55 | 56 | [[networks.libera.servers]] 57 | host = "irc.libera.chat" 58 | port = 6667 59 | ssl = false 60 | skipSslVerify = false 61 | 62 | [[networks.libera.channels]] 63 | name = "#test" 64 | ai = true 65 | sd = true 66 | 67 | # OpenRouter AI Service Configuration 68 | [openrouter] 69 | enabled = true 70 | apiKey = "your-openrouter-api-key-here" 71 | baseUrl = "https://openrouter.ai/api/v1" 72 | defaultModel = "anthropic/claude-3.5-sonnet" 73 | maxTokens = 4096 74 | temperature = 0.7 75 | 76 | # Gemini AI Service Configuration 77 | [gemini] 78 | enabled = false 79 | apiKey = "your-gemini-api-key-here" 80 | defaultModel = "gemini-pro" 81 | maxTokens = 4096 82 | temperature = 0.7 83 | 84 | # Ollama AI Service Configuration 85 | [ollama] 86 | enabled = false 87 | baseUrl = "http://localhost:11434" 88 | defaultModel = "llama3" 89 | maxTokens = 4096 90 | temperature = 0.7 91 | 92 | # ComfyUI Image Generation Configuration 93 | [comfyui] 94 | enabled = true 95 | baseUrl = "http://localhost:8188" 96 | workflowPath = "comfyuijson/" 97 | defaultWorkflow = "default.json" 98 | maxConcurrent = 2 99 | timeout = 300 100 | 101 | # HTTP Upload Configuration 102 | [http] 103 | uploadEnabled = true 104 | uploadUrl = "https://your-upload-service.com/upload" 105 | uploadToken = "your-upload-token-here" 106 | 107 | # BirdHole Upload Service 108 | [birdhole] 109 | enabled = false 110 | baseUrl = "https://birdhole.com" 111 | apiKey = "your-birdhole-api-key-here" -------------------------------------------------------------------------------- /settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/BurntSushi/toml" 9 | "github.com/go-playground/validator/v10" 10 | ) 11 | 12 | // Validate validates the loaded configuration 13 | func (c *Config) Validate() error { 14 | validate := validator.New(validator.WithRequiredStructEnabled()) 15 | return validate.Struct(c) 16 | } 17 | 18 | // LoadConfig loads the configuration from the config.toml file and all service configs. 19 | // It returns a pointer to the Config struct or an error if loading fails. 20 | func LoadConfig() (*Config, error) { 21 | var config Config 22 | configPath := "config.toml" 23 | 24 | // Check if main config file exists 25 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 26 | return nil, fmt.Errorf("config file not found: %s", configPath) 27 | } 28 | 29 | // Get absolute path for better error messages 30 | absPath, err := filepath.Abs(configPath) 31 | if err != nil { 32 | absPath = configPath // fallback to relative path 33 | } 34 | 35 | _, err = toml.DecodeFile(configPath, &config) 36 | if err != nil { 37 | return nil, fmt.Errorf("error parsing config file %s: %w", absPath, err) 38 | } 39 | 40 | // Set default values for channels 41 | for _, network := range config.Networks { 42 | for i := range network.Channels { 43 | // if Video is not set, default to true 44 | if !network.Channels[i].Video { 45 | network.Channels[i].Video = true 46 | } 47 | } 48 | } 49 | 50 | // Load service-specific configs 51 | if err := loadServiceConfigs(&config); err != nil { 52 | return nil, fmt.Errorf("error loading service configs: %w", err) 53 | } 54 | 55 | // Validate the configuration 56 | if err := config.Validate(); err != nil { 57 | return nil, fmt.Errorf("configuration validation failed: %w", err) 58 | } 59 | 60 | return &config, nil 61 | } 62 | 63 | // loadServiceConfigs loads all individual service configuration files 64 | func loadServiceConfigs(config *Config) error { 65 | serviceConfigs := map[string]interface{}{ 66 | "settings/openrouter.toml": &config.OpenRouter, 67 | "settings/gemini.toml": &config.Gemini, 68 | "settings/ollama.toml": &config.Ollama, 69 | "settings/comfyui.toml": &config.ComfyUi, 70 | "settings/birdhole.toml": &config.Birdhole, 71 | "settings/logging.toml": &config.Logging, 72 | } 73 | 74 | for configPath, configStruct := range serviceConfigs { 75 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 76 | // This is not a fatal error, just a warning 77 | continue 78 | } 79 | 80 | _, err := toml.DecodeFile(configPath, configStruct) 81 | if err != nil { 82 | return fmt.Errorf("error parsing service config file %s: %w", configPath, err) 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /image/exifparser/exifparser.go: -------------------------------------------------------------------------------- 1 | package exifparser 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | pngstructure "github.com/dsoprea/go-png-image-structure/v2" 9 | 10 | "aibird/logger" 11 | ) 12 | 13 | // IRCArtData represents the JSON structure containing IRC and ANSI art data 14 | type IRCArtData struct { 15 | IRC string `json:"irc"` 16 | ANSI string `json:"ansi"` 17 | } 18 | 19 | // ExtractIRCArtFromPNG extracts IRC art data from PNG comment chunks. 20 | // It primarily looks for iTXt chunks with "Comment" keyword containing JSON data. 21 | // Returns a slice of IRC art lines or an error if extraction fails. 22 | func ExtractIRCArtFromPNG(filePath string) ([]string, error) { 23 | // Parse PNG structure to access chunks 24 | pmp := pngstructure.NewPngMediaParser() 25 | intfc, err := pmp.ParseFile(filePath) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to parse PNG structure: %w", err) 28 | } 29 | 30 | // Extract IRC art from PNG text chunks (primary method) 31 | cs, ok := intfc.(*pngstructure.ChunkSlice) 32 | if !ok { 33 | return nil, fmt.Errorf("failed to access PNG chunks") 34 | } 35 | 36 | // Search through text chunks for Comment data 37 | for _, chunk := range cs.Chunks() { 38 | if isTextChunk(chunk.Type) { 39 | if lines, err := extractFromTextChunk(chunk); err == nil { 40 | return lines, nil 41 | } 42 | } 43 | } 44 | 45 | return nil, fmt.Errorf("no IRC art found in PNG") 46 | } 47 | 48 | // isTextChunk checks if a chunk type is a text chunk (tEXt, zTXt, or iTXt) 49 | func isTextChunk(chunkType string) bool { 50 | return chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" 51 | } 52 | 53 | // extractFromTextChunk attempts to extract IRC art from a single text chunk 54 | func extractFromTextChunk(chunk *pngstructure.Chunk) ([]string, error) { 55 | textContent := string(chunk.Data) 56 | 57 | // Check for Comment keyword (format: "Comment\0content") 58 | const commentPrefix = "Comment\x00" 59 | if !strings.HasPrefix(textContent, commentPrefix) { 60 | return nil, fmt.Errorf("chunk does not contain Comment keyword") 61 | } 62 | 63 | commentData := textContent[len(commentPrefix):] 64 | return parseIRCArtJSON(commentData) 65 | } 66 | 67 | // parseIRCArtJSON extracts IRC art from JSON-formatted comment data 68 | func parseIRCArtJSON(commentData string) ([]string, error) { 69 | // Find JSON content (starts with '{') 70 | jsonStart := strings.Index(commentData, "{") 71 | if jsonStart < 0 { 72 | return nil, fmt.Errorf("no JSON found in comment data") 73 | } 74 | 75 | jsonContent := commentData[jsonStart:] 76 | 77 | // Parse JSON into IRCArtData structure 78 | var artData IRCArtData 79 | if err := json.Unmarshal([]byte(jsonContent), &artData); err != nil { 80 | return nil, fmt.Errorf("failed to parse IRC art JSON: %w", err) 81 | } 82 | 83 | if artData.IRC == "" { 84 | return nil, fmt.Errorf("IRC art data is empty") 85 | } 86 | 87 | logger.Debug("Successfully extracted IRC art from PNG", "size", len(artData.IRC)) 88 | 89 | // Split IRC art into lines for transmission 90 | return strings.Split(artData.IRC, "\n"), nil 91 | } 92 | -------------------------------------------------------------------------------- /image/helpers.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image/jpeg" 7 | "image/png" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | "strings" 12 | 13 | "aibird/logger" 14 | ) 15 | 16 | const failedConvertPNGToJPG = "Failed to convert PNG to JPG" 17 | 18 | func ToJpeg(imageBytes []byte) ([]byte, error) { 19 | // DetectContentType detects the content type 20 | contentType := http.DetectContentType(imageBytes) 21 | 22 | if contentType == "image/png" { 23 | // Decode the PNG image bytes 24 | img, err := png.Decode(bytes.NewReader(imageBytes)) 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | buf := new(bytes.Buffer) 31 | 32 | // encode the image as a JPEG file 33 | if err := jpeg.Encode(buf, img, nil); err != nil { 34 | return nil, err 35 | } 36 | 37 | return buf.Bytes(), nil 38 | } 39 | 40 | return nil, fmt.Errorf("unable to convert %#v to jpeg", contentType) 41 | } 42 | 43 | func ConvertPngToJpg(fileName string) string { 44 | if strings.HasSuffix(fileName, ".png") { 45 | // Validate file path to prevent path traversal 46 | if strings.Contains(fileName, "..") || strings.Contains(fileName, "/") { 47 | logger.Error("Invalid file path", "file", fileName) 48 | return failedConvertPNGToJPG 49 | } 50 | 51 | imageBytes, err := os.ReadFile(fileName) // #nosec G304 - Path validated above for traversal attempts 52 | 53 | if err != nil { 54 | logger.Error("Failed to read image file", "file", fileName, "error", err) 55 | return failedConvertPNGToJPG 56 | } 57 | 58 | // Convert the PNG image to JPEG 59 | jpegBytes, err := ToJpeg(imageBytes) 60 | 61 | if err != nil { 62 | logger.Error("Failed to convert image", "file", fileName, "error", err) 63 | return failedConvertPNGToJPG 64 | } 65 | 66 | fileName = strings.TrimSuffix(fileName, ".png") + ".jpg" 67 | err = os.WriteFile(fileName, jpegBytes, 0600) // Use restrictive permissions 68 | 69 | if err != nil { 70 | logger.Error("Failed to write JPEG file", "file", fileName, "error", err) 71 | return failedConvertPNGToJPG 72 | } 73 | 74 | _ = os.Remove(strings.TrimSuffix(fileName, ".jpg") + ".png") 75 | } 76 | 77 | return fileName 78 | } 79 | 80 | func ExtractURLs(input string) ([]string, error) { 81 | regex := regexp.MustCompile(`https?://[^\s/$.?#].[^\s]*`) 82 | 83 | // Extract all URLs from input 84 | urls := regex.FindAllString(input, -1) 85 | 86 | return urls, nil 87 | } 88 | 89 | func IsImageURL(rawURL string) bool { 90 | // Validate URL to prevent SSRF attacks 91 | if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { 92 | logger.Error("Invalid URL scheme", "url", rawURL) 93 | return false 94 | } 95 | 96 | resp, err := http.Head(rawURL) // #nosec G107 - URL scheme validated above 97 | if err != nil { 98 | logger.Error("Error checking image URL", "url", rawURL, "error", err) 99 | return false 100 | } 101 | defer resp.Body.Close() 102 | 103 | contentType := resp.Header.Get("Content-Type") 104 | return strings.HasPrefix(contentType, "image/") 105 | } 106 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module aibird 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.6 6 | 7 | require ( 8 | github.com/BurntSushi/toml v1.5.0 9 | github.com/dsoprea/go-png-image-structure/v2 v2.0.0-20210512210324-29b889a6093d 10 | github.com/go-playground/validator/v10 v10.27.0 11 | github.com/google/generative-ai-go v0.20.1 12 | github.com/google/uuid v1.6.0 13 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 14 | github.com/lrstanley/girc v1.1.0 15 | github.com/mattn/go-sqlite3 v1.14.32 16 | github.com/richinsley/comfy2go v0.6.6 17 | github.com/schollz/progressbar/v3 v3.18.0 18 | golang.org/x/crypto v0.41.0 19 | google.golang.org/api v0.248.0 20 | ) 21 | 22 | require ( 23 | cloud.google.com/go v0.121.6 // indirect 24 | cloud.google.com/go/ai v0.12.1 // indirect 25 | cloud.google.com/go/auth v0.16.5 // indirect 26 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 27 | cloud.google.com/go/compute/metadata v0.8.0 // indirect 28 | cloud.google.com/go/longrunning v0.6.7 // indirect 29 | github.com/dsoprea/go-exif/v3 v3.0.1 // indirect 30 | github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect 31 | github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect 32 | github.com/felixge/httpsnoop v1.0.4 // indirect 33 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 34 | github.com/go-errors/errors v1.4.2 // indirect 35 | github.com/go-logr/logr v1.4.3 // indirect 36 | github.com/go-logr/stdr v1.2.2 // indirect 37 | github.com/go-playground/locales v0.14.1 // indirect 38 | github.com/go-playground/universal-translator v0.18.1 // indirect 39 | github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect 40 | github.com/google/s2a-go v0.1.9 // indirect 41 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 42 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect 43 | github.com/gorilla/websocket v1.5.3 // indirect 44 | github.com/leodido/go-urn v1.4.0 // indirect 45 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 46 | github.com/rivo/uniseg v0.4.7 // indirect 47 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 48 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect 49 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 50 | go.opentelemetry.io/otel v1.37.0 // indirect 51 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 52 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 53 | golang.org/x/net v0.43.0 // indirect 54 | golang.org/x/oauth2 v0.30.0 // indirect 55 | golang.org/x/sync v0.16.0 // indirect 56 | golang.org/x/sys v0.35.0 // indirect 57 | golang.org/x/term v0.34.0 // indirect 58 | golang.org/x/text v0.28.0 // indirect 59 | golang.org/x/time v0.12.0 // indirect 60 | google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect 61 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect 62 | google.golang.org/grpc v1.75.0 // indirect 63 | google.golang.org/protobuf v1.36.8 // indirect 64 | gopkg.in/yaml.v2 v2.4.0 // indirect 65 | ) 66 | 67 | replace github.com/richinsley/comfy2go => github.com/dreamfast/comfy2go v0.0.0-20250725024440-ab74d41d54e6 68 | 69 | replace github.com/lrstanley/girc => github.com/birdneststream/girc v0.0.0-20250828073659-2021c99698d2 70 | -------------------------------------------------------------------------------- /settings/types.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "aibird/irc/networks" 5 | "aibird/logger" 6 | ) 7 | 8 | type ( 9 | Config struct { 10 | Networks map[string]networks.Network `toml:"networks" validate:"required,min=1"` 11 | AiBird AiBird `toml:"aibird" validate:"required"` 12 | OpenRouter OpenRouterConfig `toml:"openrouter" validate:"required"` 13 | Gemini GeminiConfig `toml:"gemini"` 14 | Ollama OllamaConfig `toml:"ollama" validate:"required"` 15 | ComfyUi ComfyUiConfig `toml:"comfyui" validate:"required"` 16 | Birdhole BirdholeConfig `toml:"birdhole" validate:"required"` 17 | Logging logger.Config `toml:"logging" validate:"required"` 18 | } 19 | 20 | AiBird struct { 21 | AsciiRecordingUrl string `toml:"recordingUrl" validate:"omitempty,url"` 22 | FloodThreshold int `toml:"floodThreshold" validate:"gte=0"` 23 | FloodIgnoreMinutes int `toml:"floodIgnoreMinutes" validate:"gte=0"` 24 | ActionTrigger string `toml:"actionTrigger" validate:"required"` 25 | DenyCommands []string `toml:"denyCommands"` 26 | AiChatContextLimit int `toml:"aiChatContextLimit" validate:"gte=0"` 27 | Support []Support `toml:"support"` 28 | StatusUrl string `toml:"statusUrl" validate:"omitempty,url"` 29 | StatusApiKey string `toml:"statusApiKey"` 30 | Proxy Proxy `toml:"proxy"` 31 | KickRetryDelay int `toml:"kickRetryDelay" validate:"gte=0"` 32 | } 33 | 34 | Support struct { 35 | Name string `toml:"name" validate:"required"` 36 | Value string `toml:"value" validate:"required"` 37 | } 38 | 39 | Proxy struct { 40 | User string `toml:"user"` 41 | Pass string `toml:"pass"` 42 | Host string `toml:"host"` 43 | Port string `toml:"port"` 44 | } 45 | 46 | OpenRouterConfig struct { 47 | Url string `toml:"url" validate:"required,url"` 48 | ApiKey string `toml:"apiKey" validate:"required"` 49 | DefaultModel string `toml:"defaultModel"` 50 | } 51 | 52 | GeminiConfig struct { 53 | ApiKey string `toml:"apiKey"` 54 | } 55 | 56 | OllamaConfig struct { 57 | Url string `toml:"url" validate:"required,url"` 58 | Port string `toml:"port" validate:"required"` 59 | DefaultModel string `toml:"defaultModel"` 60 | ContextLimit int `toml:"contextLimit" validate:"gte=0"` 61 | } 62 | 63 | ComfyUiConfig struct { 64 | Url string `toml:"url" validate:"required"` 65 | Ports []ComfyUiPort `toml:"ports" validate:"required,min=1,dive"` 66 | BadWords []string `toml:"badWords"` 67 | BadWordsPrompt string `toml:"badWordsPrompt"` 68 | MaxQueueSize int `toml:"maxQueueSize" validate:"gte=0"` 69 | RewritePrompts bool `toml:"rewritePrompts"` 70 | } 71 | 72 | ComfyUiPort struct { 73 | Name string `toml:"name" validate:"required"` 74 | Port int `toml:"port" validate:"required"` 75 | } 76 | 77 | BirdholeConfig struct { 78 | Host string `toml:"host" validate:"required"` 79 | Port string `toml:"port" validate:"required"` 80 | EndPoint string `toml:"endPoint" validate:"required"` 81 | Key string `toml:"key" validate:"required"` 82 | UrlLen int `toml:"urlLen"` 83 | Expiry int `toml:"expiry"` 84 | Description string `toml:"description"` 85 | } 86 | ) 87 | -------------------------------------------------------------------------------- /text/openrouter/openrouter.go: -------------------------------------------------------------------------------- 1 | package openrouter 2 | 3 | // 4 | import ( 5 | "fmt" 6 | "strings" 7 | 8 | "aibird/birdbase" 9 | "aibird/helpers" 10 | "aibird/http/request" 11 | "aibird/irc/state" 12 | "aibird/logger" 13 | "aibird/settings" 14 | "aibird/text" 15 | ) 16 | 17 | // OpenRouterRequest coordinates the entire process of handling an OpenRouter request. 18 | func OpenRouterRequest(irc state.State) (string, error) { 19 | // 1. Handle special commands like "reset" 20 | if didHandle, response := handleResetCommand(irc); didHandle { 21 | return response, nil 22 | } 23 | 24 | // 2. Prepare the data for the request 25 | message := text.AppendFullStop(irc.Message()) 26 | requestBody := buildOpenRouterRequestBody(irc, message) 27 | 28 | // 3. Append the new user message to the cache 29 | text.AppendChatCache(irc.UserAiChatCacheKey(), "user", message, irc.Config.AiBird.AiChatContextLimit) 30 | 31 | // 4. Build and execute the HTTP request 32 | httpRequest := buildHttpRequest(irc.Config.OpenRouter, requestBody) 33 | var response OpenRouterResponse 34 | if err := httpRequest.Call(&response); err != nil { 35 | return "", err 36 | } 37 | 38 | // 5. Process the response and update the cache 39 | return processOpenRouterResponse(irc, &response) 40 | } 41 | 42 | // handleResetCommand checks for and handles the "reset" command. 43 | // It returns true and a message if the command was handled, false otherwise. 44 | func handleResetCommand(irc state.State) (bool, string) { 45 | if irc.Message() != "reset" { 46 | return false, "" 47 | } 48 | if err := birdbase.Delete(irc.UserAiChatCacheKey()); err != nil { 49 | logger.Error("Failed to delete user AI chat cache", "user", irc.User.NickName, "error", err) 50 | } 51 | return true, "Cache reset" 52 | } 53 | 54 | // buildOpenRouterRequestBody creates the request body for the OpenRouter API call. 55 | func buildOpenRouterRequestBody(irc state.State, message string) *OpenRouterRequestBody { 56 | body := &OpenRouterRequestBody{ 57 | Model: irc.Config.OpenRouter.DefaultModel, 58 | Messages: []text.Message{ 59 | {Role: "system", Content: irc.User.GetBasePrompt()}, 60 | }, 61 | } 62 | 63 | if history := text.GetChatCache(irc.UserAiChatCacheKey()); history != nil { 64 | body.Messages = append(body.Messages, history...) 65 | } 66 | 67 | body.Messages = append(body.Messages, text.Message{Role: "user", Content: message}) 68 | return body 69 | } 70 | 71 | // buildHttpRequest constructs the request.Request object for the API call. 72 | func buildHttpRequest(config settings.OpenRouterConfig, payload *OpenRouterRequestBody) request.Request { 73 | return request.Request{ 74 | Url: helpers.AppendSlashUrl(config.Url) + "chat/completions", 75 | Method: "POST", 76 | Headers: []request.Headers{ 77 | {Key: "Content-Type", Value: "application/json"}, 78 | {Key: "Authorization", Value: "Bearer " + config.ApiKey}, 79 | }, 80 | Payload: payload, 81 | } 82 | } 83 | 84 | // processOpenRouterResponse handles the API response, updates the cache, and returns the final message. 85 | func processOpenRouterResponse(irc state.State, response *OpenRouterResponse) (string, error) { 86 | if len(response.Choices) == 0 { 87 | return "", fmt.Errorf("openrouter returned an empty response") 88 | } 89 | 90 | apiResponse := strings.TrimSpace(response.Choices[0].Message.Content) 91 | text.AppendChatCache(irc.UserAiChatCacheKey(), "assistant", apiResponse, irc.Config.AiBird.AiChatContextLimit) 92 | 93 | return apiResponse, nil 94 | } 95 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | var defaultLogger *slog.Logger 12 | var validate *validator.Validate 13 | 14 | // LogLevel represents log levels 15 | type LogLevel string 16 | 17 | const ( 18 | LevelDebug LogLevel = "debug" 19 | LevelInfo LogLevel = "info" 20 | LevelWarn LogLevel = "warn" 21 | LevelError LogLevel = "error" 22 | ) 23 | 24 | // Config holds logger configuration 25 | type Config struct { 26 | Level LogLevel `toml:"level" validate:"required,oneof=debug info warn error"` 27 | Format string `toml:"format" validate:"required,oneof=text json"` // "text" or "json" 28 | } 29 | 30 | // Validate validates the logger configuration 31 | func (c *Config) Validate() error { 32 | validate = validator.New(validator.WithRequiredStructEnabled()) 33 | return validate.Struct(c) 34 | } 35 | 36 | // Init initializes the global logger with the given configuration 37 | func Init(config Config) { 38 | if err := config.Validate(); err != nil { 39 | slog.Error("Invalid logger configuration", "error", err) 40 | } 41 | var level slog.Level 42 | switch config.Level { 43 | case LevelDebug: 44 | level = slog.LevelDebug 45 | case LevelInfo: 46 | level = slog.LevelInfo 47 | case LevelWarn: 48 | level = slog.LevelWarn 49 | case LevelError: 50 | level = slog.LevelError 51 | default: 52 | level = slog.LevelInfo 53 | } 54 | 55 | var handler slog.Handler 56 | opts := &slog.HandlerOptions{ 57 | Level: level, 58 | } 59 | 60 | switch config.Format { 61 | case "json": 62 | handler = slog.NewJSONHandler(os.Stdout, opts) 63 | default: 64 | handler = slog.NewTextHandler(os.Stdout, opts) 65 | } 66 | 67 | defaultLogger = slog.New(handler) 68 | slog.SetDefault(defaultLogger) 69 | } 70 | 71 | // Debug logs at debug level 72 | func Debug(msg string, args ...any) { 73 | defaultLogger.Debug(msg, args...) 74 | } 75 | 76 | // Info logs at info level 77 | func Info(msg string, args ...any) { 78 | defaultLogger.Info(msg, args...) 79 | } 80 | 81 | // Warn logs at warn level 82 | func Warn(msg string, args ...any) { 83 | defaultLogger.Warn(msg, args...) 84 | } 85 | 86 | // Error logs at error level 87 | func Error(msg string, args ...any) { 88 | defaultLogger.Error(msg, args...) 89 | } 90 | 91 | // With returns a logger with additional context 92 | func With(args ...any) *slog.Logger { 93 | return defaultLogger.With(args...) 94 | } 95 | 96 | // WithContext returns a logger with context 97 | func WithContext(ctx context.Context) *slog.Logger { 98 | return defaultLogger.With() 99 | } 100 | 101 | // Fatal logs an error and exits the program 102 | func Fatal(msg string, args ...any) { 103 | defaultLogger.Error(msg, args...) 104 | os.Exit(1) 105 | } 106 | 107 | // Network creates a logger with network context 108 | func Network(name string) *slog.Logger { 109 | return defaultLogger.With("network", name) 110 | } 111 | 112 | // Channel creates a logger with channel context 113 | func Channel(network, channel string) *slog.Logger { 114 | return defaultLogger.With("network", network, "channel", channel) 115 | } 116 | 117 | // User creates a logger with user context 118 | func User(network, channel, nick string) *slog.Logger { 119 | return defaultLogger.With("network", network, "channel", channel, "nick", nick) 120 | } 121 | 122 | // Service creates a logger with service context 123 | func Service(service string) *slog.Logger { 124 | return defaultLogger.With("service", service) 125 | } 126 | -------------------------------------------------------------------------------- /asciistore/manager.go: -------------------------------------------------------------------------------- 1 | package asciistore 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | "sync" 11 | 12 | "aibird/logger" 13 | ) 14 | 15 | var ( 16 | instance *ASCIIStore 17 | once sync.Once 18 | ) 19 | 20 | func GetManager() *ASCIIStore { 21 | once.Do(func() { 22 | instance = NewASCIIStore() 23 | }) 24 | return instance 25 | } 26 | 27 | func (s *ASCIIStore) RecordToService(user, network, channel, recordingUrl string) (string, error) { 28 | art, exists := s.Retrieve(user, network, channel) 29 | if !exists { 30 | return "No ASCII art found. Generate some art with !aiscii first.", fmt.Errorf("no ASCII art found for user %s", user) 31 | } 32 | 33 | if recordingUrl == "" { 34 | return "Recording URL not configured.", fmt.Errorf("recording URL not configured") 35 | } 36 | 37 | filename := s.GenerateFilename(art) 38 | artText := s.FormatArtForRecording(art) 39 | 40 | // Create raw HTTP request to avoid JSON encoding 41 | url := strings.TrimRight(recordingUrl, "/") + "/" + filename 42 | 43 | // Create HTTP request with raw text body 44 | httpReq, err := http.NewRequest("POST", url, bytes.NewReader([]byte(artText))) 45 | if err != nil { 46 | return "Failed to create request", err 47 | } 48 | 49 | httpReq.Header.Set("Content-Type", "text/plain") 50 | 51 | logger.Debug("Recording ASCII art", "url", url, "filename", filename, "user", user) 52 | 53 | // Execute the request 54 | client := &http.Client{} 55 | httpResp, err := client.Do(httpReq) 56 | if err != nil { 57 | logger.Error("Failed to record ASCII art", "error", err, "url", url) 58 | return "Failed to record art :(", err 59 | } 60 | defer httpResp.Body.Close() 61 | 62 | // Read response body 63 | var responseBody []byte 64 | responseBody, err = io.ReadAll(httpResp.Body) 65 | if err != nil { 66 | logger.Error("Failed to read response", "error", err) 67 | return "Failed to read response", err 68 | } 69 | 70 | responseText := string(responseBody) 71 | 72 | // Clear the stored ASCII art after successful recording to prevent duplicates 73 | s.Clear(user, network, channel) 74 | 75 | logger.Info("Successfully recorded ASCII art", "filename", filename, "user", user, "response", responseText) 76 | logger.Debug("Cleared stored ASCII art after recording", "user", user, "network", network, "channel", channel) 77 | return "Art saved to " + responseText, nil 78 | } 79 | 80 | func (s *ASCIIStore) GenerateFilename(art *ASCIIArt) string { 81 | // Sanitize prompt for filename 82 | filename := s.sanitizeForFilename(art.Prompt) 83 | if filename == "" { 84 | filename = "ascii-art" 85 | } 86 | 87 | // Trim filename to 245 bytes (no timestamp) 88 | if len(filename) > 245 { 89 | filename = filename[:245] 90 | } 91 | 92 | return filename 93 | } 94 | 95 | func (s *ASCIIStore) FormatArtForRecording(art *ASCIIArt) string { 96 | var sb strings.Builder 97 | 98 | // The lines are already properly formatted from FormatIRCArtForIRC, just join them 99 | for _, line := range art.Lines { 100 | sb.WriteString(line + "\n") 101 | } 102 | 103 | return sb.String() 104 | } 105 | 106 | func (s *ASCIIStore) sanitizeForFilename(input string) string { 107 | // Convert to lowercase 108 | filename := strings.ToLower(input) 109 | 110 | // Replace spaces with dashes 111 | filename = strings.ReplaceAll(filename, " ", "-") 112 | 113 | // Remove special characters except alphanumeric and dashes 114 | reg := regexp.MustCompile(`[^a-z0-9\-]`) 115 | filename = reg.ReplaceAllString(filename, "") 116 | 117 | // Remove leading/trailing dashes 118 | filename = strings.Trim(filename, "-") 119 | 120 | return filename 121 | } 122 | -------------------------------------------------------------------------------- /irc/commands/video.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "aibird/http/request" 8 | "aibird/http/uploaders/birdhole" 9 | "aibird/image/comfyui" 10 | "aibird/irc/state" 11 | "aibird/logger" 12 | "aibird/text/ollama" 13 | 14 | meta "aibird/shared/meta" 15 | ) 16 | 17 | func ParseAiVideo(irc state.State) bool { 18 | if comfyui.WorkflowExists(irc.Action()) { 19 | var aiEnhancedPrompt string 20 | message := comfyui.CleanPrompt(irc.Message()) 21 | 22 | aiEnhancedPrompt = "" 23 | if irc.GetBoolArg("pe") { 24 | irc.Send("✨ Enhancing prompt with ai! ✨") 25 | aiEnhancedPrompt, _ = ollama.EnhancePrompt(message, irc.Config.Ollama) 26 | } 27 | 28 | if irc.User.CanUse4090() { 29 | irc.Send(fmt.Sprintf("%s: Birdnest pal! Enjoy the 🔥rtx 4090🔥 processing '%s'... please wait.", irc.User.NickName, message)) 30 | } else { 31 | irc.Send(fmt.Sprintf("%s: Queued item '%s' has started processing... please wait.", irc.User.NickName, message)) 32 | } 33 | 34 | response, err := comfyui.Process(irc, aiEnhancedPrompt, meta.GPU4090) 35 | if err != nil { 36 | logger.Error("ComfyUI request failed", "error", err) 37 | irc.SendError(err.Error()) 38 | } else { 39 | 40 | fields := []request.Fields{ 41 | {Key: "panorama", Value: strconv.FormatBool(irc.IsAction("panorama"))}, 42 | {Key: "tags", Value: irc.Action() + "," + irc.Network.NetworkName}, 43 | {Key: "meta_network", Value: irc.Network.NetworkName}, 44 | {Key: "meta_channel", Value: irc.Channel.Name}, 45 | {Key: "meta_user", Value: irc.User.NickName}, 46 | {Key: "meta_ident", Value: irc.User.Ident}, 47 | {Key: "meta_host", Value: irc.User.Host}, 48 | } 49 | 50 | if aiEnhancedPrompt != "" { 51 | fields = append(fields, request.Fields{Key: "message", Value: aiEnhancedPrompt}) 52 | } 53 | 54 | upload, err := birdhole.BirdHole(response, message, fields, irc.Config.Birdhole) 55 | 56 | if err != nil { 57 | logger.Error("Birdhole error", "error", err) 58 | irc.SendError(err.Error()) 59 | } else { 60 | irc.ReplyTo(upload + " - " + irc.GetActionTrigger() + irc.Action() + " " + message) 61 | 62 | return true 63 | } 64 | } 65 | } 66 | return false 67 | } 68 | 69 | // ParseAiVideoWithGPU handles video commands with explicit GPU selection 70 | func ParseAiVideoWithGPU(irc state.State, gpu meta.GPUType) bool { 71 | if comfyui.WorkflowExists(irc.Action()) { 72 | var aiEnhancedPrompt string 73 | message := comfyui.CleanPrompt(irc.Message()) 74 | 75 | aiEnhancedPrompt = "" 76 | if irc.GetBoolArg("pe") { 77 | irc.Send("✨ Enhancing prompt with ai! ✨") 78 | aiEnhancedPrompt, _ = ollama.EnhancePrompt(message, irc.Config.Ollama) 79 | } 80 | 81 | // Send processing message before starting the actual processing 82 | if irc.User.CanUse4090() { 83 | irc.Send(fmt.Sprintf("%s: Birdnest pal! Enjoy the 🔥rtx 4090🔥 processing '%s'... please wait.", irc.User.NickName, message)) 84 | } else { 85 | irc.Send(fmt.Sprintf("%s: Queued item '%s' has started processing... please wait.", irc.User.NickName, message)) 86 | } 87 | 88 | // Use the provided GPU parameter instead of hardcoded GPU4090 89 | response, err := comfyui.Process(irc, aiEnhancedPrompt, gpu) 90 | if err != nil { 91 | logger.Error("ComfyUI request failed", "error", err) 92 | irc.SendError(err.Error()) 93 | } else { 94 | fields := []request.Fields{ 95 | {Key: "panorama", Value: strconv.FormatBool(irc.IsAction("panorama"))}, 96 | {Key: "tags", Value: irc.Action() + "," + irc.Network.NetworkName}, 97 | {Key: "meta_network", Value: irc.Network.NetworkName}, 98 | {Key: "meta_channel", Value: irc.Channel.Name}, 99 | {Key: "meta_user", Value: irc.User.NickName}, 100 | {Key: "meta_ident", Value: irc.User.Ident}, 101 | {Key: "meta_host", Value: irc.User.Host}, 102 | } 103 | 104 | if aiEnhancedPrompt != "" { 105 | fields = append(fields, request.Fields{Key: "message", Value: aiEnhancedPrompt}) 106 | } 107 | 108 | upload, err := birdhole.BirdHole(response, message, fields, irc.Config.Birdhole) 109 | 110 | if err != nil { 111 | logger.Error("Birdhole error", "error", err) 112 | irc.SendError(err.Error()) 113 | } else { 114 | irc.ReplyTo(upload + " - " + irc.GetActionTrigger() + irc.Action() + " " + message) 115 | return true 116 | } 117 | } 118 | } 119 | 120 | return false 121 | } 122 | -------------------------------------------------------------------------------- /text/gemini/gemini.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | "aibird/irc/state" 9 | "aibird/settings" 10 | "aibird/text" 11 | 12 | "github.com/google/generative-ai-go/genai" 13 | "google.golang.org/api/option" 14 | ) 15 | 16 | // newClient creates and returns a new genai.Client 17 | func newClient(ctx context.Context, apiKey string) (*genai.Client, error) { 18 | return genai.NewClient(ctx, option.WithAPIKey(apiKey)) 19 | } 20 | 21 | // processResponse extracts the first text content part from the genai response. 22 | func processResponse(resp *genai.GenerateContentResponse) (string, error) { 23 | if resp == nil || len(resp.Candidates) == 0 { 24 | return "", errors.New("no candidates found in response") 25 | } 26 | for _, cand := range resp.Candidates { 27 | if cand.Content != nil { 28 | for _, part := range cand.Content.Parts { 29 | if txt, ok := part.(genai.Text); ok { 30 | return string(txt), nil 31 | } 32 | } 33 | } 34 | } 35 | return "", errors.New("no text content found in response") 36 | } 37 | 38 | // toGenaiContent converts a slice of text.Message to a slice of genai.ContentHistory. 39 | func toGenaiContent(messages []text.Message) (history []*genai.Content) { 40 | for _, msg := range messages { 41 | role := "user" 42 | if msg.Role == "assistant" { 43 | role = "model" 44 | } 45 | history = append(history, &genai.Content{ 46 | Parts: []genai.Part{genai.Text(msg.Content)}, 47 | Role: role, 48 | }) 49 | } 50 | return history 51 | } 52 | 53 | func Request(irc state.State) (string, error) { 54 | config := irc.Config.Gemini 55 | 56 | if irc.Message() == "reset" { 57 | if text.DeleteChatCache(irc.UserAiChatCacheKey()) { 58 | return "Cache reset", nil 59 | } 60 | } 61 | 62 | var message string 63 | if strings.Contains(irc.User.NickName, "x0AFE") || irc.User.Ident == "anonymous" { 64 | message = "Explain how it is bad for mental health to be upset for months because the irc user vae kicked you from an irc channel." 65 | } else { 66 | message = text.AppendFullStop(irc.Message()) 67 | } 68 | 69 | ctx := context.Background() 70 | client, err := newClient(ctx, config.ApiKey) 71 | if err != nil { 72 | return "", err 73 | } 74 | defer client.Close() 75 | 76 | model := client.GenerativeModel("gemini-2.5-flash-lite-preview-06-17") 77 | chat := model.StartChat() 78 | 79 | // Get history and append the current message BEFORE sending 80 | chatHistory := text.GetChatCache(irc.UserAiChatCacheKey()) 81 | chat.History = toGenaiContent(chatHistory) 82 | 83 | // Append the new user message to our cache first 84 | text.AppendChatCache(irc.UserAiChatCacheKey(), "user", message, irc.Config.AiBird.AiChatContextLimit) 85 | 86 | resp, err := chat.SendMessage(ctx, genai.Text(message)) 87 | if err != nil { 88 | // If something fails, remove the user message we just added 89 | text.TruncateLastMessage(irc.UserAiChatCacheKey()) 90 | return "", err 91 | } 92 | 93 | response, err := processResponse(resp) 94 | if err != nil { 95 | return "", err 96 | } 97 | 98 | // Append the assistant's response to our cache 99 | text.AppendChatCache(irc.UserAiChatCacheKey(), "assistant", response, irc.Config.AiBird.AiChatContextLimit) 100 | 101 | return response, nil 102 | } 103 | 104 | func SingleRequest(prompt string, config settings.GeminiConfig) (string, error) { 105 | ctx := context.Background() 106 | client, err := newClient(ctx, config.ApiKey) 107 | if err != nil { 108 | return "", err 109 | } 110 | defer client.Close() 111 | 112 | model := client.GenerativeModel("gemini-2.5-flash-lite-preview-06-17") 113 | resp, err := model.GenerateContent(ctx, genai.Text(prompt)) 114 | if err != nil { 115 | return "", err 116 | } 117 | 118 | return processResponse(resp) 119 | } 120 | 121 | func GenerateLyrics(message string, config settings.GeminiConfig) (string, error) { 122 | ctx := context.Background() 123 | client, err := newClient(ctx, config.ApiKey) 124 | if err != nil { 125 | return "", err 126 | } 127 | defer client.Close() 128 | 129 | model := client.GenerativeModel("gemini-2.5-flash-lite-preview-06-17") 130 | systemPrompt, err := text.GetPrompt("lyrics.md") 131 | if err != nil { 132 | return "", err 133 | } 134 | model.SystemInstruction = &genai.Content{ 135 | Parts: []genai.Part{ 136 | genai.Text(systemPrompt), 137 | }, 138 | } 139 | userPrompt := "Generate lyrics for a song about: " + message 140 | resp, err := model.GenerateContent(ctx, genai.Text(userPrompt)) 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | return processResponse(resp) 146 | } 147 | -------------------------------------------------------------------------------- /birdbase/memory.go: -------------------------------------------------------------------------------- 1 | package birdbase 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // In-memory data structures for hot, short-lived data 9 | var ( 10 | FloodManager *FloodProtection 11 | RateLimiter *RateLimitManager 12 | ) 13 | 14 | // FloodProtection manages flood counters in memory 15 | type FloodProtection struct { 16 | mu sync.RWMutex 17 | counters map[string]*FloodCounter 18 | bans map[string]time.Time 19 | } 20 | 21 | type FloodCounter struct { 22 | Count int 23 | ExpiresAt time.Time 24 | } 25 | 26 | // NewFloodProtection creates a new flood protection manager 27 | func NewFloodProtection() *FloodProtection { 28 | fp := &FloodProtection{ 29 | counters: make(map[string]*FloodCounter), 30 | bans: make(map[string]time.Time), 31 | } 32 | 33 | // Start cleanup goroutine 34 | go fp.cleanupLoop() 35 | 36 | return fp 37 | } 38 | 39 | // IncrementFloodCounter increments flood counter for a key 40 | func (fp *FloodProtection) IncrementFloodCounter(key string, ttl time.Duration) int { 41 | fp.mu.Lock() 42 | defer fp.mu.Unlock() 43 | 44 | now := time.Now() 45 | 46 | counter, exists := fp.counters[key] 47 | if !exists || counter.ExpiresAt.Before(now) { 48 | // Create new counter 49 | fp.counters[key] = &FloodCounter{ 50 | Count: 1, 51 | ExpiresAt: now.Add(ttl), 52 | } 53 | return 1 54 | } 55 | 56 | // Increment existing counter 57 | counter.Count++ 58 | counter.ExpiresAt = now.Add(ttl) // Reset expiration 59 | return counter.Count 60 | } 61 | 62 | // GetFloodCount returns current flood count for a key 63 | func (fp *FloodProtection) GetFloodCount(key string) int { 64 | fp.mu.RLock() 65 | defer fp.mu.RUnlock() 66 | 67 | counter, exists := fp.counters[key] 68 | if !exists || counter.ExpiresAt.Before(time.Now()) { 69 | return 0 70 | } 71 | 72 | return counter.Count 73 | } 74 | 75 | // SetFloodBan sets a flood ban with expiration 76 | func (fp *FloodProtection) SetFloodBan(key string, duration time.Duration) { 77 | fp.mu.Lock() 78 | defer fp.mu.Unlock() 79 | 80 | fp.bans[key] = time.Now().Add(duration) 81 | } 82 | 83 | // IsFloodBanned checks if a key is currently banned 84 | func (fp *FloodProtection) IsFloodBanned(key string) bool { 85 | fp.mu.RLock() 86 | defer fp.mu.RUnlock() 87 | 88 | banExpires, exists := fp.bans[key] 89 | if !exists { 90 | return false 91 | } 92 | 93 | return banExpires.After(time.Now()) 94 | } 95 | 96 | // cleanupLoop removes expired counters and bans 97 | func (fp *FloodProtection) cleanupLoop() { 98 | ticker := time.NewTicker(30 * time.Second) 99 | defer ticker.Stop() 100 | 101 | for range ticker.C { 102 | fp.cleanup() 103 | } 104 | } 105 | 106 | func (fp *FloodProtection) cleanup() { 107 | fp.mu.Lock() 108 | defer fp.mu.Unlock() 109 | 110 | now := time.Now() 111 | 112 | // Clean expired counters 113 | for key, counter := range fp.counters { 114 | if counter.ExpiresAt.Before(now) { 115 | delete(fp.counters, key) 116 | } 117 | } 118 | 119 | // Clean expired bans 120 | for key, banExpires := range fp.bans { 121 | if banExpires.Before(now) { 122 | delete(fp.bans, key) 123 | } 124 | } 125 | } 126 | 127 | // RateLimitManager manages temporary rate limits in memory 128 | type RateLimitManager struct { 129 | mu sync.RWMutex 130 | limits map[string]time.Time 131 | } 132 | 133 | func NewRateLimitManager() *RateLimitManager { 134 | rlm := &RateLimitManager{ 135 | limits: make(map[string]time.Time), 136 | } 137 | 138 | // Start cleanup goroutine 139 | go rlm.cleanupLoop() 140 | 141 | return rlm 142 | } 143 | 144 | // SetRateLimit sets a rate limit with expiration 145 | func (rlm *RateLimitManager) SetRateLimit(key string, duration time.Duration) { 146 | rlm.mu.Lock() 147 | defer rlm.mu.Unlock() 148 | 149 | rlm.limits[key] = time.Now().Add(duration) 150 | } 151 | 152 | // IsRateLimited checks if a key is currently rate limited 153 | func (rlm *RateLimitManager) IsRateLimited(key string) bool { 154 | rlm.mu.RLock() 155 | defer rlm.mu.RUnlock() 156 | 157 | limitExpires, exists := rlm.limits[key] 158 | if !exists { 159 | return false 160 | } 161 | 162 | return limitExpires.After(time.Now()) 163 | } 164 | 165 | func (rlm *RateLimitManager) cleanupLoop() { 166 | ticker := time.NewTicker(5 * time.Minute) 167 | defer ticker.Stop() 168 | 169 | for range ticker.C { 170 | rlm.cleanup() 171 | } 172 | } 173 | 174 | func (rlm *RateLimitManager) cleanup() { 175 | rlm.mu.Lock() 176 | defer rlm.mu.Unlock() 177 | 178 | now := time.Now() 179 | for key, limitExpires := range rlm.limits { 180 | if limitExpires.Before(now) { 181 | delete(rlm.limits, key) 182 | } 183 | } 184 | } 185 | 186 | // Initialize in-memory structures 187 | func InitMemory() { 188 | FloodManager = NewFloodProtection() 189 | RateLimiter = NewRateLimitManager() 190 | } 191 | -------------------------------------------------------------------------------- /image/ircart/colors.go: -------------------------------------------------------------------------------- 1 | package ircart 2 | 3 | import ( 4 | "fmt" 5 | "image/color" 6 | ) 7 | 8 | // IRCColor represents an IRC color with its code and RGB values 9 | type IRCColor struct { 10 | Code int 11 | R, G, B uint8 12 | } 13 | 14 | // IRCColorPalette contains the exact 99 IRC colors used by the ComfyUI node 15 | var IRCColorPalette = []IRCColor{ 16 | {0, 255, 255, 255}, {1, 0, 0, 0}, {2, 0, 0, 127}, {3, 0, 147, 0}, {4, 255, 0, 0}, 17 | {5, 127, 0, 0}, {6, 156, 0, 156}, {7, 252, 127, 0}, {8, 255, 255, 0}, {9, 0, 252, 0}, 18 | {10, 0, 147, 147}, {11, 0, 255, 255}, {12, 0, 0, 252}, {13, 255, 0, 255}, {14, 127, 127, 127}, 19 | {15, 210, 210, 210}, {16, 71, 0, 0}, {17, 71, 33, 0}, {18, 71, 71, 0}, {19, 50, 71, 0}, 20 | {20, 0, 71, 0}, {21, 0, 71, 44}, {22, 0, 71, 71}, {23, 0, 39, 71}, {24, 0, 0, 71}, 21 | {25, 46, 0, 71}, {26, 71, 0, 71}, {27, 71, 0, 42}, {28, 116, 0, 0}, {29, 116, 58, 0}, 22 | {30, 116, 116, 0}, {31, 81, 116, 0}, {32, 0, 116, 0}, {33, 0, 116, 73}, {34, 0, 116, 116}, 23 | {35, 0, 64, 116}, {36, 0, 0, 116}, {37, 75, 0, 116}, {38, 116, 0, 116}, {39, 116, 0, 69}, 24 | {40, 181, 0, 0}, {41, 181, 99, 0}, {42, 181, 181, 0}, {43, 125, 181, 0}, {44, 0, 181, 0}, 25 | {45, 0, 181, 113}, {46, 0, 181, 181}, {47, 0, 99, 181}, {48, 0, 0, 181}, {49, 117, 0, 181}, 26 | {50, 181, 0, 181}, {51, 181, 0, 107}, {52, 255, 0, 0}, {53, 255, 140, 0}, {54, 255, 255, 0}, 27 | {55, 178, 255, 0}, {56, 0, 255, 0}, {57, 0, 255, 160}, {58, 0, 255, 255}, {59, 0, 140, 255}, 28 | {60, 0, 0, 255}, {61, 165, 0, 255}, {62, 255, 0, 255}, {63, 255, 0, 152}, {64, 255, 89, 89}, 29 | {65, 255, 180, 89}, {66, 255, 255, 113}, {67, 207, 255, 96}, {68, 111, 255, 111}, {69, 101, 255, 201}, 30 | {70, 109, 255, 255}, {71, 89, 180, 255}, {72, 89, 89, 255}, {73, 196, 89, 255}, {74, 255, 102, 255}, 31 | {75, 255, 89, 188}, {76, 255, 156, 156}, {77, 255, 211, 156}, {78, 255, 255, 156}, {79, 226, 255, 156}, 32 | {80, 156, 255, 156}, {81, 156, 255, 219}, {82, 156, 255, 255}, {83, 156, 211, 255}, {84, 156, 156, 255}, 33 | {85, 220, 156, 255}, {86, 255, 156, 255}, {87, 255, 148, 211}, {88, 0, 0, 0}, {89, 19, 19, 19}, 34 | {90, 40, 40, 40}, {91, 54, 54, 54}, {92, 77, 77, 77}, {93, 101, 101, 101}, {94, 129, 129, 129}, 35 | {95, 159, 159, 159}, {96, 188, 188, 188}, {97, 226, 226, 226}, {98, 255, 255, 255}, 36 | } 37 | 38 | // rgbToIRCCodeMap creates a direct lookup map from RGB values to IRC color codes 39 | var rgbToIRCCodeMap = make(map[uint32]int) 40 | 41 | // initRGBLookupMap initializes the direct RGB to IRC code mapping 42 | func initRGBLookupMap() { 43 | if len(rgbToIRCCodeMap) > 0 { 44 | return // Already initialized 45 | } 46 | 47 | for _, ircColor := range IRCColorPalette { 48 | // Pack RGB into a uint32 for fast lookup: (R << 16) | (G << 8) | B 49 | rgbKey := uint32(ircColor.R)<<16 | uint32(ircColor.G)<<8 | uint32(ircColor.B) 50 | rgbToIRCCodeMap[rgbKey] = ircColor.Code 51 | } 52 | } 53 | 54 | // FindClosestIRCColor finds IRC color using direct RGB lookup (ComfyUI pre-quantizes to exact colors) 55 | func FindClosestIRCColor(r, g, b uint8) IRCColor { 56 | initRGBLookupMap() 57 | 58 | // Pack RGB into lookup key 59 | rgbKey := uint32(r)<<16 | uint32(g)<<8 | uint32(b) 60 | 61 | // Direct lookup - should always find exact match since ComfyUI quantized to these colors 62 | if colorCode, exists := rgbToIRCCodeMap[rgbKey]; exists { 63 | return IRCColor{Code: colorCode, R: r, G: g, B: b} 64 | } 65 | 66 | // Fallback to closest match (shouldn't be needed with quantized input) 67 | // Use simple RGB distance for speed 68 | minDistance := float64(999999) 69 | var closestColor IRCColor 70 | 71 | for _, ircColor := range IRCColorPalette { 72 | dr := float64(r) - float64(ircColor.R) 73 | dg := float64(g) - float64(ircColor.G) 74 | db := float64(b) - float64(ircColor.B) 75 | distance := dr*dr + dg*dg + db*db // Squared distance for speed 76 | 77 | if distance < minDistance { 78 | minDistance = distance 79 | closestColor = ircColor 80 | } 81 | } 82 | 83 | return closestColor 84 | } 85 | 86 | // GetIRCColorFromRGB directly maps RGB values to IRC color codes 87 | // Used for pre-quantized images from ComfyUI where colors are already exact matches 88 | func GetIRCColorFromRGB(c color.Color) IRCColor { 89 | r, g, b, _ := c.RGBA() 90 | // Convert from 16-bit to 8-bit 91 | r8, g8, b8 := uint8(r>>8), uint8(g>>8), uint8(b>>8) // #nosec G115 - Safe conversion, values are already shifted 92 | 93 | // Direct lookup since ComfyUI outputs exact IRC colors 94 | rgbKey := uint32(r8)<<16 | uint32(g8)<<8 | uint32(b8) 95 | 96 | // Initialize lookup map if needed 97 | initRGBLookupMap() 98 | 99 | if colorCode, exists := rgbToIRCCodeMap[rgbKey]; exists { 100 | return IRCColor{Code: colorCode, R: r8, G: g8, B: b8} 101 | } 102 | 103 | // Fallback to closest color if exact match not found (shouldn't happen with ComfyUI) 104 | return FindClosestIRCColor(r8, g8, b8) 105 | } 106 | 107 | // FormatIRCColor formats an IRC color code for use in IRC messages 108 | // Returns the control character (0x03) followed by the color code 109 | func FormatIRCColor(colorCode int) string { 110 | return fmt.Sprintf("\x03%d", colorCode) 111 | } 112 | -------------------------------------------------------------------------------- /irc/commands/leaderboard.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "aibird/birdbase" 8 | "aibird/irc/state" 9 | "aibird/logger" 10 | ) 11 | 12 | func ParseLeaderboard(irc state.State) { 13 | switch irc.Command.Action { 14 | case "leaderboard": 15 | handleLeaderboard(irc) 16 | } 17 | } 18 | 19 | func handleLeaderboard(irc state.State) { 20 | // Get the argument to determine which leaderboard to show 21 | arg := strings.TrimSpace(irc.Command.Message) 22 | 23 | switch arg { 24 | case "global": 25 | if !irc.User.IsOwnerUser() { 26 | irc.SendError("Only owners can view the global leaderboard") 27 | return 28 | } 29 | handleGlobalLeaderboard(irc) 30 | case "": 31 | // Default: show network-specific leaderboard 32 | handleNetworkLeaderboard(irc) 33 | default: 34 | // Check if it's a specific command leaderboard (owner only) 35 | if !irc.User.IsOwnerUser() { 36 | irc.SendError("Only owners can view command-specific leaderboards") 37 | return 38 | } 39 | handleCommandLeaderboard(irc, arg) 40 | } 41 | } 42 | 43 | func handleNetworkLeaderboard(irc state.State) { 44 | entries, err := birdbase.GetNetworkLeaderboard(irc.Network.NetworkName, 10) 45 | if err != nil { 46 | logger.Error("Failed to get network leaderboard", "error", err, "network", irc.Network.NetworkName) 47 | irc.SendError("Failed to retrieve leaderboard data") 48 | return 49 | } 50 | 51 | if len(entries) == 0 { 52 | irc.Send("📊 No command usage data available for " + irc.Network.NetworkName) 53 | return 54 | } 55 | 56 | // Create table header 57 | response := fmt.Sprintf("📊 Top %d users on %s:", len(entries), irc.Network.NetworkName) 58 | irc.Send(response) 59 | 60 | // Table header 61 | irc.Send("┌────┬─────────────────┬─────────────────┬───────┐") 62 | irc.Send("│ # │ Nickname │ Command │ Count │") 63 | irc.Send("├────┼─────────────────┼─────────────────┼───────┤") 64 | 65 | // Table rows 66 | for i, entry := range entries { 67 | rank := fmt.Sprintf("%2d", i+1) 68 | nickname := fmt.Sprintf("%-15s", truncateString(entry.Nickname, 15)) 69 | command := fmt.Sprintf("%-15s", truncateString(entry.Command, 15)) 70 | count := fmt.Sprintf("%5d", entry.Count) 71 | 72 | row := fmt.Sprintf("│ %s │ %s │ %s │ %s │", rank, nickname, command, count) 73 | irc.Send(row) 74 | } 75 | 76 | // Table footer 77 | irc.Send("└────┴─────────────────┴─────────────────┴───────┘") 78 | } 79 | 80 | func handleGlobalLeaderboard(irc state.State) { 81 | entries, err := birdbase.GetGlobalLeaderboard(10) 82 | if err != nil { 83 | logger.Error("Failed to get global leaderboard", "error", err) 84 | irc.SendError("Failed to retrieve global leaderboard data") 85 | return 86 | } 87 | 88 | if len(entries) == 0 { 89 | irc.Send("📊 No command usage data available globally") 90 | return 91 | } 92 | 93 | // Create table header 94 | response := fmt.Sprintf("🌍 Global Top %d users:", len(entries)) 95 | irc.Send(response) 96 | 97 | // Table header 98 | irc.Send("┌────┬─────────────────┬─────────────────┬─────────────────┬───────┐") 99 | irc.Send("│ # │ Network │ Nickname │ Command │ Count │") 100 | irc.Send("├────┼─────────────────┼─────────────────┼─────────────────┼───────┤") 101 | 102 | // Table rows 103 | for i, entry := range entries { 104 | rank := fmt.Sprintf("%2d", i+1) 105 | network := fmt.Sprintf("%-15s", truncateString(entry.Network, 15)) 106 | nickname := fmt.Sprintf("%-15s", truncateString(entry.Nickname, 15)) 107 | command := fmt.Sprintf("%-15s", truncateString(entry.Command, 15)) 108 | count := fmt.Sprintf("%5d", entry.Count) 109 | 110 | row := fmt.Sprintf("│ %s │ %s │ %s │ %s │ %s │", rank, network, nickname, command, count) 111 | irc.Send(row) 112 | } 113 | 114 | // Table footer 115 | irc.Send("└────┴─────────────────┴─────────────────┴─────────────────┴───────┘") 116 | } 117 | 118 | func handleCommandLeaderboard(irc state.State, command string) { 119 | entries, err := birdbase.GetCommandLeaderboard(command, 10) 120 | if err != nil { 121 | logger.Error("Failed to get command leaderboard", "error", err, "command", command) 122 | irc.SendError("Failed to retrieve command leaderboard data") 123 | return 124 | } 125 | 126 | if len(entries) == 0 { 127 | irc.Send(fmt.Sprintf("📊 No usage data available for command: %s", command)) 128 | return 129 | } 130 | 131 | // Create table header 132 | response := fmt.Sprintf("🎯 Top %d users for command '%s':", len(entries), command) 133 | irc.Send(response) 134 | 135 | // Table header 136 | irc.Send("┌────┬─────────────────┬─────────────────┬───────┐") 137 | irc.Send("│ # │ Network │ Nickname │ Count │") 138 | irc.Send("├────┼─────────────────┼─────────────────┼───────┤") 139 | 140 | // Table rows 141 | for i, entry := range entries { 142 | rank := fmt.Sprintf("%2d", i+1) 143 | network := fmt.Sprintf("%-15s", truncateString(entry.Network, 15)) 144 | nickname := fmt.Sprintf("%-15s", truncateString(entry.Nickname, 15)) 145 | count := fmt.Sprintf("%5d", entry.Count) 146 | 147 | row := fmt.Sprintf("│ %s │ %s │ %s │ %s │", rank, network, nickname, count) 148 | irc.Send(row) 149 | } 150 | 151 | // Table footer 152 | irc.Send("└────┴─────────────────┴─────────────────┴───────┘") 153 | } 154 | 155 | // truncateString truncates a string to the specified length and adds ellipsis if needed 156 | func truncateString(s string, maxLen int) string { 157 | if len(s) <= maxLen { 158 | return s 159 | } 160 | if maxLen <= 3 { 161 | return s[:maxLen] 162 | } 163 | return s[:maxLen-3] + "..." 164 | } 165 | -------------------------------------------------------------------------------- /irc/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | 6 | "aibird/irc/commands/help" 7 | "aibird/settings" 8 | ) 9 | 10 | // GetAllCommands returns a slice of all available command names across all command types 11 | // It can optionally filter by channel capabilities (ai, sd, sound, video) 12 | func GetAllCommands(config settings.AiBird, enableAi, enableSd, enableSound, enableVideo bool, isAdmin, isOwner bool) []string { 13 | var commands []string 14 | 15 | // Add standard commands (always available) 16 | for _, cmd := range help.StandardHelp() { 17 | commands = append(commands, cmd.Name) 18 | } 19 | 20 | // Add image commands if SD is enabled 21 | if enableSd { 22 | for _, cmd := range help.ImageHelp(config) { 23 | commands = append(commands, cmd.Name) 24 | } 25 | } 26 | 27 | // Add video commands if Video is enabled 28 | if enableVideo { 29 | for _, cmd := range help.VideoHelp(config) { 30 | commands = append(commands, cmd.Name) 31 | } 32 | } 33 | 34 | // Add text commands if AI is enabled 35 | if enableAi { 36 | for _, cmd := range help.TextHelp() { 37 | commands = append(commands, cmd.Name) 38 | } 39 | } 40 | 41 | // Add sound commands if sound is enabled 42 | if enableSound { 43 | for _, cmd := range help.SoundHelp(config) { 44 | commands = append(commands, cmd.Name) 45 | } 46 | } 47 | 48 | // Add admin commands if user is admin 49 | if isAdmin { 50 | for _, cmd := range help.AdminHelp() { 51 | commands = append(commands, cmd.Name) 52 | } 53 | } 54 | 55 | // Add owner commands if user is owner 56 | if isOwner { 57 | for _, cmd := range help.OwnerHelp() { 58 | if cmd.Name != "" { // Skip empty command names 59 | commands = append(commands, cmd.Name) 60 | } 61 | } 62 | } 63 | 64 | return commands 65 | } 66 | 67 | // GetAllCommandsUnfiltered returns all commands regardless of channel capabilities 68 | // This is useful for admin purposes or when you don't have channel context 69 | func GetAllCommandsUnfiltered(config settings.AiBird) []string { 70 | return GetAllCommands(config, true, true, true, true, true, true) 71 | } 72 | 73 | // IsValidCommand checks if a command is in the list of valid commands 74 | // This ignores channel settings and returns true if the command exists anywhere 75 | func IsValidCommand(command string, config settings.AiBird) bool { 76 | commands := GetAllCommandsUnfiltered(config) 77 | 78 | for _, cmd := range commands { 79 | if cmd == command { 80 | return true 81 | } 82 | } 83 | 84 | return false 85 | } 86 | 87 | // IsValidCommandForChannel checks if a command is valid for a specific channel with its capabilities 88 | func IsValidCommandForChannel(command string, config settings.AiBird, enableAi, enableSd, enableSound, enableVideo bool, isAdmin, isOwner bool) bool { 89 | commands := GetAllCommands(config, enableAi, enableSd, enableSound, enableVideo, isAdmin, isOwner) 90 | 91 | for _, cmd := range commands { 92 | if cmd == command { 93 | return true 94 | } 95 | } 96 | 97 | return false 98 | } 99 | 100 | // IsStandardCommand checks if a command is in the list of standard commands 101 | func IsStandardCommand(command string) bool { 102 | for _, cmd := range help.StandardHelp() { 103 | if cmd.Name == command { 104 | return true 105 | } 106 | } 107 | return false 108 | } 109 | 110 | // IsAdminCommand checks if a command is in the list of admin commands 111 | func IsAdminCommand(command string) bool { 112 | for _, cmd := range help.AdminHelp() { 113 | if cmd.Name == command { 114 | return true 115 | } 116 | } 117 | return false 118 | } 119 | 120 | // IsOwnerCommand checks if a command is in the list of owner commands 121 | func IsOwnerCommand(command string) bool { 122 | for _, cmd := range help.OwnerHelp() { 123 | if cmd.Name == command { 124 | return true 125 | } 126 | } 127 | return false 128 | } 129 | 130 | // IsSoundCommand checks if a command is in the list of sound commands 131 | func IsSoundCommand(command string, config settings.AiBird) bool { 132 | for _, cmd := range help.SoundHelp(config) { 133 | if cmd.Name == command { 134 | return true 135 | } 136 | } 137 | return false 138 | } 139 | 140 | // IsVideoCommand checks if a command is in the list of video commands 141 | func IsVideoCommand(command string, config settings.AiBird) bool { 142 | for _, cmd := range help.VideoHelp(config) { 143 | if cmd.Name == command { 144 | return true 145 | } 146 | } 147 | return false 148 | } 149 | 150 | // IsTextCommand checks if a command is a text command (from help.TextHelp) 151 | func IsTextCommand(action string) bool { 152 | for _, cmd := range help.TextHelp() { 153 | if strings.EqualFold(action, cmd.Name) { 154 | return true 155 | } 156 | } 157 | return false 158 | } 159 | 160 | // IsImageCommand checks if a command is an image generation command 161 | func IsImageCommand(command string, config settings.AiBird) bool { 162 | for _, cmd := range help.ImageHelp(config) { 163 | if strings.EqualFold(command, cmd.Name) { 164 | return true 165 | } 166 | } 167 | return false 168 | } 169 | 170 | // IsGenerativeCommand checks if a command generates content (for leaderboard tracking) 171 | func IsGenerativeCommand(action string, config settings.AiBird) bool { 172 | // Text generation commands (ai, bard, gemini, etc.) 173 | if IsTextCommand(action) { 174 | return true 175 | } 176 | 177 | // Image generation commands (ComfyUI workflows) 178 | if IsImageCommand(action, config) { 179 | return true 180 | } 181 | 182 | // Sound generation commands (ComfyUI audio workflows) 183 | if IsSoundCommand(action, config) { 184 | return true 185 | } 186 | 187 | // Video generation commands (ComfyUI video workflows) 188 | if IsVideoCommand(action, config) { 189 | return true 190 | } 191 | 192 | return false 193 | } 194 | -------------------------------------------------------------------------------- /irc/commands/standard.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "aibird/http/request" 9 | "aibird/image/comfyui" 10 | "aibird/irc/commands/help" 11 | "aibird/irc/state" 12 | "aibird/logger" 13 | "aibird/queue" 14 | "aibird/status" 15 | 16 | "github.com/lrstanley/girc" 17 | ) 18 | 19 | func ParseStandard(irc state.State) { 20 | // For backward compatibility, call the version with queue 21 | ParseStandardWithQueue(irc, nil) 22 | } 23 | 24 | func formatHelp(prefix string, commands []help.Help) string { 25 | var names []string 26 | for _, cmd := range commands { 27 | names = append(names, "{b}"+cmd.Name+"{b}") 28 | } 29 | return prefix + strings.Join(names, ", ") 30 | } 31 | 32 | func filterHelp(commands []help.Help, irc state.State) []help.Help { 33 | var filtered []help.Help 34 | for _, cmd := range commands { 35 | if !help.IsCommandDenied(cmd.Name, irc) { 36 | filtered = append(filtered, cmd) 37 | } 38 | } 39 | return filtered 40 | } 41 | 42 | func ParseStandardWithQueue(irc state.State, q *queue.DualQueue) { 43 | switch irc.Command.Action { 44 | case "help": 45 | irc.Send("Type --help for more information on a command.") 46 | 47 | irc.Send(girc.Fmt(formatHelp("IRC: ", filterHelp(help.StandardHelp(), irc)))) 48 | 49 | if irc.Channel.Sd { 50 | irc.Send(girc.Fmt(formatHelp("Images: ", filterHelp(help.ImageHelp(irc.Config.AiBird), irc)))) 51 | } 52 | 53 | if irc.Channel.Sound { 54 | irc.Send(girc.Fmt(formatHelp("Audio: ", filterHelp(help.SoundHelp(irc.Config.AiBird), irc)))) 55 | } 56 | 57 | if irc.Channel.Video { 58 | irc.Send(girc.Fmt(formatHelp("Video: ", filterHelp(help.VideoHelp(irc.Config.AiBird), irc)))) 59 | } 60 | 61 | if irc.Channel.Ai { 62 | irc.Send(girc.Fmt(formatHelp("Text: ", filterHelp(help.TextHelp(), irc)))) 63 | } 64 | 65 | // admin commands help 66 | if irc.User.IsAdmin { 67 | irc.Send(girc.Fmt(formatHelp("Admin: ", filterHelp(help.AdminHelp(), irc)))) 68 | } 69 | 70 | // owner commands help 71 | if irc.User.IsOwner { 72 | irc.Send(girc.Fmt(formatHelp("Owner: ", filterHelp(help.OwnerHelp(), irc)))) 73 | } 74 | 75 | return 76 | case "hello": 77 | irc.Send(girc.Fmt("{b}hello{b} {blue}" + irc.User.NickName + "{c}!")) 78 | return 79 | case "seen": 80 | user, _ := irc.Channel.GetUserWithNick(irc.Message()) 81 | if user == nil { 82 | irc.Send("I have not seen this user") 83 | return 84 | } 85 | 86 | if irc.Event.Source.Name == user.NickName { 87 | irc.ReplyTo(girc.Fmt("{b}Hey pal you are seen!{b}")) 88 | return 89 | } 90 | 91 | irc.Send(user.Seen()) 92 | return 93 | case "status": 94 | client := status.NewClient(irc.Config.AiBird) 95 | formattedStatus, err := client.GetFormattedStatus() 96 | if err != nil { 97 | irc.Send(girc.Fmt("❌ Error getting status: " + err.Error())) 98 | return 99 | } 100 | irc.Send(girc.Fmt(formattedStatus)) 101 | if q != nil { 102 | irc.Send(ShowQueueStatus(irc, q)) 103 | } 104 | 105 | case "support": 106 | for _, support := range irc.Config.AiBird.Support { 107 | irc.Send(girc.Fmt("💲 " + support.Name + ": " + support.Value)) 108 | } 109 | irc.Send(girc.Fmt("After you have {b}supported{b} contact an admin to enable your support only features.")) 110 | return 111 | 112 | case "models": 113 | // List all available image generation models/workflows 114 | if irc.Channel.Sd { 115 | irc.Send(girc.Fmt("📸 Available image generation models/workflows: " + comfyui.GetWorkFlows(true))) 116 | } else { 117 | irc.Send(girc.Fmt("❌ Image generation is disabled in this channel.")) 118 | } 119 | return 120 | case "headlies": 121 | ParseHeadlines(irc) 122 | case "ircnews": 123 | ParseIrcNews(irc) 124 | case "play": 125 | ParsePlay(irc) 126 | case "record": 127 | ParseRecordCommand(irc) 128 | case "leaderboard": 129 | ParseLeaderboard(irc) 130 | } 131 | } 132 | 133 | func ParsePlay(irc state.State) { 134 | if irc.Message() == "--help" || irc.Message() == "" { 135 | irc.Send(help.FindHelp(irc)) 136 | return 137 | } 138 | 139 | url := strings.TrimSpace(irc.Message()) 140 | 141 | // Validate URL pattern: must be https://hole.birdnest.live/derived/{id}.png/{id}.txt 142 | urlPattern := regexp.MustCompile(`^https://hole\.birdnest\.live/derived/([a-zA-Z0-9]+)\.png/([a-zA-Z0-9]+)\.txt$`) 143 | matches := urlPattern.FindStringSubmatch(url) 144 | if len(matches) != 3 || matches[1] != matches[2] { 145 | irc.Send(girc.Fmt("❌ Invalid URL. Must be from https://hole.birdnest.live/derived/{id}.png/{id}.txt")) 146 | return 147 | } 148 | 149 | // Create HTTP request to fetch the text file 150 | req := &request.Request{ 151 | Url: url, 152 | Method: "GET", 153 | } 154 | 155 | var content string 156 | err := req.Call(&content) 157 | if err != nil { 158 | logger.Error("Failed to fetch ASCII art", "url", url, "error", err) 159 | irc.Send(girc.Fmt("❌ Failed to fetch ASCII art: " + err.Error())) 160 | return 161 | } 162 | 163 | // Split content into lines and send each line with a small delay 164 | lines := strings.Split(content, "\n") 165 | if len(lines) == 0 { 166 | irc.Send(girc.Fmt("❌ No content found in file")) 167 | return 168 | } 169 | 170 | irc.Send(girc.Fmt("🎭 Playing ASCII art...")) 171 | 172 | // Send each line with a small delay to create scrolling effect 173 | for _, line := range lines { 174 | // Skip empty lines at the beginning/end but preserve internal spacing 175 | if strings.TrimSpace(line) == "" && (line == lines[0] || line == lines[len(lines)-1]) { 176 | continue 177 | } 178 | 179 | // Use SendRawNoSplit to bypass girc's message splitting that breaks ASCII art 180 | ircCommand := fmt.Sprintf("PRIVMSG %s :%s", irc.Channel.Name, line) 181 | err := irc.Client.Cmd.SendRawNoSplit(ircCommand) 182 | if err != nil { 183 | // Fallback to regular SendRaw if SendRawNoSplit fails 184 | irc.Client.Cmd.SendRaw(ircCommand) 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci-lint configuration file 2 | # Documentation: https://golangci-lint.run/usage/configuration/ 3 | 4 | run: 5 | timeout: 5m 6 | issues-exit-code: 1 7 | tests: true 8 | 9 | output: 10 | formats: 11 | - format: colored-line-number 12 | path: stdout 13 | 14 | issues: 15 | exclude-dirs: 16 | - vendor 17 | - bin 18 | - coverage 19 | uniq-by-line: true 20 | exclude-use-default: false 21 | max-issues-per-linter: 0 22 | max-same-issues: 0 23 | new-from-rev: "" # Show all issues, not just new ones 24 | exclude-rules: 25 | # Exclude certain linters from certain files 26 | - path: _test\.go 27 | linters: 28 | - gosec 29 | - goconst 30 | - gocyclo 31 | 32 | # Ignore complexity in main.go (it's often the entry point) 33 | - path: main\.go 34 | linters: 35 | - gocyclo 36 | - gocritic 37 | 38 | # Allow long functions in command files (they often have many cases) 39 | - path: irc/commands/ 40 | linters: 41 | - gocyclo 42 | - gocritic 43 | 44 | # Common false positives 45 | - text: "Error return value of.*is not checked" 46 | linters: 47 | - errcheck 48 | - text: "exported.*should have comment.*or be unexported" 49 | linters: 50 | - revive 51 | - text: "ST1003:" 52 | linters: 53 | - stylecheck 54 | 55 | linters-settings: 56 | revive: 57 | min-confidence: 0 58 | rules: 59 | - name: exported 60 | disabled: true # Too noisy for now 61 | - name: package-comments 62 | disabled: true # Not enforcing package comments 63 | 64 | staticcheck: 65 | checks: ["all"] 66 | 67 | gosec: 68 | severity: medium 69 | confidence: medium 70 | excludes: 71 | - G204 # Subprocess launched with variable (common in CLI apps) 72 | - G104 # Audit errors not checked (too noisy) 73 | 74 | misspell: 75 | locale: US 76 | ignore-words: 77 | - aibird 78 | - comfyui 79 | - birdbase 80 | - birdhole 81 | 82 | gofmt: 83 | simplify: true 84 | 85 | goimports: 86 | local-prefixes: aibird 87 | 88 | unused: 89 | check-exported: false 90 | 91 | govet: 92 | enable-all: true 93 | disable: 94 | - fieldalignment # Too strict for this project 95 | settings: 96 | printf: 97 | funcs: 98 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 99 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 100 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 101 | 102 | 103 | gocritic: 104 | enabled-tags: 105 | - diagnostic 106 | - experimental 107 | - opinionated 108 | - performance 109 | - style 110 | disabled-checks: 111 | - dupImport # https://github.com/go-critic/go-critic/issues/845 112 | - ifElseChain 113 | - octalLiteral 114 | - whyNoLint 115 | 116 | gocyclo: 117 | min-complexity: 15 118 | 119 | errcheck: 120 | check-type-assertions: true 121 | check-blank: true 122 | 123 | goconst: 124 | min-len: 2 125 | min-occurrences: 3 126 | 127 | funlen: 128 | lines: 100 129 | statements: 50 130 | 131 | nestif: 132 | min-complexity: 4 133 | 134 | linters: 135 | enable: 136 | # Essential linters (used in 'make lint') 137 | - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go 138 | - govet # Vet examines Go source code and reports suspicious constructs 139 | - ineffassign # Detects when assignments to existing variables are not used 140 | - misspell # Finds commonly misspelled English words in comments 141 | - gofmt # Gofmt checks whether code was gofmt-ed 142 | - goimports # In addition to fixing imports, goimports also formats your code in the same style as gofmt 143 | - gosec # Inspects source code for security problems 144 | - staticcheck # It's a set of rules from staticcheck 145 | - unused # Checks Go code for unused constants, variables, functions and types 146 | - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code 147 | 148 | # Additional useful linters 149 | - errcheck # Errcheck is a program for checking for unchecked errors in go programs 150 | - goconst # Finds repeated strings that could be replaced by a constant 151 | - gocritic # The most opinionated Go source code linter 152 | - gocyclo # Computes and checks the cyclomatic complexity of functions 153 | - bodyclose # Checks whether HTTP response body is closed successfully 154 | - rowserrcheck # Checks whether Err of rows is checked successfully 155 | - sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed 156 | 157 | disable: 158 | # Disabled because they're too strict/noisy for this project 159 | - gochecknoglobals # Checks that no global variables exist 160 | - gochecknoinits # Checks that no init functions are present 161 | - exhaustruct # Checks if all structure fields are initialized 162 | - varnamelen # Checks that the length of a variable's name matches its scope 163 | - wsl # Whitespace Linter 164 | - nlreturn # Requires a newline before return 165 | - lll # Reports long lines 166 | - gofumpt # Gofumpt checks whether code was gofumpt-ed (stricter than gofmt) 167 | - gci # Gci control golang package import order and make it always deterministic 168 | - wrapcheck # Checks that errors returned from external packages are wrapped 169 | - dupl # Tool for code clone detection 170 | - cyclop # Checks function and package cyclomatic complexity 171 | - funlen # Tool for detection of long functions 172 | - maintidx # Maintainability index of each function 173 | - nestif # Reports deeply nested if statements 174 | - gocognit # Computes and checks the cognitive complexity of functions 175 | 176 | -------------------------------------------------------------------------------- /helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "regexp" 7 | "strings" 8 | "time" 9 | "unicode" 10 | 11 | "github.com/hako/durafmt" 12 | "github.com/lrstanley/girc" 13 | ) 14 | 15 | var ( 16 | modeMapData = map[int32]string{ 17 | 'o': "@", 18 | 'v': "+", 19 | 'h': "%", 20 | 'a': "&", 21 | 'q': "~", 22 | } 23 | reverseModeMapData = make(map[string]string) 24 | ) 25 | 26 | var markdownReplacements = [][2]string{ 27 | // Must be processed first 28 | {"(?m)^(#+)\\s+(.*)$", "{b}$2{b}"}, // Headers: # text 29 | {"(?m)^>\\s+(.*)$", "{green}> $1"}, // Blockquotes: > text 30 | 31 | // Paired formatting 32 | {"\\x60(.*?)\\x60", "{cyan}$1{c}"}, // Inline code: `code` 33 | {"\\*\\*(.*?)\\*\\*", "{b}$1{b}"}, // Bold: **text** 34 | {"__(.*?)__", "{b}$1{b}"}, // Bold: __text__ 35 | {"\\*(.*?)\\*", "{i}$1{i}"}, // Italics: *text* 36 | {"_(.*?)_", "{i}$1{i}"}, // Italics: _text_ 37 | 38 | // Lists - must be processed after paired formatting 39 | {"(?m)^\\s*[\\*\\-]\\s+(.*)$", "- $1"}, // List items: * item or - item 40 | } 41 | 42 | var compiledMarkdownReplacements []*regexp.Regexp 43 | 44 | func init() { 45 | for k, v := range modeMapData { 46 | reverseModeMapData[v] = string(k) 47 | } 48 | 49 | for _, replacement := range markdownReplacements { 50 | compiledMarkdownReplacements = append(compiledMarkdownReplacements, regexp.MustCompile(replacement[0])) 51 | } 52 | } 53 | 54 | func GetIp() (string, error) { 55 | resp, err := http.Get("https://ifconfig.io") 56 | if err != nil { 57 | return "", err 58 | } 59 | defer resp.Body.Close() 60 | 61 | body, err := io.ReadAll(resp.Body) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | return string(body), nil 67 | } 68 | 69 | func AppendSlashUrl(url string) string { 70 | if url == "" { 71 | return "/" 72 | } 73 | if url != "" && url[len(url)-1:] != "/" { 74 | return url + "/" 75 | } 76 | return url 77 | } 78 | 79 | func MakeUrlWithPort(url, port string) string { 80 | return AppendSlashUrl(url + ":" + port) 81 | } 82 | 83 | func WrapText(input string, limit int) string { 84 | var result strings.Builder 85 | lines := strings.Split(input, "\n") 86 | 87 | for i, line := range lines { 88 | if strings.TrimSpace(line) == "" { 89 | result.WriteString(line) 90 | } else { 91 | words := strings.Fields(line) 92 | var currentLine strings.Builder 93 | for _, word := range words { 94 | // If the current line is empty, just add the word. 95 | if currentLine.Len() == 0 { 96 | currentLine.WriteString(word) 97 | } else if currentLine.Len()+len(word)+1 <= limit { 98 | // If the word fits, add a space and the word. 99 | currentLine.WriteString(" ") 100 | currentLine.WriteString(word) 101 | } else { 102 | // If the word does not fit, finalize the current line and start a new one. 103 | result.WriteString(currentLine.String()) 104 | result.WriteString("\n") 105 | currentLine.Reset() 106 | currentLine.WriteString(word) 107 | } 108 | } 109 | result.WriteString(currentLine.String()) 110 | } 111 | 112 | if i < len(lines)-1 { 113 | result.WriteString("\n") 114 | } 115 | } 116 | 117 | return result.String() 118 | } 119 | 120 | func UnixTimeToHumanReadable(timestamp int64) string { 121 | if timestamp == 0 { 122 | return "never" 123 | } 124 | 125 | return durafmt.Parse(time.Second * time.Duration(time.Now().Unix()-timestamp)).String() 126 | } 127 | 128 | // StringToStatusIndicator converts a string to a status indicator string. 129 | func StringToStatusIndicator(s string) string { 130 | if s == "" { 131 | return "[N/A]" // ASCII for empty or not available 132 | } 133 | // Assuming you want to keep the boolean to emoji functionality as well 134 | if s == "true" { 135 | return "[YES]" 136 | } else if s == "false" { 137 | return "[NO]" 138 | } 139 | // Return a default emoji if the string is not empty, true, or false 140 | return "[?]" 141 | } 142 | 143 | func GetModes(modes string) []string { 144 | var foundModes []string 145 | for _, char := range modes { 146 | switch char { 147 | case '@', '+', '~', '&', '%': 148 | foundModes = append(foundModes, string(char)) 149 | } 150 | } 151 | return foundModes 152 | } 153 | 154 | func ModeHas(modes []string, checkMode string) bool { 155 | for _, mode := range modes { 156 | if mode == checkMode { 157 | return true 158 | } 159 | } 160 | return false 161 | } 162 | 163 | func ModeMap(mode int32) string { 164 | if val, ok := modeMapData[mode]; ok { 165 | return val 166 | } 167 | return "" 168 | } 169 | 170 | func ReverseModeMap(mode string) string { 171 | if val, ok := reverseModeMapData[mode]; ok { 172 | return val 173 | } 174 | return "" 175 | } 176 | 177 | func FindChannelNameInEventParams(event girc.Event) string { 178 | for _, param := range event.Params { 179 | if strings.HasPrefix(param, "#") { 180 | return param 181 | } 182 | } 183 | return "" 184 | } 185 | 186 | // MarkdownToIrc converts markdown to irc formatting 187 | func MarkdownToIrc(message string) string { 188 | inCodeBlock := false 189 | var result strings.Builder 190 | lines := strings.Split(message, "\n") 191 | 192 | for i, line := range lines { 193 | if strings.HasPrefix(line, "```") { 194 | inCodeBlock = !inCodeBlock 195 | if inCodeBlock { 196 | result.WriteString("{cyan}[code]{clear}") 197 | } else { 198 | result.WriteString("{cyan}[/code]{clear}") 199 | } 200 | } else if inCodeBlock { 201 | result.WriteString("{green}") 202 | result.WriteString(line) 203 | } else { 204 | processed := line 205 | for i, re := range compiledMarkdownReplacements { 206 | processed = re.ReplaceAllString(processed, markdownReplacements[i][1]) 207 | } 208 | result.WriteString(processed) 209 | } 210 | 211 | if i < len(lines)-1 { 212 | result.WriteString("\n") 213 | } 214 | } 215 | 216 | return girc.Fmt(result.String()) 217 | } 218 | 219 | func CapitaliseFirst(s string) string { 220 | if s == "" { 221 | return "" 222 | } 223 | r := []rune(s) 224 | return string(unicode.ToUpper(r[0])) + string(r[1:]) 225 | } 226 | -------------------------------------------------------------------------------- /irc/commands/admin.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | 6 | "aibird/helpers" 7 | "aibird/irc/channels" 8 | "aibird/irc/state" 9 | "aibird/irc/users" 10 | "aibird/logger" 11 | "aibird/queue" 12 | 13 | meta "aibird/shared/meta" 14 | 15 | "github.com/lrstanley/girc" 16 | ) 17 | 18 | func ParseAdmin(irc state.State) { 19 | // For now, call the original function without queue access 20 | // This will be updated when we have queue access 21 | parseAdminCommands(irc, nil) 22 | } 23 | 24 | func ParseAdminWithQueue(irc state.State, q *queue.DualQueue) { 25 | parseAdminCommands(irc, q) 26 | } 27 | 28 | func parseAdminCommands(irc state.State, q *queue.DualQueue) { 29 | if irc.User.IsAdmin { 30 | switch irc.Command.Action { 31 | 32 | case "debug": 33 | logger.Debug("IRC Client Debug Info", 34 | "channels", irc.Client.Channels(), 35 | "users", irc.Client.Users(), 36 | "network_name", irc.Client.NetworkName(), 37 | "client_string", irc.Client.String()) 38 | 39 | for _, user := range irc.Client.Users() { 40 | logger.Debug("User info", "channels", strings.Join(user.ChannelList, "-"), "nick", user.Nick, "ident", user.Ident, "host", user.Host) 41 | } 42 | for _, channel := range irc.Client.Channels() { 43 | logger.Debug("Channel info", "channel", channel) 44 | } 45 | 46 | return 47 | case "user": 48 | var user *users.User 49 | 50 | // Check if we're dealing with ident@host format 51 | if strings.Contains(irc.Command.Message, "@") { 52 | // Parse ident@host format 53 | parts := strings.SplitN(irc.Command.Message, "@", 2) 54 | ident := parts[0] 55 | host := parts[1] 56 | 57 | // Try with original ident first 58 | user = irc.Network.GetUserWithIdentAndHost(ident, host) 59 | 60 | // If not found and ident doesn't already have ~, try with ~ prepended 61 | if user == nil && !strings.HasPrefix(ident, "~") { 62 | user = irc.Network.GetUserWithIdentAndHost("~"+ident, host) 63 | } 64 | } else { 65 | // Use the standard nick lookup if no @ is found 66 | user = irc.Network.GetUserWithNick(irc.Command.Message) 67 | } 68 | 69 | if user != nil { 70 | if irc.IsEmptyArguments() { 71 | irc.Send(girc.Fmt(user.String())) 72 | return 73 | } 74 | 75 | if user.IsOwnerUser() && !irc.User.IsOwnerUser() { 76 | irc.SendError("Cannot change owner user") 77 | return 78 | } 79 | 80 | irc.UpdateUserBasedOnArgs(user) 81 | } 82 | 83 | return 84 | case "channel": 85 | var channel *channels.Channel 86 | if irc.IsEmptyMessage() { 87 | channel = irc.Network.GetNetworkChannel(helpers.FindChannelNameInEventParams(irc.Event)) 88 | } else { 89 | channel = irc.Network.GetNetworkChannel(irc.Command.Message) 90 | } 91 | 92 | if channel != nil { 93 | if irc.IsEmptyArguments() { 94 | irc.Send(girc.Fmt(channel.String())) 95 | return 96 | } 97 | 98 | irc.UpdateChannelBasedOnArgs() 99 | } 100 | case "network": 101 | if irc.IsEmptyArguments() { 102 | irc.Send(girc.Fmt(irc.Network.String())) 103 | return 104 | } 105 | 106 | irc.UpdateNetworkBasedOnArgs() 107 | return 108 | case "sync": 109 | irc.Send("Syncing channel " + irc.Channel.Name + "...") 110 | irc.Client.Cmd.SendRaw("WHO " + irc.Channel.Name) 111 | // Mode restoration will happen automatically when RPL_ENDOFWHO is received 112 | return 113 | case "op": 114 | irc.Client.Cmd.Mode(irc.Channel.Name, "+o", irc.Command.Message) 115 | return 116 | case "deop": 117 | irc.Client.Cmd.Mode(irc.Channel.Name, "-o", irc.Command.Message) 118 | return 119 | case "voice": 120 | irc.Client.Cmd.Mode(irc.Channel.Name, "+v", irc.Command.Message) 121 | return 122 | case "devoice": 123 | irc.Client.Cmd.Mode(irc.Channel.Name, "-v", irc.Command.Message) 124 | return 125 | case "kick": 126 | irc.Client.Cmd.Kick(irc.Channel.Name, irc.Command.Message, "You have been kicked by "+irc.User.NickName) 127 | return 128 | case "ban": 129 | irc.Client.Cmd.Mode(irc.Channel.Name, "+b", irc.Command.Message) 130 | return 131 | case "unban": 132 | irc.Client.Cmd.Mode(irc.Channel.Name, "-b", irc.Command.Message) 133 | return 134 | case "topic": 135 | irc.Client.Cmd.Topic(irc.Channel.Name, irc.Command.Message) 136 | return 137 | case "join": 138 | irc.Client.Cmd.Join(irc.Command.Message) 139 | return 140 | case "part": 141 | irc.Client.Cmd.Part(irc.Command.Message) 142 | return 143 | case "ignore": 144 | irc.Send("Ignoring " + irc.Command.Message) 145 | user, _ := irc.Channel.GetUserWithNick(irc.Command.Message) 146 | 147 | if user != nil { 148 | user.Ignore() 149 | irc.Network.Save() 150 | } 151 | 152 | return 153 | case "unignore": 154 | irc.Send("Unignoring " + irc.Command.Message) 155 | user, _ := irc.Channel.GetUserWithNick(irc.Command.Message) 156 | 157 | if user != nil { 158 | user.UnIgnore() 159 | irc.Network.Save() 160 | } 161 | 162 | return 163 | case "nick": 164 | irc.Client.Cmd.Nick(irc.Command.Message) 165 | return 166 | case "clearqueue": 167 | if q == nil { 168 | irc.SendError("Queue system not available") 169 | return 170 | } 171 | 172 | targetVal := irc.FindArgument("4090", "all") 173 | target, ok := targetVal.(string) 174 | if !ok { 175 | target = "all" 176 | } 177 | switch target { 178 | case "4090": 179 | irc.Send("🔄 Clearing 4090 queue...") 180 | q.ClearQueue(meta.GPU4090) 181 | irc.Send("✅ 4090 queue cleared") 182 | case "2070": 183 | irc.Send("🔄 Clearing 2070 queue...") 184 | q.ClearQueue(meta.GPU2070) 185 | irc.Send("✅ 2070 queue cleared") 186 | case "all": 187 | irc.Send("🔄 Clearing all queues...") 188 | q.ClearAllQueues() 189 | irc.Send("✅ All queues cleared") 190 | default: 191 | irc.SendError("Invalid target. Use: 4090, 2070, or all") 192 | } 193 | return 194 | case "removecurrent": 195 | if q == nil { 196 | irc.SendError("Queue system not available") 197 | return 198 | } 199 | 200 | irc.Send("🔄 Removing current processing item...") 201 | if q.RemoveCurrentItem() { 202 | irc.Send("✅ Current processing item removed") 203 | } else { 204 | irc.Send("ℹ️ No items currently processing") 205 | } 206 | return 207 | 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /irc/commands/dispatch.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | 6 | "aibird/image/comfyui" 7 | "aibird/irc/commands/help" 8 | "aibird/irc/state" 9 | "aibird/logger" 10 | "aibird/settings" 11 | "aibird/shared/meta" 12 | ) 13 | 14 | // IsQueueableFromHelp checks if a command is queueable using the help system 15 | func IsQueueableFromHelp(action string, config settings.AiBird) bool { 16 | // Check all help categories for the command 17 | allHelp := []help.Help{} 18 | 19 | // Add all help categories 20 | allHelp = append(allHelp, help.StandardHelp()...) 21 | allHelp = append(allHelp, help.ImageHelp(config)...) 22 | allHelp = append(allHelp, help.VideoHelp(config)...) 23 | allHelp = append(allHelp, help.TextHelp()...) 24 | allHelp = append(allHelp, help.SoundHelp(config)...) 25 | allHelp = append(allHelp, help.AdminHelp()...) 26 | allHelp = append(allHelp, help.OwnerHelp()...) 27 | 28 | // Look for the command in the help system 29 | for _, cmd := range allHelp { 30 | if strings.EqualFold(action, cmd.Name) { 31 | logger.Debug("Found command in help system", "action", action, "queueable", cmd.Queueable) 32 | return cmd.Queueable 33 | } 34 | } 35 | 36 | // If not found in help system, check if it's a ComfyUI workflow 37 | // All ComfyUI workflows are assumed to be queueable 38 | workflows := comfyui.GetWorkFlowsSlice() 39 | for _, workflow := range workflows { 40 | if strings.EqualFold(action, workflow) { 41 | logger.Debug("Found ComfyUI workflow", "action", action, "queueable", true) 42 | return true // All ComfyUI workflows are queueable 43 | } 44 | } 45 | 46 | logger.Debug("Command not found in help system or workflows", "action", action, "queueable", false) 47 | return false 48 | } 49 | 50 | // IsQueueableCommand checks if a command should be queued. 51 | func IsQueueableCommand(s state.State) bool { 52 | action := s.Action() 53 | if action == "" { 54 | logger.Debug("IsQueueableCommand: action is empty") 55 | return false 56 | } 57 | 58 | logger.Debug("IsQueueableCommand: checking action", "action", action) 59 | 60 | // Use the help system to determine if command is queueable 61 | config := s.Config.AiBird 62 | isQueueable := IsQueueableFromHelp(action, config) 63 | 64 | logger.Debug("IsQueueableCommand: result", "action", action, "queueable", isQueueable) 65 | return isQueueable 66 | } 67 | 68 | // RunQueueableCommand runs a command that has been taken from the queue. 69 | // It routes to the existing handlers that already have upload functionality. 70 | func RunQueueableCommand(s state.State, gpu meta.GPUType) { 71 | actionLower := strings.ToLower(s.Action()) 72 | 73 | logger.Debug("Routing queue command", "action", s.Action(), "actionLower", actionLower) 74 | 75 | // Route based on the command action to existing handlers 76 | // Prioritize text commands over image commands since they are more specific 77 | switch { 78 | case IsTextCommand(actionLower): 79 | logger.Debug("Command categorized as text", "action", s.Action()) 80 | // Use existing ParseAiText which already has upload functionality 81 | ParseAiText(s) 82 | case isImageCommand(actionLower, s.Config.AiBird): 83 | logger.Debug("Command categorized as image", "action", s.Action()) 84 | // Use existing ParseAiImageWithGPU which accepts GPU parameter 85 | ParseAiImageWithGPU(s, gpu) 86 | case isVideoCommand(actionLower, s.Config.AiBird): 87 | logger.Debug("Command categorized as video", "action", s.Action()) 88 | // Use existing ParseAiVideoWithGPU which accepts GPU parameter 89 | ParseAiVideoWithGPU(s, gpu) 90 | case isSoundCommand(actionLower, s.Config.AiBird): 91 | logger.Debug("Command categorized as sound", "action", s.Action()) 92 | // Use existing ParseAiSoundWithGPU which accepts GPU parameter 93 | ParseAiSoundWithGPU(s, gpu) 94 | default: 95 | logger.Debug("Command categorized as default (image)", "action", s.Action()) 96 | // Fallback for custom workflows - use image handler with GPU 97 | ParseAiImageWithGPU(s, gpu) 98 | } 99 | } 100 | 101 | // Helper functions to categorize commands based on help system 102 | func isImageCommand(action string, config settings.AiBird) bool { 103 | // Get image commands from help system FIRST 104 | imageHelp := help.ImageHelp(config) 105 | for _, cmd := range imageHelp { 106 | if strings.EqualFold(action, cmd.Name) { 107 | return true 108 | } 109 | } 110 | 111 | // Only then check if it's a ComfyUI workflow with image/video type 112 | workflows := comfyui.GetWorkFlowsSlice() 113 | for _, workflow := range workflows { 114 | if strings.EqualFold(action, workflow) { 115 | workflowFile := "comfyuijson/" + workflow + ".json" 116 | meta, err := comfyui.GetAibirdMeta(workflowFile) 117 | if err == nil && meta != nil { 118 | return meta.Type == "image" 119 | } 120 | } 121 | } 122 | return false 123 | } 124 | 125 | func isVideoCommand(action string, config settings.AiBird) bool { 126 | // Get video commands from help system FIRST 127 | videoHelp := help.VideoHelp(config) 128 | for _, cmd := range videoHelp { 129 | if strings.EqualFold(action, cmd.Name) { 130 | return true 131 | } 132 | } 133 | 134 | // Only then check if it's a ComfyUI workflow with image/video type 135 | workflows := comfyui.GetWorkFlowsSlice() 136 | for _, workflow := range workflows { 137 | if strings.EqualFold(action, workflow) { 138 | workflowFile := "comfyuijson/" + workflow + ".json" 139 | meta, err := comfyui.GetAibirdMeta(workflowFile) 140 | if err == nil && meta != nil { 141 | return meta.Type == "video" 142 | } 143 | } 144 | } 145 | return false 146 | } 147 | 148 | func isSoundCommand(action string, config settings.AiBird) bool { 149 | // Get sound commands from help system FIRST 150 | soundHelp := help.SoundHelp(config) 151 | for _, cmd := range soundHelp { 152 | if strings.EqualFold(action, cmd.Name) { 153 | return true 154 | } 155 | } 156 | 157 | // Only then check if it's a ComfyUI workflow with sound type 158 | workflows := comfyui.GetWorkFlowsSlice() 159 | for _, workflow := range workflows { 160 | if strings.EqualFold(action, workflow) { 161 | workflowFile := "comfyuijson/" + workflow + ".json" 162 | meta, err := comfyui.GetAibirdMeta(workflowFile) 163 | if err == nil && meta != nil { 164 | return meta.Type == "sound" 165 | } 166 | } 167 | } 168 | return false 169 | } 170 | -------------------------------------------------------------------------------- /http/request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "mime/multipart" 10 | "net/http" 11 | "net/textproto" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | 17 | "aibird/logger" 18 | ) 19 | 20 | func (r *Request) GetUrl() string { 21 | return r.Url 22 | } 23 | 24 | func (r *Request) GetMethod() string { 25 | return r.Method 26 | } 27 | 28 | func (r *Request) IsPost() bool { 29 | return r.Method == "POST" 30 | } 31 | 32 | func (r *Request) IsGet() bool { 33 | return r.Method == "GET" 34 | } 35 | 36 | func (r *Request) GetHeaders() []Headers { 37 | return r.Headers 38 | } 39 | 40 | func (r *Request) GetPayload() interface{} { 41 | return r.Payload 42 | } 43 | 44 | func (r *Request) AddHeader(key, value string) { 45 | r.Headers = append(r.Headers, Headers{Key: key, Value: value}) 46 | } 47 | 48 | // Add this helper function 49 | func getFileContentType(file *os.File) (string, error) { 50 | // Only the first 512 bytes are used to detect content type 51 | buffer := make([]byte, 512) 52 | _, err := file.Read(buffer) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | // Reset the file pointer to the beginning 58 | _, err = file.Seek(0, 0) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | // Detect content type 64 | contentType := http.DetectContentType(buffer) 65 | 66 | if contentType == "application/octet-stream" { 67 | if strings.HasSuffix(strings.ToLower(file.Name()), ".jpg") || 68 | strings.HasSuffix(strings.ToLower(file.Name()), ".jpeg") { 69 | contentType = "image/jpeg" 70 | } 71 | 72 | if strings.HasSuffix(strings.ToLower(file.Name()), ".png") { 73 | contentType = "image/png" 74 | } 75 | 76 | if strings.HasSuffix(strings.ToLower(file.Name()), ".txt") { 77 | contentType = "text/plain" 78 | } 79 | 80 | if strings.HasSuffix(strings.ToLower(file.Name()), ".webp") { 81 | contentType = "image/webp" 82 | } 83 | 84 | if strings.HasSuffix(strings.ToLower(file.Name()), ".webm") { 85 | contentType = "video/webm" 86 | } 87 | } 88 | 89 | return contentType, nil 90 | } 91 | 92 | func (r *Request) Call(response interface{}) error { //nolint:gocyclo 93 | var jsonData []byte 94 | var err error 95 | var reqBody *bytes.Buffer 96 | 97 | if r.FileName != "" { 98 | reqBody = &bytes.Buffer{} 99 | writer := multipart.NewWriter(reqBody) 100 | file, errFile1 := os.Open(r.FileName) 101 | if errFile1 != nil { 102 | return fmt.Errorf("failed to open file: %w", errFile1) 103 | } 104 | defer file.Close() 105 | 106 | // Get content type for file 107 | contentType, contentErr := getFileContentType(file) 108 | if contentErr != nil { 109 | return fmt.Errorf("failed to get content type: %w", contentErr) 110 | } 111 | 112 | // Create form file with content type 113 | h := make(textproto.MIMEHeader) 114 | h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, filepath.Base(r.FileName))) 115 | h.Set("Content-Type", contentType) 116 | 117 | part1, errFile1 := writer.CreatePart(h) 118 | if errFile1 != nil { 119 | return fmt.Errorf("failed to create form part: %w", errFile1) 120 | } 121 | 122 | bytesWritten, errFile1 := io.Copy(part1, file) 123 | if errFile1 != nil { 124 | return fmt.Errorf("failed to copy file content: %w", errFile1) 125 | } 126 | logger.Debug("Copied bytes to form file", "bytes", bytesWritten) 127 | 128 | // Add form fields 129 | for _, field := range r.Fields { 130 | fieldErr := writer.WriteField(field.Key, field.Value) 131 | if fieldErr != nil { 132 | return fmt.Errorf("failed to write field: %w", fieldErr) 133 | } 134 | } 135 | 136 | err = writer.Close() 137 | if err != nil { 138 | return fmt.Errorf("failed to close writer: %w", err) 139 | } 140 | 141 | // Set the multipart form content type header 142 | r.AddHeader("Content-Type", writer.FormDataContentType()) 143 | 144 | } else if r.IsPost() { 145 | // JSON post request 146 | jsonData, err = json.Marshal(r.GetPayload()) 147 | if err != nil { 148 | return fmt.Errorf("failed to marshal JSON: %w", err) 149 | } 150 | 151 | reqBody = bytes.NewBuffer(jsonData) 152 | } 153 | 154 | if reqBody == nil { 155 | reqBody = &bytes.Buffer{} 156 | } 157 | 158 | req, err := http.NewRequest(r.GetMethod(), r.GetUrl(), reqBody) 159 | if err != nil { 160 | return fmt.Errorf("failed to create new request: %w", err) 161 | } 162 | 163 | for _, header := range r.GetHeaders() { 164 | req.Header.Set(header.Key, header.Value) 165 | } 166 | 167 | client := &http.Client{} 168 | resp, err := client.Do(req) 169 | if err != nil { 170 | return fmt.Errorf("failed to execute request: %w", err) 171 | } 172 | defer resp.Body.Close() 173 | 174 | // Birdhole will return a string and not any JSON 175 | if strPtr, ok := response.(*string); ok { 176 | bodyBytes, bodyErr := io.ReadAll(resp.Body) 177 | if bodyErr != nil { 178 | return fmt.Errorf("failed to read response body: %w", bodyErr) 179 | } 180 | *strPtr = string(bodyBytes) 181 | } else { 182 | // Handle as JSON for everything else 183 | err = json.NewDecoder(resp.Body).Decode(response) 184 | if err != nil { 185 | logger.Error("Failed to decode JSON response", "error", err) 186 | return fmt.Errorf("failed to decode JSON response: %w", err) 187 | } 188 | } 189 | 190 | return nil 191 | } 192 | 193 | func (r *Request) Download() error { 194 | // Get the response bytes from the url 195 | response, err := http.Get(r.Url) 196 | if err != nil { 197 | return err 198 | } 199 | defer response.Body.Close() 200 | 201 | if response.StatusCode != 200 { 202 | return errors.New("received non 200 response code") 203 | } 204 | 205 | // Create empty file 206 | file, err := os.Create(r.FileName) 207 | if err != nil { 208 | return err 209 | } 210 | defer file.Close() 211 | 212 | // Write the bytes to the field 213 | _, err = io.Copy(file, response.Body) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | return nil 219 | } 220 | 221 | func IsImage(url string) bool { 222 | // Validate URL to prevent SSRF attacks 223 | if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { 224 | logger.Error("Invalid URL scheme", "url", url) 225 | return false 226 | } 227 | 228 | resp, err := http.Head(url) // #nosec G107 - URL scheme validated above 229 | if err != nil { 230 | logger.Error("Error checking image URL", "url", url, "error", err) 231 | return false 232 | } 233 | defer resp.Body.Close() 234 | 235 | contentType := resp.Header.Get("Content-Type") 236 | contentLength, _ := strconv.Atoi(resp.Header.Get("Content-Length")) 237 | 238 | // Check if Content-Type header starts with "image/" and Content-Length is less than 5MB 239 | if strings.HasPrefix(contentType, "image/") && contentLength <= 5*1024*1024 { 240 | return true 241 | } 242 | 243 | return false 244 | } 245 | -------------------------------------------------------------------------------- /birdbase/birdbase_test.go: -------------------------------------------------------------------------------- 1 | package birdbase 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestSQLiteOperations(t *testing.T) { 10 | // Create test database 11 | testDB, err := NewSQLiteDB(":memory:") // In-memory for tests 12 | if err != nil { 13 | t.Fatalf("Failed to create test database: %v", err) 14 | } 15 | defer testDB.db.Close() 16 | 17 | // Test basic operations 18 | key := "test-key" 19 | value := []byte("test-value") 20 | 21 | // Test Put/Get 22 | err = testDB.Put(key, value) 23 | if err != nil { 24 | t.Fatalf("Put failed: %v", err) 25 | } 26 | 27 | retrieved, err := testDB.Get(key) 28 | if err != nil { 29 | t.Fatalf("Get failed: %v", err) 30 | } 31 | 32 | if !bytes.Equal(retrieved, value) { 33 | t.Errorf("Value mismatch: got %s, want %s", retrieved, value) 34 | } 35 | 36 | // Test Has 37 | if !testDB.Has(key) { 38 | t.Error("Has should return true for existing key") 39 | } 40 | 41 | // Test Delete 42 | err = testDB.Delete(key) 43 | if err != nil { 44 | t.Fatalf("Delete failed: %v", err) 45 | } 46 | 47 | if testDB.Has(key) { 48 | t.Error("Has should return false after delete") 49 | } 50 | } 51 | 52 | func TestTTLOperations(t *testing.T) { 53 | testDB, err := NewSQLiteDB(":memory:") 54 | if err != nil { 55 | t.Fatalf("Failed to create test database: %v", err) 56 | } 57 | defer testDB.db.Close() 58 | 59 | key := "ttl-key" 60 | value := []byte("ttl-value") 61 | 62 | // Put with 1 second TTL 63 | err = testDB.PutWithTTL(key, value, time.Second) 64 | if err != nil { 65 | t.Fatalf("PutWithTTL failed: %v", err) 66 | } 67 | 68 | // Should exist immediately 69 | if !testDB.Has(key) { 70 | t.Error("Key should exist immediately after insert") 71 | } 72 | 73 | // Wait for expiration 74 | time.Sleep(2 * time.Second) 75 | 76 | // Should be filtered out by expiration check 77 | if testDB.Has(key) { 78 | t.Error("Key should not be accessible after expiration") 79 | } 80 | 81 | // Cleanup should remove expired entries 82 | err = testDB.Cleanup() 83 | if err != nil { 84 | t.Fatalf("Cleanup failed: %v", err) 85 | } 86 | } 87 | 88 | func TestCompatibilityFunctions(t *testing.T) { 89 | // Set global Data variable for testing 90 | var err error 91 | Data, err = NewSQLiteDB(":memory:") 92 | if err != nil { 93 | t.Fatalf("Failed to create test database: %v", err) 94 | } 95 | defer Data.db.Close() 96 | 97 | // Test PutString 98 | err = PutString("string-key", "string-value") 99 | if err != nil { 100 | t.Fatalf("PutString failed: %v", err) 101 | } 102 | 103 | value, err := Get("string-key") 104 | if err != nil { 105 | t.Fatalf("Get failed: %v", err) 106 | } 107 | 108 | if string(value) != "string-value" { 109 | t.Errorf("String value mismatch: got %s, want string-value", value) 110 | } 111 | 112 | // Test PutInt 113 | err = PutInt("int-key", 42) 114 | if err != nil { 115 | t.Fatalf("PutInt failed: %v", err) 116 | } 117 | 118 | value, err = Get("int-key") 119 | if err != nil { 120 | t.Fatalf("Get failed: %v", err) 121 | } 122 | 123 | if string(value) != "42" { 124 | t.Errorf("Int value mismatch: got %s, want 42", value) 125 | } 126 | 127 | // Test TTL functions 128 | err = PutStringExpireSeconds("expire-key", "expire-value", 3600) 129 | if err != nil { 130 | t.Fatalf("PutStringExpireSeconds failed: %v", err) 131 | } 132 | 133 | if !Has("expire-key") { 134 | t.Error("TTL key should exist") 135 | } 136 | } 137 | 138 | func TestChatHistory(t *testing.T) { 139 | testDB, err := NewSQLiteDB(":memory:") 140 | if err != nil { 141 | t.Fatalf("Failed to create test database: %v", err) 142 | } 143 | defer testDB.db.Close() 144 | 145 | key := "chat-test" 146 | messages := []Message{ 147 | {Role: "user", Content: "Hello"}, 148 | {Role: "assistant", Content: "Hi there"}, 149 | } 150 | 151 | // Test PutChatHistory 152 | err = testDB.PutChatHistory(key, messages) 153 | if err != nil { 154 | t.Fatalf("PutChatHistory failed: %v", err) 155 | } 156 | 157 | // Test GetChatHistory 158 | retrieved, err := testDB.GetChatHistory(key) 159 | if err != nil { 160 | t.Fatalf("GetChatHistory failed: %v", err) 161 | } 162 | 163 | if len(retrieved) != 2 { 164 | t.Errorf("Expected 2 messages, got %d", len(retrieved)) 165 | } 166 | 167 | if retrieved[0].Role != "user" || retrieved[0].Content != "Hello" { 168 | t.Errorf("First message mismatch: got %+v", retrieved[0]) 169 | } 170 | } 171 | 172 | func TestUserUsage(t *testing.T) { 173 | testDB, err := NewSQLiteDB(":memory:") 174 | if err != nil { 175 | t.Fatalf("Failed to create test database: %v", err) 176 | } 177 | defer testDB.db.Close() 178 | 179 | ident := "testuser" 180 | host := "example.com" 181 | 182 | // Test IncrementUserUsage 183 | totalUses, shouldNag, err := testDB.IncrementUserUsage(ident, host) 184 | if err != nil { 185 | t.Fatalf("IncrementUserUsage failed: %v", err) 186 | } 187 | 188 | if totalUses != 1 { 189 | t.Errorf("Expected 1 use, got %d", totalUses) 190 | } 191 | 192 | if shouldNag { 193 | t.Error("Should not nag on first use") 194 | } 195 | 196 | // Test multiple increments 197 | for i := 2; i <= 30; i++ { 198 | totalUses, shouldNag, err = testDB.IncrementUserUsage(ident, host) 199 | if err != nil { 200 | t.Fatalf("IncrementUserUsage failed: %v", err) 201 | } 202 | } 203 | 204 | if totalUses != 30 { 205 | t.Errorf("Expected 30 uses, got %d", totalUses) 206 | } 207 | 208 | if !shouldNag { 209 | t.Error("Should nag at 30 uses") 210 | } 211 | 212 | // Test GetUserUsage 213 | usage, err := testDB.GetUserUsage(ident, host) 214 | if err != nil { 215 | t.Fatalf("GetUserUsage failed: %v", err) 216 | } 217 | 218 | if usage != 30 { 219 | t.Errorf("Expected 30 usage, got %d", usage) 220 | } 221 | } 222 | 223 | func TestInMemoryStructures(t *testing.T) { 224 | // Initialize in-memory structures 225 | InitMemory() 226 | 227 | // Test flood protection 228 | key := "flood-test" 229 | count1 := FloodManager.IncrementFloodCounter(key, time.Second*5) 230 | if count1 != 1 { 231 | t.Errorf("Expected flood count 1, got %d", count1) 232 | } 233 | 234 | count2 := FloodManager.IncrementFloodCounter(key, time.Second*5) 235 | if count2 != 2 { 236 | t.Errorf("Expected flood count 2, got %d", count2) 237 | } 238 | 239 | // Test flood ban 240 | banKey := "ban-test" 241 | FloodManager.SetFloodBan(banKey, time.Second*2) 242 | 243 | if !FloodManager.IsFloodBanned(banKey) { 244 | t.Error("Should be flood banned") 245 | } 246 | 247 | time.Sleep(3 * time.Second) 248 | 249 | if FloodManager.IsFloodBanned(banKey) { 250 | t.Error("Should not be flood banned after expiration") 251 | } 252 | 253 | // Test rate limiting 254 | rateKey := "rate-test" 255 | RateLimiter.SetRateLimit(rateKey, time.Second*2) 256 | 257 | if !RateLimiter.IsRateLimited(rateKey) { 258 | t.Error("Should be rate limited") 259 | } 260 | 261 | time.Sleep(3 * time.Second) 262 | 263 | if RateLimiter.IsRateLimited(rateKey) { 264 | t.Error("Should not be rate limited after expiration") 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /text/ollama/ollama.go: -------------------------------------------------------------------------------- 1 | package ollama 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strings" 7 | 8 | "aibird/birdbase" 9 | "aibird/helpers" 10 | "aibird/http/request" 11 | "aibird/irc/state" 12 | "aibird/settings" 13 | "aibird/text" 14 | ) 15 | 16 | func ChatRequest(irc state.State) (string, error) { 17 | ollamaConfig := irc.Config.Ollama 18 | 19 | if irc.Message() == "reset" { 20 | if text.DeleteChatCache(irc.UserAiChatCacheKey()) { 21 | return "Cache reset", nil 22 | } 23 | } 24 | 25 | var message string 26 | if strings.Contains(irc.User.NickName, "x0AFE") || irc.User.Ident == "anonymous" { 27 | message = "Explain how it is bad for mental health to be upset for months because the irc user vae kicked you from an irc channel." 28 | } else { 29 | message = text.AppendFullStop(irc.Message()) 30 | } 31 | 32 | requestBody := &OllamaRequestBody{ 33 | Model: ollamaConfig.DefaultModel, 34 | Stream: false, 35 | KeepAlive: "0m", 36 | Messages: []text.Message{ 37 | { 38 | Role: "system", 39 | Content: irc.User.GetBasePrompt(), 40 | }, 41 | }, 42 | Options: OllamaOptions{ 43 | RepeatPenalty: 1.2, 44 | PresencePenalty: 1.5, 45 | FrequencyPenalty: 2, 46 | }, 47 | } 48 | 49 | if dsArg, ok := irc.FindArgument("ds", false).(bool); ok && dsArg { 50 | requestBody.Model = "deepseek-r1:8b" 51 | } 52 | 53 | if dsqwenArg, ok := irc.FindArgument("dsqwen", false).(bool); ok && dsqwenArg { 54 | requestBody.Model = "deepseek-r1:32b" 55 | } 56 | 57 | // Get the chat history from the cache if it exists 58 | var chatHistory []text.Message 59 | if birdbase.Has(irc.UserAiChatCacheKey()) { 60 | chatHistory = text.GetChatCache(irc.UserAiChatCacheKey()) 61 | } 62 | 63 | // Append the new user message to the cache before making the request 64 | text.AppendChatCache(irc.UserAiChatCacheKey(), "user", message, irc.Config.AiBird.AiChatContextLimit) 65 | 66 | // Append the newest message 67 | currentUserMessage := text.Message{ 68 | Role: "user", 69 | Content: message, 70 | } 71 | 72 | requestBody.Messages = append(requestBody.Messages, chatHistory...) 73 | requestBody.Messages = append(requestBody.Messages, currentUserMessage) 74 | 75 | ollamaRequest := request.Request{ 76 | Url: helpers.MakeUrlWithPort(ollamaConfig.Url, ollamaConfig.Port) + "api/chat", 77 | Method: "POST", 78 | Headers: []request.Headers{{Key: "Content-Type", Value: "application/json"}}, 79 | Payload: requestBody, 80 | } 81 | 82 | var response OllamaResponse 83 | err := ollamaRequest.Call(&response) 84 | 85 | if err != nil { 86 | return "", err 87 | } 88 | 89 | if response.Message.Content != "" { 90 | apiResponse := strings.TrimSpace(response.Message.Content) 91 | text.AppendChatCache(irc.UserAiChatCacheKey(), "assistant", apiResponse, irc.Config.AiBird.AiChatContextLimit) 92 | 93 | return apiResponse, nil 94 | } 95 | 96 | return "", errors.New("no content found") 97 | } 98 | 99 | func EnhancePrompt(message string, config settings.OllamaConfig) (string, error) { 100 | systemPrompt := "your function is to expand out prompts from a simple sentence to a more complex one, including vivid detail and descriptions. Only include the expanded prompt, do not provide any explanations or things like Description:" 101 | userPrompt := "Expand out the following prompt, include details such as camera movements and describe it as a movie scene:" + message 102 | 103 | return SingleRequest(userPrompt, systemPrompt, config) 104 | } 105 | 106 | func GenerateArtFilename(prompt string, config settings.OllamaConfig) (string, error) { 107 | systemPrompt := "Generate a short, creative filename for digital artwork. Rules: 1) Use only letters, numbers, and hyphens 2) Maximum 20 characters 3) Describe the main subject/theme 4) No file extensions 5) Return ONLY the filename, nothing else" 108 | userPrompt := "Create filename for art prompt: " + prompt 109 | 110 | response, err := SingleRequest(userPrompt, systemPrompt, config) 111 | if err != nil { 112 | return "", err 113 | } 114 | 115 | // Remove various thinking/reasoning patterns (case insensitive, multiline) 116 | patterns := []string{ 117 | `(?i).*?`, // tags 118 | `(?i).*?`, // tags 119 | `(?i)\*thinks?\*.*?\*`, // *thinks* ... * 120 | `(?i)\*thinking\*.*?\*`, // *thinking* ... * 121 | `(?i)let me think.*?(?:\n|$)`, // "let me think..." lines 122 | } 123 | 124 | cleaned := response 125 | for _, pattern := range patterns { 126 | re := regexp.MustCompile(`(?s)` + pattern) // (?s) makes . match newlines 127 | cleaned = re.ReplaceAllString(cleaned, "") 128 | } 129 | 130 | // Clean up multiple whitespace and return first non-empty line 131 | lines := strings.Split(strings.TrimSpace(cleaned), "\n") 132 | for _, line := range lines { 133 | line = strings.TrimSpace(line) 134 | if line != "" && len(line) <= 30 { // Reasonable filename length 135 | return line, nil 136 | } 137 | } 138 | 139 | // If nothing good found, return cleaned version truncated 140 | result := strings.TrimSpace(cleaned) 141 | if len(result) > 30 { 142 | result = result[:30] 143 | } 144 | return result, nil 145 | } 146 | 147 | func SdPrompt(message string, config settings.OllamaConfig) (string, error) { 148 | systemPrompt, err := text.GetPrompt("sd.md") 149 | if err != nil { 150 | return "", err 151 | } 152 | userPrompt := "Enhance the following prompt: " + message 153 | 154 | prompt, err := SingleRequest(userPrompt, systemPrompt, config) 155 | if err != nil { 156 | return "", err 157 | } 158 | 159 | // replace , with , in prompt 160 | prompt = strings.ReplaceAll(prompt, ",", ", ") 161 | prompt = strings.ReplaceAll(prompt, "_", " ") 162 | 163 | return prompt, nil 164 | } 165 | 166 | func GenerateLyrics(message string, config settings.OllamaConfig) (string, error) { 167 | systemPrompt, err := text.GetPrompt("lyrics.md") 168 | if err != nil { 169 | return "", err 170 | } 171 | userPrompt := "Generate lyrics for a song about: " + message 172 | 173 | return SingleRequest(userPrompt, systemPrompt, config) 174 | } 175 | 176 | func SingleRequest(message, system string, config settings.OllamaConfig) (string, error) { 177 | requestBody := &OllamaRequestBody{ 178 | Model: "dolphin-llama3:8b", 179 | Stream: false, 180 | KeepAlive: "0m", 181 | Messages: []text.Message{ 182 | { 183 | Role: "system", 184 | Content: system, 185 | }, 186 | }, 187 | } 188 | 189 | // Append the newest message 190 | currentUserMessage := text.Message{ 191 | Role: "user", 192 | Content: message, 193 | } 194 | 195 | requestBody.Messages = append(requestBody.Messages, currentUserMessage) 196 | 197 | ollamaRequest := request.Request{ 198 | Url: helpers.MakeUrlWithPort(config.Url, config.Port) + "api/chat", 199 | Method: "POST", 200 | Headers: []request.Headers{{Key: "Content-Type", Value: "application/json"}}, 201 | Payload: requestBody, 202 | } 203 | 204 | var response OllamaResponse 205 | err := ollamaRequest.Call(&response) 206 | 207 | if err != nil { 208 | return "", err 209 | } 210 | 211 | if response.Message.Content != "" { 212 | apiResponse := strings.TrimSpace(response.Message.Content) 213 | return apiResponse, nil 214 | } 215 | 216 | return "", errors.New("no content found") 217 | } 218 | -------------------------------------------------------------------------------- /image/ircart/converter.go: -------------------------------------------------------------------------------- 1 | package ircart 2 | 3 | import ( 4 | "fmt" 5 | "image/png" 6 | "os" 7 | "strings" 8 | 9 | "aibird/image/exifparser" 10 | "aibird/logger" 11 | ) 12 | 13 | // ExtractOrConvertIRCArt attempts to extract IRC art from PNG EXIF data first, 14 | // then falls back to pixel-based conversion if EXIF data is unavailable 15 | func ExtractOrConvertIRCArt(pngFilePath string, useHalfblocks bool) ([]string, error) { 16 | logger.Debug("Attempting to extract IRC art from EXIF data", "file", pngFilePath) 17 | 18 | // First, try to extract IRC art from EXIF UserComment 19 | ircArtLines, err := exifparser.ExtractIRCArtFromPNG(pngFilePath) 20 | if err == nil && len(ircArtLines) > 0 { 21 | logger.Debug("Successfully extracted IRC art from EXIF", "lines", len(ircArtLines)) 22 | return ircArtLines, nil 23 | } 24 | 25 | // Log the EXIF extraction attempt result 26 | logger.Debug("EXIF extraction failed, falling back to pixel conversion", "error", err) 27 | 28 | // Fall back to pixel-based conversion 29 | return ConvertPNGToIRCArt(pngFilePath, useHalfblocks) 30 | } 31 | 32 | // ConvertPNGToIRCArt converts a PNG file to IRC art format 33 | // Expects a pre-processed image from ComfyUI with perfect 8x15 pixel blocks 34 | // Returns a slice of strings, each representing one line of IRC art 35 | func ConvertPNGToIRCArt(pngFilePath string, useHalfblocks bool) ([]string, error) { //nolint:gocyclo 36 | logger.Debug("Starting PNG to IRC art conversion (ComfyUI pre-processed)", "file", pngFilePath) 37 | 38 | // Open and decode the PNG file 39 | file, err := os.Open(pngFilePath) // #nosec G304 - Internal file path from image generation 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to open PNG file: %w", err) 42 | } 43 | defer file.Close() 44 | 45 | img, err := png.Decode(file) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to decode PNG file: %w", err) 48 | } 49 | 50 | bounds := img.Bounds() 51 | imgWidth := bounds.Max.X - bounds.Min.X 52 | imgHeight := bounds.Max.Y - bounds.Min.Y 53 | 54 | logger.Debug("Image loaded", "width", imgWidth, "height", imgHeight) 55 | 56 | // ComfyUI block dimensions - fixed and perfect 57 | const BLOCK_WIDTH = 8 58 | const BLOCK_HEIGHT = 15 59 | 60 | var ircLines []string 61 | 62 | // Calculate dimensions based on perfect blocks from ComfyUI 63 | blocksX := imgWidth / BLOCK_WIDTH 64 | var blocksY int 65 | if useHalfblocks { 66 | // Half blocks use 7.5 pixel height, so double the vertical resolution 67 | blocksY = imgHeight * 2 / BLOCK_HEIGHT 68 | } else { 69 | blocksY = imgHeight / BLOCK_HEIGHT 70 | } 71 | 72 | logger.Debug("Block calculations", "blocksX", blocksX, "blocksY", blocksY, "blockWidth", BLOCK_WIDTH, "blockHeight", BLOCK_HEIGHT) 73 | 74 | if useHalfblocks { 75 | // Halfblock rendering: each character represents two vertically stacked half blocks 76 | for row := 0; row < blocksY; row += 2 { // Process pairs of half-blocks 77 | var line strings.Builder 78 | var lastFgColor, lastBgColor = -1, -1 79 | 80 | for col := 0; col < blocksX; col++ { 81 | // Sample pixels from the center of each half-block 82 | topX := col*BLOCK_WIDTH + BLOCK_WIDTH/2 83 | topY := row*BLOCK_HEIGHT/2 + BLOCK_HEIGHT/4 // Center of top half-block 84 | 85 | bottomX := col*BLOCK_WIDTH + BLOCK_WIDTH/2 86 | bottomY := (row+1)*BLOCK_HEIGHT/2 + BLOCK_HEIGHT/4 // Center of bottom half-block 87 | 88 | // Get colors from the pre-quantized image (direct lookup) 89 | topIrcColor := GetIRCColorFromRGB(img.At(topX, topY)) 90 | 91 | var bottomIrcColor IRCColor 92 | if row+1 < blocksY { 93 | bottomIrcColor = GetIRCColorFromRGB(img.At(bottomX, bottomY)) 94 | } else { 95 | // If we're at the last odd row, use the same color for bottom 96 | bottomIrcColor = topIrcColor 97 | } 98 | 99 | // Choose character and colors based on similarity 100 | var char string 101 | var fgColor, bgColor int 102 | 103 | if topIrcColor.Code == bottomIrcColor.Code { 104 | // Same color - use space with background color (more efficient) 105 | char = " " 106 | fgColor = -1 // No foreground color 107 | bgColor = topIrcColor.Code 108 | } else { 109 | // Different colors - use half block 110 | char = "▀" // Upper half block 111 | fgColor = topIrcColor.Code // Foreground = top half 112 | bgColor = bottomIrcColor.Code // Background = bottom half 113 | } 114 | 115 | // Add color codes only if they changed (optimized for bytes) 116 | needsUpdate := fgColor != lastFgColor || bgColor != lastBgColor 117 | if needsUpdate { 118 | var actualFg int 119 | if fgColor == -1 { 120 | actualFg = bgColor // Use background color for foreground when no fg specified 121 | } else { 122 | actualFg = fgColor 123 | } 124 | 125 | // Use most compact format while maintaining irssi compatibility 126 | if actualFg < 10 && bgColor < 10 { 127 | // Both single digit - shortest format: \x035,7 (5 bytes) 128 | line.WriteString(fmt.Sprintf("\x03%d,%d", actualFg, bgColor)) 129 | } else if actualFg < 10 { 130 | // Only fg is single digit: \x035,15 (6 bytes) 131 | line.WriteString(fmt.Sprintf("\x03%d,%d", actualFg, bgColor)) 132 | } else if bgColor < 10 { 133 | // Only bg is single digit: \x0315,5 (6 bytes) 134 | line.WriteString(fmt.Sprintf("\x03%d,%d", actualFg, bgColor)) 135 | } else { 136 | // Both double digit: \x0315,20 (7 bytes) 137 | line.WriteString(fmt.Sprintf("\x03%d,%d", actualFg, bgColor)) 138 | } 139 | lastFgColor = fgColor 140 | lastBgColor = bgColor 141 | } 142 | 143 | line.WriteString(char) 144 | } 145 | 146 | ircLines = append(ircLines, line.String()) 147 | } 148 | } else { 149 | // Full block rendering: each character represents one block 150 | for row := 0; row < blocksY; row++ { 151 | var line strings.Builder 152 | var lastColorCode = -1 153 | 154 | for col := 0; col < blocksX; col++ { 155 | // Sample pixel from the center of the block 156 | centerX := col*BLOCK_WIDTH + BLOCK_WIDTH/2 157 | centerY := row*BLOCK_HEIGHT + BLOCK_HEIGHT/2 158 | 159 | // Get color from the pre-quantized image (direct lookup) 160 | ircColor := GetIRCColorFromRGB(img.At(centerX, centerY)) 161 | 162 | // Add background color code only if it changed 163 | if ircColor.Code != lastColorCode { 164 | // Use most compact fg,bg format for irssi compatibility 165 | if ircColor.Code < 10 { 166 | // Single digit - shortest format: \x035,5 (5 bytes) 167 | line.WriteString(fmt.Sprintf("\x03%d,%d", ircColor.Code, ircColor.Code)) 168 | } else { 169 | // Double digit: \x0315,15 (7 bytes) 170 | line.WriteString(fmt.Sprintf("\x03%d,%d", ircColor.Code, ircColor.Code)) 171 | } 172 | lastColorCode = ircColor.Code 173 | } 174 | 175 | line.WriteString(" ") 176 | } 177 | 178 | ircLines = append(ircLines, line.String()) 179 | } 180 | } 181 | 182 | logger.Debug("IRC art conversion completed", "lines", len(ircLines)) 183 | return ircLines, nil 184 | } 185 | 186 | // FormatIRCArtForIRC formats IRC art lines for transmission over IRC 187 | func FormatIRCArtForIRC(ircArtLines []string) []string { 188 | // The lines are already properly formatted, just return them 189 | return ircArtLines 190 | } 191 | -------------------------------------------------------------------------------- /irc/users/user.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "aibird/birdbase" 9 | "aibird/helpers" 10 | "aibird/irc/users/modes" 11 | "aibird/logger" 12 | 13 | "github.com/lrstanley/girc" 14 | ) 15 | 16 | func (u *User) String() string { 17 | return girc.Fmt(fmt.Sprintf("{b}PreservedModes{b}: %s, {b}CurrentModes{b}: %s {b}NickName{b}: %s, {b}Ident{b}: %s, {b}Host{b}: %s, {b}LatestActivity{b}: %s, {b}FirstSeen{b}: %s, {b}IsAdmin{b}: %s, {b}IsOwner{b}: %s, {b}AccessLevel{b}: %d, {b}Ignored{b}: %s", 18 | u.PreservedModes, 19 | u.CurrentModes, 20 | u.NickName, 21 | u.Ident, 22 | u.Host, 23 | helpers.UnixTimeToHumanReadable(u.LatestActivity), 24 | helpers.UnixTimeToHumanReadable(u.FirstSeen)+" ago", 25 | helpers.StringToStatusIndicator(strconv.FormatBool(u.IsAdmin)), 26 | helpers.StringToStatusIndicator(strconv.FormatBool(u.IsOwner)), 27 | u.AccessLevel, 28 | helpers.StringToStatusIndicator(strconv.FormatBool(u.Ignored)))) 29 | } 30 | 31 | func (u *User) Touch(latestChat string) { 32 | u.LatestActivity = time.Now().Unix() 33 | u.LatestChat = latestChat 34 | logger.Debug("User touched", "nickname", u.NickName, "ident", u.Ident, "host", u.Host, "latest_chat", latestChat) 35 | } 36 | 37 | func (u *User) UpdateNick(nick string) { 38 | if u.NickName == nick { 39 | logger.Debug("UpdateNick called but nick unchanged", "current_nick", u.NickName, "new_nick", nick, "ident", u.Ident, "host", u.Host) 40 | return 41 | } 42 | 43 | oldNick := u.NickName 44 | u.NickName = nick 45 | // Update activity timestamp when nick changes 46 | u.LatestActivity = time.Now().Unix() 47 | logger.Debug("Nick updated in User object", "old_nick", oldNick, "new_nick", u.NickName, "ident", u.Ident, "host", u.Host, "latest_activity", u.LatestActivity) 48 | } 49 | 50 | func (u *User) UpdateIdentHost(ident, host string) { 51 | if u.Ident == ident && u.Host == host { 52 | return 53 | } 54 | 55 | u.Ident = ident 56 | u.Host = host 57 | } 58 | 59 | func (u *User) Seen() string { 60 | if u.LatestActivity == 0 { 61 | return "I first saw " + u.NickName + " " + helpers.UnixTimeToHumanReadable(u.FirstSeen) + " ago but have not seen any chats" 62 | } 63 | 64 | return u.NickName + " was last seen " + helpers.UnixTimeToHumanReadable(u.LatestActivity) + " ago" 65 | } 66 | 67 | func (u *User) CanSkipQueue() bool { 68 | return u.GetAccessLevel() >= 2 || u.IsAdmin || u.IsOwner 69 | } 70 | 71 | func (u *User) IsAdminUser() bool { 72 | return u.IsAdmin 73 | } 74 | 75 | func (u *User) IsOwnerUser() bool { 76 | return u.IsOwner 77 | } 78 | 79 | func (u *User) Ignore() { 80 | u.Ignored = true 81 | } 82 | 83 | func (u *User) UnIgnore() { 84 | u.Ignored = false 85 | } 86 | 87 | func (u *User) GetAccessLevel() int { 88 | return u.AccessLevel 89 | } 90 | 91 | func (u *User) GetAccessLevelString() string { 92 | switch u.AccessLevel { 93 | case 1: 94 | return "Chat Pal Status" 95 | case 2: 96 | return "Swan Squadron" 97 | case 3: 98 | return "Sparrow Society" 99 | case 4: 100 | return "Golden Toucans" 101 | case 5: 102 | return "Free Bird" 103 | default: 104 | return "Free" 105 | } 106 | } 107 | 108 | func (u *User) CanUse4090() bool { 109 | // Perform a strict check to prevent false positives 110 | accessLevel := u.GetAccessLevel() 111 | return accessLevel >= 2 || u.IsAdmin || u.IsOwner 112 | } 113 | 114 | func (u *User) IsIgnored() bool { 115 | return u.Ignored 116 | } 117 | 118 | func (u *User) SetBasePrompt(prompt string) { 119 | u.AiBasePrompt = prompt 120 | } 121 | 122 | func (u *User) SetAiService(service string) { 123 | u.AiService = service 124 | } 125 | 126 | func (u *User) SetPersonality(personality string) { 127 | u.AiPersonality = personality 128 | } 129 | 130 | func (u *User) GetBasePrompt() string { 131 | return u.AiBasePrompt 132 | } 133 | 134 | func (u *User) GetAiService() string { 135 | return u.AiService 136 | } 137 | 138 | func (u *User) GetPersonality() string { 139 | return u.AiPersonality 140 | } 141 | 142 | func (u *User) SetAiModel(model string) { 143 | u.AiModel = model 144 | } 145 | 146 | func (u *User) GetAiModel() string { 147 | return u.AiModel 148 | } 149 | 150 | func (u *User) HasCurrentModes(channel string) bool { 151 | for _, mode := range u.CurrentModes { 152 | if mode.Channel == channel { 153 | return true 154 | } 155 | } 156 | return false 157 | } 158 | 159 | func (u *User) HasPreservedModes(channel string) bool { 160 | for _, mode := range u.PreservedModes { 161 | if mode.Channel == channel { 162 | return true 163 | } 164 | } 165 | return false 166 | } 167 | 168 | func (u *User) HasAnyMode() bool { 169 | if len(u.CurrentModes) == 0 { 170 | return false 171 | } 172 | for _, mode := range u.CurrentModes { 173 | if len(mode.Modes) > 0 { 174 | return true 175 | } 176 | } 177 | return false 178 | } 179 | 180 | // =========================================== 181 | // NORMALIZED DATABASE CONVERSION METHODS 182 | // =========================================== 183 | 184 | // ToUserData converts a User struct to birdbase.UserData for database storage 185 | func (u *User) ToUserData(networkID int) (*birdbase.UserData, error) { 186 | userData := &birdbase.UserData{ 187 | NetworkID: networkID, 188 | NickName: u.NickName, 189 | Ident: u.Ident, 190 | Host: u.Host, 191 | FirstSeen: u.FirstSeen, 192 | LatestActivity: u.LatestActivity, 193 | LatestChat: u.LatestChat, 194 | IsAdmin: u.IsAdmin, 195 | IsOwner: u.IsOwner, 196 | Ignored: u.Ignored, 197 | AccessLevel: u.AccessLevel, 198 | AiService: u.AiService, 199 | AiModel: u.AiModel, 200 | AiBasePrompt: u.AiBasePrompt, 201 | AiPersonality: u.AiPersonality, 202 | } 203 | 204 | // Convert PreservedModes 205 | for _, mode := range u.PreservedModes { 206 | userData.PreservedModes = append(userData.PreservedModes, birdbase.UserModeData{ 207 | Channel: mode.Channel, 208 | Modes: mode.Modes, 209 | }) 210 | } 211 | 212 | // Convert CurrentModes 213 | for _, mode := range u.CurrentModes { 214 | userData.CurrentModes = append(userData.CurrentModes, birdbase.UserModeData{ 215 | Channel: mode.Channel, 216 | Modes: mode.Modes, 217 | }) 218 | } 219 | 220 | return userData, nil 221 | } 222 | 223 | // FromUserData converts birdbase.UserData back to a User struct 224 | func (u *User) FromUserData(userData *birdbase.UserData) error { 225 | u.NickName = userData.NickName 226 | u.Ident = userData.Ident 227 | u.Host = userData.Host 228 | u.FirstSeen = userData.FirstSeen 229 | u.LatestActivity = userData.LatestActivity 230 | u.LatestChat = userData.LatestChat 231 | u.IsAdmin = userData.IsAdmin 232 | u.IsOwner = userData.IsOwner 233 | // NOTE: Ignored status will be set correctly when the user is loaded into network context 234 | // This prevents users from staying ignored when they're removed from the ignore list 235 | u.Ignored = userData.Ignored 236 | u.AccessLevel = userData.AccessLevel 237 | u.AiService = userData.AiService 238 | u.AiModel = userData.AiModel 239 | u.AiBasePrompt = userData.AiBasePrompt 240 | u.AiPersonality = userData.AiPersonality 241 | 242 | // Clear existing modes 243 | u.PreservedModes = nil 244 | u.CurrentModes = nil 245 | 246 | // Convert PreservedModes 247 | for _, modeData := range userData.PreservedModes { 248 | u.PreservedModes = append(u.PreservedModes, modes.UserModes{ 249 | Channel: modeData.Channel, 250 | Modes: modeData.Modes, 251 | }) 252 | } 253 | 254 | // Convert CurrentModes 255 | for _, modeData := range userData.CurrentModes { 256 | u.CurrentModes = append(u.CurrentModes, modes.UserModes{ 257 | Channel: modeData.Channel, 258 | Modes: modeData.Modes, 259 | }) 260 | } 261 | 262 | return nil 263 | } 264 | 265 | // NewUserFromData creates a new User from birdbase.UserData 266 | func NewUserFromData(userData *birdbase.UserData) *User { 267 | user := &User{} 268 | user.FromUserData(userData) 269 | return user 270 | } 271 | -------------------------------------------------------------------------------- /irc/channels/channel.go: -------------------------------------------------------------------------------- 1 | package channels 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "aibird/helpers" 8 | "aibird/irc/users" 9 | "aibird/irc/users/modes" 10 | "aibird/logger" 11 | 12 | "github.com/lrstanley/girc" 13 | ) 14 | 15 | func (c *Channel) String() string { 16 | return girc.Fmt(fmt.Sprintf("{b}Name{b}: %s {b}Users{b}: %d {b}PreserveModes{b}: %s {b}Ai{b}: %s {b}Sd{b}: %s {b}ImageDescribe{b}: %s {b}Sound{b}: %s {b}ActionTrigger{b}: %s {b}TrimOutput{b}: %s", 17 | c.Name, 18 | len(c.Users), 19 | helpers.StringToStatusIndicator(strconv.FormatBool(c.PreserveModes)), 20 | helpers.StringToStatusIndicator(strconv.FormatBool(c.Ai)), 21 | helpers.StringToStatusIndicator(strconv.FormatBool(c.Sd)), 22 | helpers.StringToStatusIndicator(strconv.FormatBool(c.ImageDescribe)), 23 | helpers.StringToStatusIndicator(strconv.FormatBool(c.Sound)), 24 | c.ActionTrigger, 25 | helpers.StringToStatusIndicator(strconv.FormatBool(c.TrimOutput)))) 26 | } 27 | 28 | func (c *Channel) GetUserWithNick(nick string) (*users.User, error) { 29 | if c == nil { 30 | logger.Warn("GetUserWithNick called on nil *Channel") 31 | return nil, fmt.Errorf("GetUserWithNick called on nil *Channel") 32 | } 33 | 34 | var foundUsers []*users.User 35 | for _, user := range c.Users { 36 | if user.NickName == nick { 37 | foundUsers = append(foundUsers, user) 38 | } 39 | } 40 | 41 | if len(foundUsers) == 0 { 42 | return nil, nil 43 | } 44 | 45 | if len(foundUsers) == 1 { 46 | return foundUsers[0], nil 47 | } 48 | 49 | latestUser := foundUsers[0] 50 | for _, user := range foundUsers { 51 | if user.LatestActivity > latestUser.LatestActivity { 52 | latestUser = user 53 | } 54 | } 55 | 56 | return latestUser, nil 57 | } 58 | 59 | func (c *Channel) SyncUser(user *users.User) bool { 60 | if c == nil { 61 | logger.Warn("SyncUserToChannel called on nil *Channel") 62 | return false 63 | } 64 | 65 | for _, existingUser := range c.Users { 66 | if existingUser.NickName == user.NickName && existingUser.Ident == user.Ident && existingUser.Host == user.Host { 67 | // User already exists in the channel 68 | return false 69 | } 70 | } 71 | // User does not exist, append to channel 72 | c.Users = append(c.Users, user) 73 | return true 74 | } 75 | 76 | // SyncCurrentModes 77 | // Used on WHO sync to sync the users current modes if they are first seen 78 | func (c *Channel) SyncCurrentModes(user *users.User, newModes []string) { 79 | if user == nil { 80 | logger.Warn("SyncCurrentModes: User is nil") 81 | return 82 | } 83 | 84 | if c == nil { 85 | logger.Warn("SyncCurrentModes: Channel is nil") 86 | return 87 | } 88 | 89 | for i, userMode := range user.CurrentModes { 90 | if userMode.Channel == c.Name { 91 | user.CurrentModes[i].Modes = newModes 92 | return 93 | } 94 | } 95 | 96 | user.CurrentModes = append(user.CurrentModes, modes.UserModes{ 97 | Channel: c.Name, 98 | Modes: newModes, 99 | }) 100 | 101 | } 102 | 103 | func (c *Channel) SyncPreservedModes(user *users.User, newModes []string) { 104 | if user == nil { 105 | logger.Warn("SyncPreservedModes: User is nil") 106 | return 107 | } 108 | 109 | if c == nil { 110 | logger.Warn("SyncPreservedModes: Channel is nil") 111 | return 112 | } 113 | 114 | for i, userMode := range user.PreservedModes { 115 | if userMode.Channel == c.Name { 116 | user.PreservedModes[i].Modes = newModes 117 | return 118 | } 119 | } 120 | 121 | user.PreservedModes = append(user.PreservedModes, modes.UserModes{ 122 | Channel: c.Name, 123 | Modes: newModes, 124 | }) 125 | 126 | } 127 | 128 | // SyncMode - Used on mode change 129 | func (c *Channel) SyncMode(user *users.User, mode string) { 130 | if user == nil { 131 | logger.Warn("RememberChannelMode: User is nil") 132 | return 133 | } 134 | 135 | if c == nil { 136 | logger.Warn("RememberChannelMode: Channel is nil") 137 | return 138 | } 139 | 140 | hasMode := false 141 | 142 | for i, userMode := range user.PreservedModes { 143 | if userMode.Channel == c.Name { 144 | for _, existingMode := range user.PreservedModes[i].Modes { 145 | if existingMode == mode { 146 | hasMode = true 147 | break 148 | } 149 | } 150 | 151 | if !hasMode { 152 | user.PreservedModes[i].Modes = append(user.PreservedModes[i].Modes, mode) 153 | } 154 | 155 | break 156 | } 157 | } 158 | 159 | if !hasMode { 160 | user.PreservedModes = append(user.PreservedModes, modes.UserModes{ 161 | Channel: c.Name, 162 | Modes: []string{mode}, 163 | }) 164 | } 165 | 166 | hasMode = false 167 | 168 | for i, userMode := range user.CurrentModes { 169 | if userMode.Channel == c.Name { 170 | // Mode for this channel found, check if the mode already exists 171 | for _, existingMode := range user.CurrentModes[i].Modes { 172 | if existingMode == mode { 173 | hasMode = true 174 | break 175 | } 176 | } 177 | // Mode not found, add it 178 | if !hasMode { 179 | user.CurrentModes[i].Modes = append(user.CurrentModes[i].Modes, mode) 180 | hasMode = true 181 | } 182 | 183 | break 184 | } 185 | } 186 | 187 | if !hasMode { 188 | user.CurrentModes = append(user.CurrentModes, modes.UserModes{ 189 | Channel: c.Name, 190 | Modes: []string{mode}, 191 | }) 192 | } 193 | 194 | } 195 | 196 | // ForgetChannelMode - Used on mode change 197 | func (c *Channel) ForgetMode(user *users.User, mode string) { 198 | if user == nil { 199 | logger.Warn("ForgetChannelMode: User is nil") 200 | return 201 | } 202 | 203 | if c == nil { 204 | logger.Warn("ForgetChannelMode: Channel is nil") 205 | return 206 | } 207 | 208 | for i, userModes := range user.CurrentModes { 209 | if userModes.Channel == c.Name { 210 | // Mode for this channel found, remove it 211 | for j, userMode := range userModes.Modes { 212 | if userMode == mode { 213 | user.CurrentModes[i].Modes = append(user.CurrentModes[i].Modes[:j], user.CurrentModes[i].Modes[j+1:]...) 214 | } 215 | } 216 | } 217 | } 218 | 219 | // We don't want to accidentally remove the owner or admin preserved modes 220 | if user.IsOwnerUser() || user.IsAdminUser() { 221 | return 222 | } 223 | 224 | for i, userModes := range user.PreservedModes { 225 | if userModes.Channel == c.Name { 226 | // Mode for this channel found, remove it 227 | for j, userMode := range userModes.Modes { 228 | if userMode == mode { 229 | user.PreservedModes[i].Modes = append(user.PreservedModes[i].Modes[:j], user.PreservedModes[i].Modes[j+1:]...) 230 | } 231 | } 232 | } 233 | } 234 | 235 | } 236 | 237 | // CanUserOp 238 | // Used to check if the bot has ops in the channel to save spamming 239 | func (c *Channel) CanUserOp(user *users.User) bool { 240 | if user == nil { 241 | return false 242 | } 243 | 244 | for _, userMode := range user.CurrentModes { 245 | if userMode.Channel == c.Name { 246 | for _, mode := range userMode.Modes { 247 | if mode == "@" || mode == "~" || mode == "%" { 248 | return true 249 | } 250 | } 251 | } 252 | } 253 | return false 254 | } 255 | 256 | // Forget and Sync modes for all users in a channel 257 | // Used on PART or KICK to clear modes for all users in a channel 258 | func (c *Channel) AllUsersForgetSyncModes(user *users.User, modes []string) { 259 | if user == nil { 260 | return 261 | } 262 | 263 | // Forget modes for the specified user 264 | for _, mode := range modes { 265 | c.ForgetMode(user, mode) 266 | } 267 | 268 | // Sync modes for all users in the channel 269 | for _, channelUser := range c.Users { 270 | for _, mode := range modes { 271 | c.SyncMode(channelUser, mode) 272 | } 273 | } 274 | } 275 | 276 | func (c *Channel) RemoveUser(user *users.User) { 277 | if c == nil || user == nil { 278 | return 279 | } 280 | 281 | // Remove the user from the channel's user list 282 | for i, u := range c.Users { 283 | if u.NickName == user.NickName { 284 | c.Users = append(c.Users[:i], c.Users[i+1:]...) 285 | break 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /queue/dual_queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "aibird/image/comfyui" 11 | "aibird/logger" 12 | "aibird/shared/meta" 13 | "aibird/status" 14 | ) 15 | 16 | func NewDualQueue() *DualQueue { 17 | return &DualQueue{ 18 | Queue4090: &Queue{}, 19 | Queue2070: &Queue{}, 20 | } 21 | } 22 | 23 | func (dq *DualQueue) Enqueue(item QueueItem) (string, error) { //nolint:gocyclo 24 | dq.Mutex.Lock() 25 | defer dq.Mutex.Unlock() 26 | 27 | // Check if this is a ComfyUI workflow by checking if the workflow file exists 28 | workflowFile := "comfyuijson/" + item.Model + ".json" 29 | if _, err := os.Stat(workflowFile); os.IsNotExist(err) { 30 | // Not a ComfyUI workflow, treat as text command or other non-workflow command 31 | logger.Debug("Enqueuing non-workflow command", "model", item.Model) 32 | 33 | // For non-workflow commands, use 2070 by default (they don't need GPU) 34 | item.GPU = meta.GPU2070 35 | if item.User != nil && item.User.CanSkipQueue() { 36 | return dq.Queue2070.EnqueueFront(item, "") 37 | } 38 | return dq.Queue2070.Enqueue(item) 39 | } 40 | 41 | // Handle ComfyUI workflows 42 | metaData, err := comfyui.GetAibirdMeta(workflowFile) 43 | if err != nil { 44 | return "", errors.New("could not load workflow metadata for this model") 45 | } 46 | 47 | statusClient := status.NewClient(item.State.Config.AiBird) 48 | statusMeta := &meta.AibirdMeta{ 49 | AccessLevel: metaData.AccessLevel, 50 | BigModel: metaData.BigModel, 51 | } 52 | use4090, err := statusClient.CheckModelExecution(item.Model, statusMeta, item.User, item.State.User.NickName) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | // Big model: strict 4090 only 58 | if metaData.BigModel { 59 | item.GPU = meta.GPU4090 60 | if item.User != nil && item.User.CanSkipQueue() { 61 | return dq.Queue4090.EnqueueFront(item, "") 62 | } 63 | return dq.Queue4090.Enqueue(item) 64 | } 65 | 66 | // Small model logic with defer to 2070 if 4090 is busy 67 | if use4090 { 68 | if !dq.Queue4090.IsCurrentlyProcessing() { 69 | item.GPU = meta.GPU4090 70 | if item.User != nil && item.User.CanSkipQueue() { 71 | return dq.Queue4090.EnqueueFront(item, "") 72 | } 73 | return dq.Queue4090.Enqueue(item) 74 | } else { 75 | item.GPU = meta.GPU2070 76 | msg := "4090 is busy, your request is being processed on the 2070 instead." 77 | if item.User != nil && item.User.CanSkipQueue() { 78 | return dq.Queue2070.EnqueueFront(item, msg) 79 | } 80 | return dq.Queue2070.Enqueue(item) 81 | } 82 | } else { 83 | item.GPU = meta.GPU2070 84 | if item.User != nil && item.User.CanSkipQueue() { 85 | return dq.Queue2070.EnqueueFront(item, "") 86 | } 87 | return dq.Queue2070.Enqueue(item) 88 | } 89 | } 90 | 91 | func (dq *DualQueue) ProcessQueues(ctx context.Context) error { 92 | // Use errgroup to manage both goroutines and handle errors 93 | type errgroup struct { 94 | ctx context.Context 95 | cancel context.CancelFunc 96 | wg sync.WaitGroup 97 | errMu sync.Mutex 98 | err error 99 | } 100 | 101 | eg := &errgroup{} 102 | eg.ctx, eg.cancel = context.WithCancel(ctx) 103 | defer eg.cancel() 104 | 105 | // Process 4090 queue 106 | eg.wg.Add(1) 107 | go func() { 108 | defer eg.wg.Done() 109 | if err := dq.processQueue(eg.ctx, dq.Queue4090, "4090"); err != nil { 110 | eg.errMu.Lock() 111 | if eg.err == nil { 112 | eg.err = err 113 | } 114 | eg.errMu.Unlock() 115 | eg.cancel() 116 | } 117 | }() 118 | 119 | // Process 2070 queue 120 | eg.wg.Add(1) 121 | go func() { 122 | defer eg.wg.Done() 123 | if err := dq.processQueue(eg.ctx, dq.Queue2070, "2070"); err != nil { 124 | eg.errMu.Lock() 125 | if eg.err == nil { 126 | eg.err = err 127 | } 128 | eg.errMu.Unlock() 129 | eg.cancel() 130 | } 131 | }() 132 | 133 | eg.wg.Wait() 134 | eg.errMu.Lock() 135 | defer eg.errMu.Unlock() 136 | return eg.err 137 | } 138 | 139 | func (dq *DualQueue) processQueue(ctx context.Context, queue *Queue, queueName string) error { 140 | ticker := time.NewTicker(100 * time.Millisecond) 141 | defer ticker.Stop() 142 | 143 | for { 144 | select { 145 | case <-ctx.Done(): 146 | logger.Info("Queue processing stopped due to context cancellation", "queue", queueName) 147 | return ctx.Err() 148 | case <-ticker.C: 149 | if !queue.isProcessing() && !queue.IsEmpty() { 150 | dq.processQueueItem(queue) 151 | } 152 | } 153 | } 154 | } 155 | 156 | func (dq *DualQueue) processQueueItem(queue *Queue) { 157 | queue.setProcessing(true) 158 | item := queue.Dequeue() 159 | queue.setProcessingItem(item) 160 | if item != nil { 161 | logger.Debug("Processing queue item", "gpu", item.GPU, "action", item.State.Action()) 162 | 163 | // Create a context with a 4-minute timeout 164 | ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) 165 | defer cancel() 166 | 167 | // Create a channel to signal when the function is complete 168 | done := make(chan struct{}) 169 | 170 | go func() { 171 | item.Function(item.State, item.GPU) 172 | close(done) 173 | }() 174 | 175 | select { 176 | case <-done: 177 | // Function completed successfully 178 | logger.Debug("Completed queue item", "gpu", item.GPU) 179 | case <-ctx.Done(): 180 | // Timeout occurred 181 | logger.Error("Queue item timed out", "gpu", item.GPU, "action", item.State.Action()) 182 | item.State.SendError("An unknown error occurred, your request has been canceled. Please try again later.") 183 | } 184 | 185 | queue.setProcessing(false) 186 | queue.setProcessingItem(nil) 187 | } else { 188 | queue.setProcessing(false) 189 | queue.setProcessingItem(nil) 190 | } 191 | } 192 | 193 | // Status methods 194 | func (dq *DualQueue) IsEmpty() bool { 195 | return dq.Queue4090.IsEmpty() && dq.Queue2070.IsEmpty() 196 | } 197 | 198 | func (dq *DualQueue) IsCurrentlyProcessing() bool { 199 | return dq.Queue4090.IsCurrentlyProcessing() || dq.Queue2070.IsCurrentlyProcessing() 200 | } 201 | 202 | func (dq *DualQueue) GetQueueLengths() (int, int) { 203 | return dq.Queue4090.GetQueueLength(), dq.Queue2070.GetQueueLength() 204 | } 205 | 206 | func (dq *DualQueue) GetActionLists() ([]string, []string) { 207 | return dq.Queue4090.GetActionList(), dq.Queue2070.GetActionList() 208 | } 209 | 210 | // Admin control methods 211 | func (dq *DualQueue) ClearAllQueues() { 212 | dq.Mutex.Lock() 213 | defer dq.Mutex.Unlock() 214 | 215 | dq.Queue4090.Clear() 216 | dq.Queue2070.Clear() 217 | logger.Info("All queues cleared by admin") 218 | } 219 | 220 | func (dq *DualQueue) ClearQueue(gpuType meta.GPUType) { 221 | dq.Mutex.Lock() 222 | defer dq.Mutex.Unlock() 223 | 224 | switch gpuType { 225 | case meta.GPU4090: 226 | dq.Queue4090.Clear() 227 | logger.Info("4090 queue cleared by admin") 228 | case meta.GPU2070: 229 | dq.Queue2070.Clear() 230 | logger.Info("2070 queue cleared by admin") 231 | } 232 | } 233 | 234 | func (dq *DualQueue) RemoveCurrentItem() bool { 235 | dq.Mutex.Lock() 236 | defer dq.Mutex.Unlock() 237 | 238 | removed4090 := dq.Queue4090.RemoveCurrent() 239 | removed2070 := dq.Queue2070.RemoveCurrent() 240 | 241 | return removed4090 || removed2070 242 | } 243 | 244 | func (dq *DualQueue) GetDetailedStatus() *QueueStatus { 245 | dq.Mutex.Lock() 246 | defer dq.Mutex.Unlock() 247 | 248 | return &QueueStatus{ 249 | Queue4090Length: dq.Queue4090.GetQueueLength(), 250 | Queue2070Length: dq.Queue2070.GetQueueLength(), 251 | Processing4090: dq.Queue4090.IsCurrentlyProcessing(), 252 | Processing2070: dq.Queue2070.IsCurrentlyProcessing(), 253 | Queue4090Items: dq.Queue4090.GetActionList(), 254 | Queue2070Items: dq.Queue2070.GetActionList(), 255 | } 256 | } 257 | 258 | type QueueStatus struct { 259 | Queue4090Length int `json:"queue_4090_length"` 260 | Queue2070Length int `json:"queue_2070_length"` 261 | Processing4090 bool `json:"processing_4090"` 262 | Processing2070 bool `json:"processing_2070"` 263 | Queue4090Items []string `json:"queue_4090_items"` 264 | Queue2070Items []string `json:"queue_2070_items"` 265 | } 266 | -------------------------------------------------------------------------------- /queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "aibird/logger" 11 | ) 12 | 13 | // Queue represents a queue data structure 14 | // Now stores QueueItem instead of Item 15 | 16 | type Queue struct { 17 | elements []QueueItem 18 | mutex sync.Mutex 19 | processing bool 20 | processingMutex sync.Mutex 21 | processingItem *QueueItem // currently processing item 22 | } 23 | 24 | // Enqueue adds an element to the end of the queue 25 | func (q *Queue) Enqueue(element QueueItem) (string, error) { 26 | q.mutex.Lock() 27 | defer q.mutex.Unlock() 28 | 29 | if q.hasElementsUnsafe(11) { 30 | return "", errors.New("the queue is currently full (limit is 10), please try again in a few minutes") 31 | } 32 | 33 | // Add the new item to the queue 34 | q.elements = append(q.elements, element) 35 | 36 | // Determine the number of items ahead of the user 37 | itemsAhead := len(q.elements) - 1 38 | if q.isProcessing() { 39 | itemsAhead++ 40 | } 41 | 42 | // If there are no items ahead, no message is needed 43 | if itemsAhead == 0 { 44 | return "", nil 45 | } 46 | 47 | // Formulate the queue message 48 | if itemsAhead == 1 { 49 | return "There is 1 item in the queue ahead of you. Your request will be processed shortly.", nil 50 | } 51 | 52 | return fmt.Sprintf("There are %d items in the queue ahead of you. Your request will be processed shortly.", itemsAhead), nil 53 | } 54 | 55 | // EnqueueFront adds an element to the start of the queue and returns a message if not empty. 56 | // If msg is empty, uses the default VIP message. 57 | func (q *Queue) EnqueueFront(element QueueItem, msg string) (string, error) { 58 | q.mutex.Lock() 59 | defer q.mutex.Unlock() 60 | 61 | // The queue is considered "busy" if it's processing an item or already has items. 62 | // A VIP user should get a message if they are skipping ahead of others, or if they still have to wait for the current item. 63 | isBusy := q.isProcessing() || len(q.elements) > 0 64 | 65 | q.elements = append([]QueueItem{element}, q.elements...) 66 | 67 | // Only send a message if the queue was busy. If it was completely idle, 68 | // the item will be processed immediately and the processing message will be sent. 69 | if !isBusy { 70 | return "", nil 71 | } 72 | 73 | if msg == "" { 74 | msg = "Your elite patreon status has put you at the front of the queue!" 75 | } 76 | return msg, nil 77 | } 78 | 79 | // Dequeue removes and returns the first element of the queue 80 | func (q *Queue) Dequeue() *QueueItem { 81 | q.mutex.Lock() 82 | defer q.mutex.Unlock() 83 | 84 | if len(q.elements) == 0 { 85 | return nil 86 | } 87 | element := q.elements[0] 88 | q.elements = q.elements[1:] 89 | return &element 90 | } 91 | 92 | // IsEmpty checks if the queue is empty 93 | func (q *Queue) IsEmpty() bool { 94 | q.mutex.Lock() 95 | defer q.mutex.Unlock() 96 | return len(q.elements) == 0 97 | } 98 | 99 | func (q *Queue) HasOneOrEmpty() bool { 100 | q.mutex.Lock() 101 | defer q.mutex.Unlock() 102 | return len(q.elements) <= 1 103 | } 104 | 105 | // HasElements checks if the queue has elements 106 | func (q *Queue) HasElements(amount int) bool { 107 | q.mutex.Lock() 108 | defer q.mutex.Unlock() 109 | return q.hasElementsUnsafe(amount) 110 | } 111 | 112 | // GetQueueLength returns the current number of items in the queue 113 | func (q *Queue) GetQueueLength() int { 114 | q.mutex.Lock() 115 | defer q.mutex.Unlock() 116 | return len(q.elements) 117 | } 118 | 119 | // IsCurrentlyProcessing returns whether an item is actively being processed 120 | func (q *Queue) IsCurrentlyProcessing() bool { 121 | return q.isProcessing() 122 | } 123 | 124 | // hasElementsUnsafe is an internal function that checks queue size without locking 125 | // It should only be called when the mutex is already locked 126 | func (q *Queue) hasElementsUnsafe(amount int) bool { 127 | return len(q.elements) >= amount 128 | } 129 | 130 | // setProcessing sets the processing flag to indicate a task is being processed 131 | func (q *Queue) setProcessing(value bool) { 132 | q.processingMutex.Lock() 133 | defer q.processingMutex.Unlock() 134 | q.processing = value 135 | } 136 | 137 | // isProcessing checks if a task is currently being processed 138 | func (q *Queue) isProcessing() bool { 139 | q.processingMutex.Lock() 140 | defer q.processingMutex.Unlock() 141 | return q.processing 142 | } 143 | 144 | // ProcessQueue continuously processes elements in the queue with context cancellation 145 | func (q *Queue) ProcessQueue(ctx context.Context) error { 146 | ticker := time.NewTicker(100 * time.Millisecond) 147 | defer ticker.Stop() 148 | 149 | for { 150 | select { 151 | case <-ctx.Done(): 152 | logger.Info("Queue processing stopped due to context cancellation") 153 | return ctx.Err() 154 | case <-ticker.C: 155 | // Only process if we're not already processing and there are items in the queue 156 | if !q.isProcessing() && !q.IsEmpty() { 157 | logger.Debug("Queue: Starting to process next item", "queue_length", q.Len()) 158 | 159 | // Mark as processing before dequeuing to prevent race conditions 160 | q.setProcessing(true) 161 | 162 | // Get the next item from the queue 163 | element := q.Dequeue() 164 | 165 | // Set the currently processing item 166 | q.setProcessingItem(element) 167 | 168 | // Process the item if it's a valid function 169 | if element != nil { 170 | // Execute the function in a goroutine but maintain processing flag 171 | go func() { 172 | logger.Debug("Queue: Executing function") 173 | // Execute the function 174 | element.Function(element.State, element.GPU) 175 | 176 | // Mark as not processing when done 177 | q.setProcessing(false) 178 | q.setProcessingItem(nil) 179 | logger.Debug("Queue: Function completed", "queue_length", q.Len()) 180 | }() 181 | } else { 182 | // If not a valid function, reset processing flag 183 | q.setProcessing(false) 184 | q.setProcessingItem(nil) 185 | logger.Debug("Queue: Dequeued item was not a function") 186 | } 187 | } 188 | } 189 | } 190 | } 191 | 192 | // Len returns the current number of items in the queue 193 | func (q *Queue) Len() int { 194 | q.mutex.Lock() 195 | defer q.mutex.Unlock() 196 | return len(q.elements) 197 | } 198 | 199 | // Peek returns the first element of the queue without removing it 200 | func (q *Queue) Peek() *QueueItem { 201 | q.mutex.Lock() 202 | defer q.mutex.Unlock() 203 | if len(q.elements) == 0 { 204 | return nil 205 | } 206 | return &q.elements[0] 207 | } 208 | 209 | // GetActionList returns a slice of the action strings for each item in the queue 210 | func (q *Queue) GetActionList() []string { 211 | q.mutex.Lock() 212 | defer q.mutex.Unlock() 213 | 214 | var actions []string 215 | for _, item := range q.elements { 216 | actions = append(actions, item.State.Action()) 217 | } 218 | return actions 219 | } 220 | 221 | // Clear removes all items from the queue 222 | func (q *Queue) Clear() { 223 | q.mutex.Lock() 224 | defer q.mutex.Unlock() 225 | q.elements = nil 226 | logger.Info("Queue cleared") 227 | } 228 | 229 | // RemoveCurrent removes the currently processing item 230 | func (q *Queue) RemoveCurrent() bool { 231 | q.processingMutex.Lock() 232 | defer q.processingMutex.Unlock() 233 | 234 | if q.processing { 235 | // Send cancellation message to user if we have access to the current item 236 | // For now, just mark as not processing 237 | q.processing = false 238 | logger.Info("Current processing item removed") 239 | return true 240 | } 241 | 242 | return false 243 | } 244 | 245 | // Set the currently processing item 246 | func (q *Queue) setProcessingItem(item *QueueItem) { 247 | q.processingMutex.Lock() 248 | defer q.processingMutex.Unlock() 249 | q.processingItem = item 250 | } 251 | 252 | // Get the currently processing action (or empty string if none) 253 | func (q *Queue) GetProcessingAction() string { 254 | q.processingMutex.Lock() 255 | defer q.processingMutex.Unlock() 256 | if q.processingItem != nil { 257 | return q.processingItem.State.Action() 258 | } 259 | return "" 260 | } 261 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Bird IRC Bot 🐦 2 | 3 | A sophisticated multi-network IRC bot that provides AI-powered services including text generation, image generation, and audio processing. Built with Go and featuring a dual-GPU queue system, comprehensive state management, and modular architecture. 4 | 5 | You can find the bot in use on popular IRC networks in the channel #birdnest. The main #birdnest channel is on efnet, however also on libera, rizon and a few other networks. It's free to use and so far compared to other online AI generation services for images, sounds or music - it has unlimited use and is less censored. Also as it's mostly ran from my local system here it's more private. The exception is when Google Gemini or OpenRouter services are used. Everything else runs off the `airig` which has a 2070 and 4090 dual GPU setup which is networked with wireguard. 6 | 7 | 8 | 9 | While it is possible to run this yourself it may need some adjustments to how it works. Some parts are also incomplete and the full use of `aibird` is not properly documented. 10 | 11 | The `aibird` also shares some other projects to make it functional. 12 | 13 | * [birdcheck](https://github.com/birdneststream/birdcheck) - Birdcheck runs on the `airig` and provides a basic api to check the online status, gpu information and voices for the `!tts` command. 14 | * [birdhole](https://github.com/birdneststream/birdhole) - The File upload and gallery service, which `aibird` uploads to for sharing the requested generations over IRC. 15 | 16 | ## AI Disclaimer 17 | 18 | This bot is a mix of maual coding (70%) with autocomplete, and LLM agent assisted coding (30%). 19 | 20 | ## 🚀 Features 21 | 22 | - **Multi-Network IRC Support** - Connect to multiple IRC networks simultaneously 23 | - **AI-Powered Services** - Text generation, image generation, and audio processing 24 | - **Dual GPU Queue System** - Intelligent routing between RTX 4090 and RTX 2070 25 | - **Comprehensive State Management** - Robust IRC state tracking and user management 26 | - **Security & Moderation** - Flood protection, content filtering, and access control 27 | - **Graceful Shutdown** - Proper cleanup and resource management 28 | - **Extensive Logging** - Detailed logging for debugging and monitoring 29 | 30 | ## 🏗️ Architecture 31 | 32 | The bot is built with a modular architecture featuring: 33 | 34 | - **Main Application** (`main.go`) - Entry point and orchestration 35 | - **Configuration Management** (`settings/`) - TOML-based configuration 36 | - **Dual Queue System** (`queue/`) - Intelligent GPU resource management 37 | - **IRC State Management** (`irc/state/`) - Comprehensive IRC state tracking 38 | - **Command System** (`irc/commands/`) - Permission-based command handling 39 | - **Image Processing** (`image/comfyui/`) - Advanced image generation 40 | - **Text Services** (`text/`) - Multiple AI text generation providers 41 | 42 | ## 📋 Requirements 43 | 44 | - **Go 1.23+** - Minimum Go version required 45 | - **GPU Support** - NVIDIA GPUs for image generation (optional) 46 | - **IRC Networks** - Access to IRC networks you want to connect to 47 | - **AI Services** - API keys for text generation services (OpenRouter, Gemini, etc.) 48 | 49 | ## 🛠️ Installation 50 | 51 | 1. **Clone the repository** 52 | ```bash 53 | git clone https://github.com/yourusername/aibird.git 54 | cd aibird 55 | ``` 56 | 57 | 2. **Install dependencies** 58 | ```bash 59 | go mod download 60 | ``` 61 | 62 | 3. **Build the application** 63 | ```bash 64 | go build -o aibird . 65 | ``` 66 | 67 | 4. **Configure the bot** 68 | ```bash 69 | cp config.toml.example config.toml 70 | # Edit config.toml with your settings 71 | ``` 72 | 73 | 5. **Run the bot** 74 | ```bash 75 | ./aibird 76 | ``` 77 | 78 | ## ⚙️ Configuration 79 | 80 | The bot uses TOML configuration files: 81 | 82 | - **`config.toml`** - Main configuration file 83 | - **`settings/`** - Service-specific configuration files 84 | 85 | ### Example Configuration 86 | 87 | ```toml 88 | [networks.freenode] 89 | enabled = true 90 | nick = "aibird" 91 | user = "aibird" 92 | name = "AI Bird Bot" 93 | channels = [ 94 | { name = "#test", ai = true, sd = true } 95 | ] 96 | 97 | [aibird] 98 | actionTrigger = "!" 99 | floodThreshold = 5 100 | floodIgnoreMinutes = 10 101 | ``` 102 | 103 | ## 🔧 Development 104 | 105 | ### Project Structure 106 | 107 | ``` 108 | aibird/ 109 | ├── main.go # Application entry point 110 | ├── config.toml # Main configuration (gitignored) 111 | ├── settings/ # Configuration management 112 | ├── queue/ # Dual GPU queue system 113 | ├── irc/ # IRC client and state management 114 | ├── image/ # Image generation services 115 | ├── text/ # Text generation services 116 | ├── http/ # HTTP utilities 117 | ├── logger/ # Logging system 118 | ├── helpers/ # Utility functions 119 | └── shared/ # Shared components 120 | ``` 121 | 122 | ## ComfyUi Workflows 123 | 124 | Each ComfyUi workflow must be placed in the `comfyuiworkflows` directory for `aibird` to reconize as a command. Each workflow must also have a special API group, and in the group contains a text node `aibird_meta` which has some TOML information on how the workflow is used and what arguments override what node widget values. 125 | 126 | An example workflow is provided as `sd-example.json`. 127 | 128 | ### Building 129 | 130 | ```bash 131 | # Build for current platform 132 | go build -o aibird . 133 | 134 | # Build for specific platform 135 | GOOS=linux GOARCH=amd64 go build -o aibird . 136 | ``` 137 | 138 | ### Testing 139 | 140 | ```bash 141 | # Run all tests 142 | go test ./... 143 | 144 | # Run with coverage 145 | go test -cover ./... 146 | 147 | # Run specific package tests 148 | go test ./queue 149 | ``` 150 | 151 | ### Code Quality 152 | 153 | ```bash 154 | # Run go vet 155 | go vet ./... 156 | 157 | # Format code 158 | go fmt ./... 159 | 160 | # Tidy dependencies 161 | go mod tidy 162 | ``` 163 | 164 | ## 📚 Documentation 165 | 166 | The project includes comprehensive inline documentation and comments throughout the codebase. Key areas to explore: 167 | 168 | - **Main Application** (`main.go`) - Entry point with detailed comments 169 | - **Configuration** (`settings/`) - Configuration management and validation 170 | - **IRC System** (`irc/`) - IRC client, state management, and commands 171 | - **Queue System** (`queue/`) - Dual GPU queue management 172 | - **AI Services** (`text/`, `image/`) - Text and image generation services 173 | - **Utilities** (`helpers/`, `logger/`) - Helper functions and logging 174 | 175 | For detailed architecture information, check the inline comments and code structure. 176 | 177 | ## 🔒 Security 178 | 179 | The bot includes several security features: 180 | 181 | - **Input Validation** - All user inputs are validated 182 | - **Access Control** - Hierarchical permission system 183 | - **Content Filtering** - Configurable content moderation 184 | - **Flood Protection** - Rate limiting and abuse prevention 185 | - **Secure Configuration** - Sensitive files excluded from git 186 | 187 | ## 🤝 Contributing 188 | 189 | 1. Fork the repository 190 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 191 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 192 | 4. Push to the branch (`git push origin feature/amazing-feature`) 193 | 5. Open a Pull Request 194 | 195 | ## 📄 License 196 | 197 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 198 | 199 | ## 🆘 Support 200 | 201 | For support and questions: 202 | 203 | - Create an issue on GitHub 204 | - Review the inline code documentation 205 | - Check the configuration examples 206 | 207 | ## 🙏 Acknowledgments 208 | 209 | - Built with [Go](https://golang.org/) 210 | - IRC client using [girc](https://github.com/lrstanley/girc) 211 | - Image generation via [ComfyUI](https://github.com/comfyanonymous/ComfyUI) using [Comfy2Go](https://github.com/richinsley/comfy2go) 212 | - Configuration using [TOML](https://github.com/BurntSushi/toml) 213 | 214 | --- 215 | 216 | **AI Bird IRC Bot** - Bringing AI to IRC since 2022! 🐦✨ -------------------------------------------------------------------------------- /irc/commands/headlines.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "math/big" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "aibird/irc/state" 15 | "aibird/settings" 16 | "aibird/text/gemini" 17 | ) 18 | 19 | var ( 20 | headlinesCache []string 21 | headlinesCacheTime time.Time 22 | processedHeadlines = make(map[string]bool) 23 | ) 24 | 25 | type RedditResponse struct { 26 | Data struct { 27 | Children []struct { 28 | Data struct { 29 | Title string `json:"title"` 30 | } `json:"data"` 31 | } `json:"children"` 32 | } `json:"data"` 33 | } 34 | 35 | func fetchRedditHeadlines(proxy settings.Proxy) ([]string, error) { 36 | if time.Since(headlinesCacheTime) < time.Hour { 37 | return headlinesCache, nil 38 | } 39 | 40 | req, err := http.NewRequest("GET", "https://old.reddit.com/r/worldnews/new.json", nil) 41 | if err != nil { 42 | return nil, fmt.Errorf("error creating request for headlines: %w", err) 43 | } 44 | // Reddit requires a custom User-Agent 45 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0") 46 | 47 | var resp *http.Response 48 | if proxy.Host != "" && proxy.Port != "" { 49 | proxyStr := fmt.Sprintf("http://%s:%s@%s:%s", proxy.User, proxy.Pass, proxy.Host, proxy.Port) 50 | proxyURL, err := url.Parse(proxyStr) 51 | if err != nil { 52 | return nil, fmt.Errorf("error parsing proxy URL: %w", err) 53 | } 54 | 55 | client := &http.Client{ 56 | Transport: &http.Transport{ 57 | Proxy: http.ProxyURL(proxyURL), 58 | }, 59 | Timeout: 30 * time.Second, 60 | } 61 | resp, err = client.Do(req) 62 | if err != nil { 63 | return nil, fmt.Errorf("error fetching headlines from Reddit via proxy: %w", err) 64 | } 65 | } else { 66 | client := &http.Client{Timeout: 10 * time.Second} 67 | var err error 68 | resp, err = client.Do(req) 69 | if err != nil { 70 | return nil, fmt.Errorf("error fetching headlines from Reddit: %w", err) 71 | } 72 | } 73 | defer resp.Body.Close() 74 | 75 | body, err := io.ReadAll(resp.Body) 76 | if err != nil { 77 | return nil, fmt.Errorf("error reading response body: %w", err) 78 | } 79 | 80 | var redditResponse RedditResponse 81 | if err := json.Unmarshal(body, &redditResponse); err != nil { 82 | return nil, fmt.Errorf("error parsing headlines from Reddit: %w", err) 83 | } 84 | 85 | if len(redditResponse.Data.Children) == 0 { 86 | return nil, fmt.Errorf("no headlines found") 87 | } 88 | 89 | var titles []string 90 | for _, child := range redditResponse.Data.Children { 91 | titles = append(titles, child.Data.Title) 92 | } 93 | 94 | headlinesCache = titles 95 | headlinesCacheTime = time.Now() 96 | processedHeadlines = make(map[string]bool) // Reset processed headlines when we fetch new ones 97 | 98 | return titles, nil 99 | } 100 | 101 | func callGeminiAndSend(irc state.State, prompt string, message string) { 102 | irc.Send(fmt.Sprintf("%s, %s", irc.User.NickName, message)) 103 | 104 | if irc.Config.Gemini.ApiKey == "" { 105 | irc.Send("Error: Gemini API key is not configured.") 106 | return 107 | } 108 | 109 | answer, err := gemini.SingleRequest(prompt, irc.Config.Gemini) 110 | if err != nil { 111 | irc.Send("Error getting summary from AI.") 112 | return 113 | } 114 | 115 | irc.Send(answer) 116 | } 117 | 118 | func ParseHeadlines(irc state.State) { 119 | go func() { 120 | headlines, err := fetchRedditHeadlines(irc.Config.AiBird.Proxy) 121 | if err != nil { 122 | irc.Send(err.Error()) 123 | return 124 | } 125 | 126 | var titles []string 127 | for i, headline := range headlines { 128 | if i >= 25 { 129 | break 130 | } 131 | titles = append(titles, headline) 132 | } 133 | 134 | if len(titles) == 0 { 135 | irc.Send("No headlines found.") 136 | return 137 | } 138 | 139 | allTitles := strings.Join(titles, "\n") 140 | prompt := fmt.Sprintf("As a man who is skeptical and think the satanists control everything, summarize the following headlines into a single, concise paragraph blaming the satanists and the illuminati:\n\n%s", allTitles) 141 | message := "fetching a summary of the latest headlines..." 142 | 143 | callGeminiAndSend(irc, prompt, message) 144 | }() 145 | } 146 | 147 | func ParseIrcNews(irc state.State) { 148 | go func() { 149 | headlines, err := fetchRedditHeadlines(irc.Config.AiBird.Proxy) 150 | if err != nil { 151 | irc.Send(err.Error()) 152 | return 153 | } 154 | 155 | var availableHeadlines []string 156 | for _, h := range headlines { 157 | if _, exists := processedHeadlines[h]; !exists { 158 | availableHeadlines = append(availableHeadlines, h) 159 | } 160 | } 161 | 162 | if len(availableHeadlines) == 0 && len(headlines) > 0 { 163 | // All headlines have been processed, reset the map 164 | processedHeadlines = make(map[string]bool) 165 | // and refill availableHeadlines 166 | availableHeadlines = headlines 167 | irc.Send("All headlines have been used, starting over.") 168 | } 169 | 170 | if len(availableHeadlines) == 0 { 171 | irc.Send("No headlines available to process.") 172 | return 173 | } 174 | 175 | // Use crypto/rand for secure random number generation 176 | randomIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(availableHeadlines)))) 177 | if err != nil { 178 | irc.Send("Error generating random headline") 179 | return 180 | } 181 | randomHeadline := availableHeadlines[randomIndex.Int64()] 182 | processedHeadlines[randomHeadline] = true 183 | 184 | prompt := fmt.Sprintf(`Rewrite the following real-world news headline into a single, creative, and humorous IRC-themed headline. The theme must be based on the culture and lore of the EFNet IRC network. 185 | 186 | Here are the rules for the rewrite: 187 | 1. **One Headline Only:** Your entire response must be ONLY the single rewritten headline. Do not provide options, explanations, or any text other than the final headline. 188 | 2. **Replace Countries with Channels:** Map country names to famous EFNet channel names from this list: #lrh, #birdnest, #evildojo, #efnetnews, #h4x, #warez, #chat, #help, #hrl, #wyzrds-tower, #dragonflybsd, #bex, #mircart. 189 | 3. **Replace People with Nicks:** Map names of leaders, groups, or individuals to well-known EFNet user nickname from this list: darkmage, l0de, bex, ralph, jrra, kuntz, moony, sniff, astro, anji, b-rex, canada420, clamkin, skg, gary, beenz, deakin, interdome, syn, darkness, vae, gowce, moneytree, Retarded, spoon, sylar, stovepipe, morthrane, chrono, acidvegas, again, hgc, durendal, knio, mavericks, pyrex, sh, irie, seirdy, sq, stratum, WeEatnKid, dieforirc, tater, buttvomit, luldangs, MichealK, AnalMan, poccri, vap0r, kakama, fregyXin, kayos, stovepipe, Audasity, PsyMaster, perplexa, alyosha, Darn, efsenable, EchoShun, dumbguy, phobos, COMPUTERS, dave, nance, sthors, X-Bot, lamer, ChanServ. 190 | 4. **Translate Actions to IRC Events:** Convert real-world actions into IRC equivalents. For example: 191 | * **Military Conflict:** A "channel takeover," "flame war," "mass-kick script," "DDoS attack," or a "netsplit" for a major war. 192 | * **Military Action (Missile, Bomb, Strike):** A "malicious script," "flood bot," "CTCP flood," or a user being "/killed" by an op. 193 | * **Defense/Interception:** A "kick/ban" (+b), an op using "/kill," a server-wide "K-line" or "G-line," or a "clone block." 194 | * **Diplomacy/Negotiations:** A "private message (/query)," an "op meeting," or someone getting "opped" (+o). 195 | * **Sanctions/Penalties:** A channel "ban" (+b), a "server-wide K-line/G-line," being "shunned," or added to a "shitlist." 196 | * **Protests/Uprisings:** A "mass-join," "spamming slogans," users "mass-parting," or a "revolt against the channel founder." 197 | * **Espionage/Spying:** "Lurking," using "/whois," "social engineering an op," or sniffing DCC traffic. 198 | * **Alliances/Treaties:** Linking two servers, sharing a "ban list," adding friendly bots, or forming a "council of ops." 199 | * **Economic/Financial Events:** 200 | * **Economy/Trade:** "DCC file trading," "XDCC pack serving," or "bot currency transfers." 201 | * **Economic Crisis:** "Channel is dead," "everyone is /away," or a "netsplit wiped out the user list." 202 | * **Legal/Political Events:** 203 | * **Elections:** "Ops holding a vote for founder," or a "poll in the topic." 204 | * **Legislation:** "New channel rule (+R) set," or "topic updated with new policies." 205 | * **Scandal/Corruption:** "Op caught sharing chan keys," or a "DCC transfer was intercepted." 206 | * **Technology/Cybersecurity Events:** 207 | * **New Invention:** "A new TCL script was released," or "a new mIRC version is out." 208 | * **Data Breach:** "User list was leaked," or "server passwords compromised." 209 | * **Disasters/Infrastructure Failures:** 210 | * **Natural Disaster:** A "server crash," "massive lag," or the "main server going down." 211 | 212 | Headline to rewrite: %s`, randomHeadline) 213 | message := "Getting the latest IRC news..." 214 | 215 | callGeminiAndSend(irc, prompt, message) 216 | }() 217 | } 218 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for aibird Go project 2 | .PHONY: help build build-linux test test-verbose test-race test-coverage lint lint-fix fmt vet clean deps deps-update run dev install uninstall docker-build docker-run check-tools install-tools benchmark profile security audit 3 | 4 | # Default target 5 | .DEFAULT_GOAL := help 6 | 7 | # Variables 8 | BINARY_NAME=aibird 9 | BUILD_DIR=bin 10 | COVERAGE_DIR=coverage 11 | GO_VERSION := $(shell go version | cut -d ' ' -f 3) 12 | GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 13 | BUILD_TIME := $(shell date +%FT%T%z) 14 | LDFLAGS := -ldflags "-X main.Version=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME} -w -s" 15 | 16 | # Colors for output 17 | RED=\033[0;31m 18 | GREEN=\033[0;32m 19 | YELLOW=\033[0;33m 20 | BLUE=\033[0;34m 21 | NC=\033[0m # No Color 22 | 23 | ## help: Show this help message 24 | help: 25 | @echo "Available commands:" 26 | @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' 27 | 28 | ## build: Build the application 29 | build: fmt vet 30 | @echo "$(BLUE)Building $(BINARY_NAME)...$(NC)" 31 | @mkdir -p $(BUILD_DIR) 32 | go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) . 33 | @echo "$(GREEN)✓ Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(NC)" 34 | 35 | ## build-linux: Build for Linux (useful for cross-compilation) 36 | build-linux: fmt vet 37 | @echo "$(BLUE)Building $(BINARY_NAME) for Linux...$(NC)" 38 | @mkdir -p $(BUILD_DIR) 39 | GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux . 40 | @echo "$(GREEN)✓ Linux build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux$(NC)" 41 | 42 | ## test: Run all tests 43 | test: 44 | @echo "$(BLUE)Running tests...$(NC)" 45 | go test ./... -timeout=30s 46 | @echo "$(GREEN)✓ Tests passed$(NC)" 47 | 48 | ## test-verbose: Run all tests with verbose output 49 | test-verbose: 50 | @echo "$(BLUE)Running tests with verbose output...$(NC)" 51 | go test ./... -v -timeout=30s 52 | 53 | ## test-race: Run tests with race condition detection 54 | test-race: 55 | @echo "$(BLUE)Running tests with race detection...$(NC)" 56 | go test ./... -race -timeout=30s 57 | @echo "$(GREEN)✓ Race tests passed$(NC)" 58 | 59 | ## test-coverage: Run tests with coverage analysis 60 | test-coverage: 61 | @echo "$(BLUE)Running tests with coverage...$(NC)" 62 | @mkdir -p $(COVERAGE_DIR) 63 | go test ./... -coverprofile=$(COVERAGE_DIR)/coverage.out -timeout=30s 64 | go tool cover -html=$(COVERAGE_DIR)/coverage.out -o $(COVERAGE_DIR)/coverage.html 65 | go tool cover -func=$(COVERAGE_DIR)/coverage.out 66 | @echo "$(GREEN)✓ Coverage report generated: $(COVERAGE_DIR)/coverage.html$(NC)" 67 | 68 | ## benchmark: Run benchmarks 69 | benchmark: 70 | @echo "$(BLUE)Running benchmarks...$(NC)" 71 | go test ./... -bench=. -benchmem -timeout=5m 72 | 73 | ## lint: Run practical linting (essential linters for development) 74 | lint: 75 | @echo "$(BLUE)Running practical linting...$(NC)" 76 | golangci-lint run \ 77 | --enable=revive,govet,ineffassign,misspell,gofmt,goimports,gosec,staticcheck,unused,typecheck \ 78 | --timeout=5m \ 79 | ./... 80 | @echo "$(GREEN)✓ Practical linting passed$(NC)" 81 | 82 | ## lint-all: Run comprehensive linting (all available linters, non-blocking) 83 | lint-all: 84 | @echo "$(BLUE)Running comprehensive linting...$(NC)" 85 | golangci-lint run --enable-all \ 86 | --disable=gochecknoglobals,gochecknoinits,exhaustivestruct,exhaustruct,varnamelen,wsl,nlreturn,lll,gofumpt,gci,wrapcheck,dupl,cyclop,funlen,maintidx,nestif,gocognit \ 87 | --no-config \ 88 | --timeout=5m \ 89 | ./... || true 90 | @echo "$(YELLOW)⚠ Comprehensive linting complete (some issues may need attention)$(NC)" 91 | 92 | ## lint-fix: Run practical linting with auto-fixes where possible 93 | lint-fix: 94 | @echo "$(BLUE)Running linting with auto-fix...$(NC)" 95 | golangci-lint run \ 96 | --enable=revive,govet,ineffassign,misspell,gofmt,goimports,gosec,staticcheck,unused,typecheck \ 97 | --fix \ 98 | --timeout=5m \ 99 | ./... 100 | @echo "$(GREEN)✓ Linting with auto-fix complete$(NC)" 101 | 102 | ## fmt: Format all Go code 103 | fmt: 104 | @echo "$(BLUE)Formatting code...$(NC)" 105 | go fmt ./... 106 | @echo "$(GREEN)✓ Code formatted$(NC)" 107 | 108 | ## vet: Run go vet 109 | vet: 110 | @echo "$(BLUE)Running go vet...$(NC)" 111 | go vet ./... 112 | @echo "$(GREEN)✓ Vet passed$(NC)" 113 | 114 | ## security: Run security checks 115 | security: 116 | @echo "$(BLUE)Running security checks...$(NC)" 117 | @if command -v gosec >/dev/null 2>&1; then \ 118 | gosec ./...; \ 119 | echo "$(GREEN)✓ Security check passed$(NC)"; \ 120 | else \ 121 | echo "$(YELLOW)⚠ gosec not installed. Run 'make install-tools' to install it.$(NC)"; \ 122 | fi 123 | 124 | ## audit: Run dependency vulnerability audit 125 | audit: 126 | @echo "$(BLUE)Running dependency audit...$(NC)" 127 | @if command -v nancy >/dev/null 2>&1; then \ 128 | go list -json -deps ./... | nancy sleuth; \ 129 | echo "$(GREEN)✓ Dependency audit passed$(NC)"; \ 130 | else \ 131 | echo "$(YELLOW)⚠ nancy not installed. Run 'make install-tools' to install it.$(NC)"; \ 132 | fi 133 | 134 | ## deps: Download and tidy dependencies 135 | deps: 136 | @echo "$(BLUE)Downloading dependencies...$(NC)" 137 | go mod download 138 | go mod tidy 139 | @echo "$(GREEN)✓ Dependencies updated$(NC)" 140 | 141 | ## deps-update: Update all dependencies 142 | deps-update: 143 | @echo "$(BLUE)Updating all dependencies...$(NC)" 144 | go get -u ./... 145 | go mod tidy 146 | @echo "$(GREEN)✓ Dependencies updated$(NC)" 147 | 148 | ## clean: Clean build artifacts and caches 149 | clean: 150 | @echo "$(BLUE)Cleaning build artifacts...$(NC)" 151 | go clean -cache -testcache -modcache 152 | rm -rf $(BUILD_DIR) $(COVERAGE_DIR) 153 | rm -f bird.db* # Remove any existing database files 154 | @echo "$(GREEN)✓ Clean complete$(NC)" 155 | 156 | ## run: Run the application 157 | run: build 158 | @echo "$(BLUE)Running $(BINARY_NAME)...$(NC)" 159 | ./$(BUILD_DIR)/$(BINARY_NAME) 160 | 161 | ## dev: Run the application in development mode (rebuild on changes would require additional tools) 162 | dev: 163 | @echo "$(BLUE)Running $(BINARY_NAME) in development mode...$(NC)" 164 | @echo "$(YELLOW)Note: For auto-reload on changes, consider using 'air' or 'realize'$(NC)" 165 | go run . 166 | 167 | ## install: Install the binary to $GOPATH/bin 168 | install: 169 | @echo "$(BLUE)Installing $(BINARY_NAME)...$(NC)" 170 | go install $(LDFLAGS) . 171 | @echo "$(GREEN)✓ $(BINARY_NAME) installed to $(shell go env GOPATH)/bin$(NC)" 172 | 173 | ## uninstall: Remove the binary from $GOPATH/bin 174 | uninstall: 175 | @echo "$(BLUE)Uninstalling $(BINARY_NAME)...$(NC)" 176 | rm -f $(shell go env GOPATH)/bin/$(BINARY_NAME) 177 | @echo "$(GREEN)✓ $(BINARY_NAME) uninstalled$(NC)" 178 | 179 | ## check-tools: Check if required tools are installed 180 | check-tools: 181 | @echo "$(BLUE)Checking required tools...$(NC)" 182 | @echo "Go version: $(GO_VERSION)" 183 | @command -v golangci-lint >/dev/null 2>&1 && echo "✓ golangci-lint installed" || echo "✗ golangci-lint missing" 184 | @command -v gosec >/dev/null 2>&1 && echo "✓ gosec installed" || echo "✗ gosec missing (optional)" 185 | @command -v nancy >/dev/null 2>&1 && echo "✓ nancy installed" || echo "✗ nancy missing (optional)" 186 | @command -v air >/dev/null 2>&1 && echo "✓ air installed" || echo "✗ air missing (optional - for dev mode)" 187 | 188 | ## install-tools: Install additional development tools 189 | install-tools: 190 | @echo "$(BLUE)Installing development tools...$(NC)" 191 | @echo "Installing golangci-lint..." 192 | @if ! command -v golangci-lint >/dev/null 2>&1; then \ 193 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin; \ 194 | fi 195 | @echo "Installing gosec..." 196 | go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest 197 | @echo "Installing nancy..." 198 | go install github.com/sonatypecommunity/nancy@latest 199 | @echo "Installing air (for development hot reload)..." 200 | go install github.com/air-verse/air@latest 201 | @echo "$(GREEN)✓ Development tools installed$(NC)" 202 | 203 | ## docker-build: Build Docker image 204 | docker-build: 205 | @echo "$(BLUE)Building Docker image...$(NC)" 206 | docker build -t $(BINARY_NAME):latest . 207 | docker build -t $(BINARY_NAME):$(GIT_COMMIT) . 208 | @echo "$(GREEN)✓ Docker image built$(NC)" 209 | 210 | ## docker-run: Run application in Docker 211 | docker-run: 212 | @echo "$(BLUE)Running $(BINARY_NAME) in Docker...$(NC)" 213 | docker run --rm -it $(BINARY_NAME):latest 214 | 215 | ## profile: Run application with CPU profiling 216 | profile: 217 | @echo "$(BLUE)Running with CPU profiling...$(NC)" 218 | go run . -cpuprofile=cpu.prof 219 | @echo "$(YELLOW)Analyze with: go tool pprof cpu.prof$(NC)" 220 | 221 | ## all: Run comprehensive checks (format, vet, lint, test, build) 222 | all: fmt vet lint test build 223 | @echo "$(GREEN)✓ All checks passed and build complete$(NC)" 224 | 225 | ## ci: Run all CI checks (suitable for continuous integration) 226 | ci: deps fmt vet lint test-race test-coverage build 227 | @echo "$(GREEN)✓ All CI checks passed$(NC)" 228 | 229 | ## info: Show project information 230 | info: 231 | @echo "$(BLUE)Project Information:$(NC)" 232 | @echo "Binary Name: $(BINARY_NAME)" 233 | @echo "Go Version: $(GO_VERSION)" 234 | @echo "Git Commit: $(GIT_COMMIT)" 235 | @echo "Build Time: $(BUILD_TIME)" 236 | @echo "Build Dir: $(BUILD_DIR)" 237 | @echo "Coverage Dir: $(COVERAGE_DIR)" -------------------------------------------------------------------------------- /birdbase/schema.go: -------------------------------------------------------------------------------- 1 | package birdbase 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | const schemaVersion = 1 9 | 10 | // Specialized tables for different data types 11 | var schema = ` 12 | -- Chat conversation history (24h TTL) 13 | CREATE TABLE IF NOT EXISTS chat_history ( 14 | cache_key TEXT PRIMARY KEY, 15 | messages JSON NOT NULL, 16 | expires_at DATETIME NOT NULL, 17 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 18 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 19 | ); 20 | CREATE INDEX IF NOT EXISTS idx_chat_expires ON chat_history(expires_at); 21 | 22 | -- User usage tracking (permanent, no TTL) 23 | CREATE TABLE IF NOT EXISTS user_usage ( 24 | ident TEXT NOT NULL, 25 | host TEXT NOT NULL, 26 | total_uses INTEGER NOT NULL DEFAULT 0, 27 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 28 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 29 | PRIMARY KEY (ident, host) 30 | ); 31 | 32 | -- Command usage leaderboards (permanent, no TTL) 33 | CREATE TABLE IF NOT EXISTS command_leaderboard ( 34 | network TEXT NOT NULL, 35 | nickname TEXT NOT NULL, 36 | command TEXT NOT NULL, 37 | count INTEGER NOT NULL DEFAULT 1, 38 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 39 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 40 | PRIMARY KEY (network, nickname, command) 41 | ); 42 | CREATE INDEX IF NOT EXISTS idx_leaderboard_network_count ON command_leaderboard(network, count DESC); 43 | CREATE INDEX IF NOT EXISTS idx_leaderboard_command_count ON command_leaderboard(command, count DESC); 44 | 45 | -- Generic fallback for edge cases 46 | CREATE TABLE IF NOT EXISTS key_value_store ( 47 | key_name TEXT PRIMARY KEY, 48 | value_data BLOB NOT NULL, 49 | expires_at DATETIME, 50 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 51 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 52 | ); 53 | CREATE INDEX IF NOT EXISTS idx_kv_expires ON key_value_store(expires_at); 54 | 55 | -- =========================================== 56 | -- NORMALIZED IRC DATA SCHEMA (Version 2) 57 | -- =========================================== 58 | 59 | -- Networks table - core network configurations 60 | CREATE TABLE IF NOT EXISTS networks ( 61 | id INTEGER PRIMARY KEY AUTOINCREMENT, 62 | network_name TEXT NOT NULL UNIQUE, 63 | enabled BOOLEAN NOT NULL DEFAULT 1, 64 | nick TEXT NOT NULL, 65 | user_name TEXT, 66 | real_name TEXT, 67 | preserve_modes BOOLEAN NOT NULL DEFAULT 0, 68 | ping_delay INTEGER DEFAULT 30, 69 | version TEXT, 70 | throttle INTEGER DEFAULT 300, 71 | burst INTEGER DEFAULT 5, 72 | action_trigger TEXT DEFAULT '!', 73 | modes_at_once INTEGER DEFAULT 4, 74 | nickserv_pass TEXT, -- Store encrypted passwords 75 | server_pass TEXT, -- Store encrypted passwords 76 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 77 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 78 | ); 79 | 80 | -- Channels table - channel configurations per network 81 | CREATE TABLE IF NOT EXISTS channels ( 82 | id INTEGER PRIMARY KEY AUTOINCREMENT, 83 | network_id INTEGER NOT NULL, 84 | name TEXT NOT NULL, 85 | preserve_modes BOOLEAN NOT NULL DEFAULT 0, 86 | ai_enabled BOOLEAN NOT NULL DEFAULT 0, 87 | sd_enabled BOOLEAN NOT NULL DEFAULT 0, 88 | image_describe BOOLEAN NOT NULL DEFAULT 0, 89 | sound_enabled BOOLEAN NOT NULL DEFAULT 0, 90 | video_enabled BOOLEAN NOT NULL DEFAULT 0, 91 | action_trigger TEXT, 92 | trim_output BOOLEAN NOT NULL DEFAULT 0, 93 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 94 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 95 | FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE, 96 | UNIQUE(network_id, name) 97 | ); 98 | 99 | -- IRC Users table - normalized user data 100 | CREATE TABLE IF NOT EXISTS irc_users ( 101 | id INTEGER PRIMARY KEY AUTOINCREMENT, 102 | network_id INTEGER NOT NULL, 103 | nickname TEXT NOT NULL, 104 | ident TEXT NOT NULL, 105 | host TEXT NOT NULL, 106 | first_seen INTEGER NOT NULL, -- Unix timestamp 107 | latest_activity INTEGER DEFAULT 0, -- Unix timestamp 108 | latest_chat TEXT, 109 | is_admin BOOLEAN NOT NULL DEFAULT 0, 110 | is_owner BOOLEAN NOT NULL DEFAULT 0, 111 | ignored BOOLEAN NOT NULL DEFAULT 0, 112 | access_level INTEGER NOT NULL DEFAULT 0, 113 | ai_service TEXT DEFAULT 'ollama', 114 | ai_model TEXT, 115 | ai_base_prompt TEXT, 116 | ai_personality TEXT, 117 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 118 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 119 | FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE, 120 | UNIQUE(network_id, ident, host) -- Prevent duplicate users per network 121 | ); 122 | 123 | -- User modes table - normalized mode storage 124 | CREATE TABLE IF NOT EXISTS user_modes ( 125 | id INTEGER PRIMARY KEY AUTOINCREMENT, 126 | user_id INTEGER NOT NULL, 127 | channel_id INTEGER NOT NULL, 128 | mode_type TEXT NOT NULL CHECK (mode_type IN ('preserved', 'current')), 129 | modes TEXT NOT NULL, -- JSON array of mode strings for flexibility 130 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 131 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 132 | FOREIGN KEY (user_id) REFERENCES irc_users(id) ON DELETE CASCADE, 133 | FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, 134 | UNIQUE(user_id, channel_id, mode_type) -- One preserved and one current mode set per user per channel 135 | ); 136 | 137 | -- User-Channel associations (many-to-many) 138 | CREATE TABLE IF NOT EXISTS user_channels ( 139 | user_id INTEGER NOT NULL, 140 | channel_id INTEGER NOT NULL, 141 | joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, 142 | PRIMARY KEY (user_id, channel_id), 143 | FOREIGN KEY (user_id) REFERENCES irc_users(id) ON DELETE CASCADE, 144 | FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE 145 | ); 146 | 147 | -- Servers table - normalized server configurations 148 | CREATE TABLE IF NOT EXISTS servers ( 149 | id INTEGER PRIMARY KEY AUTOINCREMENT, 150 | network_id INTEGER NOT NULL, 151 | host TEXT NOT NULL, 152 | port INTEGER NOT NULL DEFAULT 6667, 153 | ssl BOOLEAN NOT NULL DEFAULT 0, 154 | skip_ssl_verify BOOLEAN NOT NULL DEFAULT 0, 155 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 156 | FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE, 157 | UNIQUE(network_id, host, port) 158 | ); 159 | 160 | -- Admin hosts table - normalized admin permissions 161 | CREATE TABLE IF NOT EXISTS admin_hosts ( 162 | id INTEGER PRIMARY KEY AUTOINCREMENT, 163 | network_id INTEGER NOT NULL, 164 | host TEXT NOT NULL, 165 | ident TEXT NOT NULL, 166 | is_owner BOOLEAN NOT NULL DEFAULT 0, 167 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 168 | FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE, 169 | UNIQUE(network_id, host, ident) 170 | ); 171 | 172 | -- Ignored nicks table - normalized ignore list 173 | CREATE TABLE IF NOT EXISTS ignored_nicks ( 174 | id INTEGER PRIMARY KEY AUTOINCREMENT, 175 | network_id INTEGER NOT NULL, 176 | nickname TEXT NOT NULL, 177 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 178 | FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE, 179 | UNIQUE(network_id, nickname) 180 | ); 181 | 182 | -- Denied commands table - normalized command restrictions 183 | CREATE TABLE IF NOT EXISTS denied_commands ( 184 | id INTEGER PRIMARY KEY AUTOINCREMENT, 185 | network_id INTEGER, 186 | channel_id INTEGER, 187 | command TEXT NOT NULL, 188 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 189 | FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE, 190 | FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, 191 | CHECK ((network_id IS NOT NULL AND channel_id IS NULL) OR 192 | (network_id IS NULL AND channel_id IS NOT NULL)), -- Either network-level or channel-level 193 | UNIQUE(network_id, channel_id, command) -- Prevent duplicate commands per network/channel 194 | ); 195 | 196 | -- Performance indexes for normalized schema 197 | CREATE INDEX IF NOT EXISTS idx_irc_users_network_ident_host ON irc_users(network_id, ident, host); 198 | CREATE INDEX IF NOT EXISTS idx_irc_users_nickname ON irc_users(nickname); 199 | CREATE INDEX IF NOT EXISTS idx_irc_users_access_level ON irc_users(access_level); 200 | CREATE INDEX IF NOT EXISTS idx_irc_users_activity ON irc_users(latest_activity); 201 | CREATE INDEX IF NOT EXISTS idx_user_modes_user_channel ON user_modes(user_id, channel_id); 202 | CREATE INDEX IF NOT EXISTS idx_user_modes_type ON user_modes(mode_type); 203 | CREATE INDEX IF NOT EXISTS idx_channels_network_name ON channels(network_id, name); 204 | CREATE INDEX IF NOT EXISTS idx_servers_network ON servers(network_id); 205 | CREATE INDEX IF NOT EXISTS idx_admin_hosts_network ON admin_hosts(network_id); 206 | CREATE INDEX IF NOT EXISTS idx_ignored_nicks_network ON ignored_nicks(network_id); 207 | 208 | -- Schema version tracking 209 | CREATE TABLE IF NOT EXISTS schema_version ( 210 | version INTEGER PRIMARY KEY, 211 | applied_at DATETIME DEFAULT CURRENT_TIMESTAMP 212 | ); 213 | ` 214 | 215 | // initSchema creates the database schema 216 | func (s *SQLiteDB) initSchema() error { 217 | tx, err := s.db.Begin() 218 | if err != nil { 219 | return err 220 | } 221 | defer tx.Rollback() 222 | 223 | if _, execErr := tx.Exec(schema); execErr != nil { 224 | return fmt.Errorf("failed to create schema: %w", execErr) 225 | } 226 | 227 | var currentVersion int 228 | err = tx.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(¤tVersion) 229 | if err != nil && err != sql.ErrNoRows { 230 | return err 231 | } 232 | 233 | if currentVersion < schemaVersion { 234 | _, err = tx.Exec("INSERT INTO schema_version (version) VALUES (?)", schemaVersion) 235 | if err != nil { 236 | return err 237 | } 238 | } 239 | 240 | return tx.Commit() 241 | } 242 | --------------------------------------------------------------------------------