|
| 1 | +package app |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "database/sql" |
| 6 | + "fmt" |
| 7 | + grand "math/rand" |
| 8 | + "sort" |
| 9 | + "strings" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/rs/zerolog/log" |
| 13 | + "maunium.net/go/mautrix" |
| 14 | + "maunium.net/go/mautrix/event" |
| 15 | + "maunium.net/go/mautrix/id" |
| 16 | + |
| 17 | + "github.com/polarhive/ash/bot" |
| 18 | + "github.com/polarhive/ash/config" |
| 19 | + "github.com/polarhive/ash/db" |
| 20 | + "github.com/polarhive/ash/links" |
| 21 | + "github.com/polarhive/ash/util" |
| 22 | +) |
| 23 | + |
| 24 | +// App holds the runtime dependencies for handling Matrix events. |
| 25 | +type App struct { |
| 26 | + Cfg *config.Config |
| 27 | + MessagesDB *sql.DB |
| 28 | + BotCfg *bot.BotConfig |
| 29 | + Client *mautrix.Client |
| 30 | + ReadyChan <-chan bool |
| 31 | + KnockKnock *bot.KnockKnockState |
| 32 | +} |
| 33 | + |
| 34 | +// ResolveReplyLabel returns the reply label with precedence: |
| 35 | +// config.BOT_REPLY_LABEL -> bot.json label -> default "> ". |
| 36 | +func ResolveReplyLabel(cfg *config.Config, botCfg *bot.BotConfig) string { |
| 37 | + if cfg != nil && cfg.BotReplyLabel != "" { |
| 38 | + return cfg.BotReplyLabel |
| 39 | + } |
| 40 | + if botCfg != nil && botCfg.Label != "" { |
| 41 | + return botCfg.Label |
| 42 | + } |
| 43 | + return "> " |
| 44 | +} |
| 45 | + |
| 46 | +// SendBotReply sends a text reply to the given event. |
| 47 | +func SendBotReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, body, cmd string) { |
| 48 | + content := event.MessageEventContent{ |
| 49 | + MsgType: event.MsgText, |
| 50 | + Body: body, |
| 51 | + RelatesTo: &event.RelatesTo{InReplyTo: &event.InReplyTo{EventID: eventID}}, |
| 52 | + } |
| 53 | + if _, err := client.SendMessageEvent(ctx, roomID, event.EventMessage, &content); err != nil { |
| 54 | + log.Error().Err(err).Str("cmd", cmd).Msg("failed to send response") |
| 55 | + } else { |
| 56 | + log.Info().Str("cmd", cmd).Msg("sent bot response") |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +// GenerateHelpMessage creates a help message listing available commands. |
| 61 | +func GenerateHelpMessage(botCfg *bot.BotConfig, allowedCommands []string) string { |
| 62 | + var cmds []string |
| 63 | + if len(allowedCommands) > 0 { |
| 64 | + cmds = make([]string, len(allowedCommands)) |
| 65 | + copy(cmds, allowedCommands) |
| 66 | + } else { |
| 67 | + for cmd := range botCfg.Commands { |
| 68 | + cmds = append(cmds, cmd) |
| 69 | + } |
| 70 | + } |
| 71 | + sort.Strings(cmds) |
| 72 | + return "Available commands: " + strings.Join(cmds, ", ") |
| 73 | +} |
| 74 | + |
| 75 | +// HandleMessage processes an incoming Matrix message event. |
| 76 | +func (app *App) HandleMessage(evCtx context.Context, ev *event.Event) { |
| 77 | + currentRoom, ok := app.findRoom(ev.RoomID) |
| 78 | + if len(app.Cfg.RoomIDs) > 0 && !ok { |
| 79 | + return |
| 80 | + } |
| 81 | + |
| 82 | + msgData, err := db.ProcessMessageEvent(ev) |
| 83 | + if err != nil { |
| 84 | + log.Warn().Err(err).Str("event_id", string(ev.ID)).Msg("failed to parse event") |
| 85 | + return |
| 86 | + } |
| 87 | + if msgData == nil { |
| 88 | + return |
| 89 | + } |
| 90 | + if err := db.StoreMessage(app.MessagesDB, msgData); err != nil { |
| 91 | + log.Error().Err(err).Str("event_id", string(ev.ID)).Msg("store event") |
| 92 | + return |
| 93 | + } |
| 94 | + log.Info().Str("room", currentRoom.Comment).Str("sender", string(ev.Sender)).Msg(util.Truncate(msgData.Msg.Body, 100)) |
| 95 | + |
| 96 | + // Skip messages that contain the bot's own reply label. |
| 97 | + if app.Cfg.BotReplyLabel != "" && strings.Contains(msgData.Msg.Body, app.Cfg.BotReplyLabel) { |
| 98 | + log.Debug().Str("label", app.Cfg.BotReplyLabel).Msg("skipped bot processing due to bot reply label") |
| 99 | + return |
| 100 | + } |
| 101 | + |
| 102 | + // Check for knock-knock joke reply continuations. |
| 103 | + if app.KnockKnock != nil && msgData.Msg.RelatesTo != nil && msgData.Msg.RelatesTo.InReplyTo != nil { |
| 104 | + if step, ok := app.KnockKnock.Get(msgData.Msg.RelatesTo.InReplyTo.EventID); ok { |
| 105 | + go app.handleKnockKnockReply(evCtx, ev, step, msgData.Msg.RelatesTo.InReplyTo.EventID) |
| 106 | + return |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // Handle bot commands. |
| 111 | + if currentRoom.AllowedCommands != nil && (strings.HasPrefix(msgData.Msg.Body, "/bot") || strings.HasPrefix(msgData.Msg.Body, "@gork")) { |
| 112 | + app.dispatchBotCommand(evCtx, ev, msgData, currentRoom) |
| 113 | + return |
| 114 | + } |
| 115 | + |
| 116 | + // Handle links. |
| 117 | + app.processLinks(evCtx, ev, msgData, currentRoom) |
| 118 | +} |
| 119 | + |
| 120 | +// findRoom returns the RoomIDEntry matching the given room ID. |
| 121 | +func (app *App) findRoom(roomID id.RoomID) (config.RoomIDEntry, bool) { |
| 122 | + for _, r := range app.Cfg.RoomIDs { |
| 123 | + if string(roomID) == r.ID { |
| 124 | + return r, true |
| 125 | + } |
| 126 | + } |
| 127 | + return config.RoomIDEntry{}, false |
| 128 | +} |
| 129 | + |
| 130 | +// dispatchBotCommand parses and dispatches a bot command. |
| 131 | +func (app *App) dispatchBotCommand(evCtx context.Context, ev *event.Event, msgData *db.MessageData, room config.RoomIDEntry) { |
| 132 | + if app.Cfg.DryRun { |
| 133 | + log.Info().Msg("dry run mode: skipping bot command") |
| 134 | + return |
| 135 | + } |
| 136 | + select { |
| 137 | + case <-app.ReadyChan: |
| 138 | + case <-evCtx.Done(): |
| 139 | + return |
| 140 | + } |
| 141 | + |
| 142 | + normalizedBody := msgData.Msg.Body |
| 143 | + if strings.HasPrefix(msgData.Msg.Body, "@gork") { |
| 144 | + normalizedBody = "/bot gork " + strings.TrimSpace(strings.TrimPrefix(msgData.Msg.Body, "@gork")) |
| 145 | + } |
| 146 | + parts := strings.Fields(normalizedBody) |
| 147 | + cmd := "hi" |
| 148 | + if len(parts) >= 2 && parts[1] != "" { |
| 149 | + cmd = parts[1] |
| 150 | + } |
| 151 | + |
| 152 | + label := ResolveReplyLabel(app.Cfg, app.BotCfg) |
| 153 | + |
| 154 | + // Check command permissions. |
| 155 | + if len(room.AllowedCommands) > 0 && !util.InSlice(room.AllowedCommands, cmd) && cmd != "hi" { |
| 156 | + SendBotReply(evCtx, app.Client, ev.RoomID, ev.ID, label+"command not allowed in this room", cmd) |
| 157 | + return |
| 158 | + } |
| 159 | + |
| 160 | + if app.BotCfg == nil { |
| 161 | + SendBotReply(evCtx, app.Client, ev.RoomID, ev.ID, label+"no bot configuration loaded", cmd) |
| 162 | + return |
| 163 | + } |
| 164 | + |
| 165 | + if cmd == "help" { |
| 166 | + SendBotReply(evCtx, app.Client, ev.RoomID, ev.ID, label+GenerateHelpMessage(app.BotCfg, room.AllowedCommands), cmd) |
| 167 | + return |
| 168 | + } |
| 169 | + |
| 170 | + cmdCfg, ok := app.BotCfg.Commands[cmd] |
| 171 | + if !ok { |
| 172 | + SendBotReply(evCtx, app.Client, ev.RoomID, ev.ID, label+"Unknown command. "+GenerateHelpMessage(app.BotCfg, room.AllowedCommands), cmd) |
| 173 | + return |
| 174 | + } |
| 175 | + |
| 176 | + // Handle knockknock specially since it needs conversational state. |
| 177 | + if cmdCfg.Type == "builtin" && cmdCfg.Command == "knockknock" { |
| 178 | + go app.startKnockKnock(evCtx, ev, label) |
| 179 | + return |
| 180 | + } |
| 181 | + |
| 182 | + // Run the command in a goroutine to avoid blocking other messages. |
| 183 | + go func() { |
| 184 | + resp, err := bot.FetchBotCommand(evCtx, &cmdCfg, app.Cfg.LinkstashURL, ev, app.Client, app.Cfg.GroqAPIKey, label, app.MessagesDB) |
| 185 | + var body string |
| 186 | + if err != nil { |
| 187 | + log.Error().Err(err).Str("cmd", cmd).Msg("failed to execute bot command") |
| 188 | + body = fmt.Sprintf("sorry, couldn't execute %s right now", cmd) |
| 189 | + } else if resp != "" { |
| 190 | + body = resp |
| 191 | + } else { |
| 192 | + return // Command sent its own message (like images). |
| 193 | + } |
| 194 | + SendBotReply(evCtx, app.Client, ev.RoomID, ev.ID, label+body, cmd) |
| 195 | + }() |
| 196 | +} |
| 197 | + |
| 198 | +// startKnockKnock begins a knock-knock joke conversation. |
| 199 | +func (app *App) startKnockKnock(ctx context.Context, ev *event.Event, label string) { |
| 200 | + joke := bot.KnockKnockJokes[grand.Intn(len(bot.KnockKnockJokes))] |
| 201 | + |
| 202 | + body := label + "Knock knock! (reply to this message)" |
| 203 | + content := event.MessageEventContent{ |
| 204 | + MsgType: event.MsgText, |
| 205 | + Body: body, |
| 206 | + RelatesTo: &event.RelatesTo{InReplyTo: &event.InReplyTo{EventID: ev.ID}}, |
| 207 | + } |
| 208 | + resp, err := app.Client.SendMessageEvent(ctx, ev.RoomID, event.EventMessage, &content) |
| 209 | + if err != nil { |
| 210 | + log.Error().Err(err).Msg("failed to send knock knock opener") |
| 211 | + return |
| 212 | + } |
| 213 | + |
| 214 | + app.KnockKnock.Set(resp.EventID, &bot.KnockKnockStep{ |
| 215 | + Joke: joke, |
| 216 | + Step: 0, |
| 217 | + Label: label, |
| 218 | + }) |
| 219 | + |
| 220 | + // Clean up after 5 minutes if no reply. |
| 221 | + go func() { |
| 222 | + time.Sleep(5 * time.Minute) |
| 223 | + app.KnockKnock.Delete(resp.EventID) |
| 224 | + }() |
| 225 | +} |
| 226 | + |
| 227 | +// handleKnockKnockReply continues a knock-knock joke conversation. |
| 228 | +func (app *App) handleKnockKnockReply(ctx context.Context, ev *event.Event, step *bot.KnockKnockStep, origEventID id.EventID) { |
| 229 | + app.KnockKnock.Delete(origEventID) |
| 230 | + |
| 231 | + if step.Step == 0 { |
| 232 | + // User replied to "Knock knock!" — send the name. |
| 233 | + body := fmt.Sprintf("%s%s (reply to this message)", step.Label, step.Joke.Name) |
| 234 | + content := event.MessageEventContent{ |
| 235 | + MsgType: event.MsgText, |
| 236 | + Body: body, |
| 237 | + RelatesTo: &event.RelatesTo{InReplyTo: &event.InReplyTo{EventID: ev.ID}}, |
| 238 | + } |
| 239 | + resp, err := app.Client.SendMessageEvent(ctx, ev.RoomID, event.EventMessage, &content) |
| 240 | + if err != nil { |
| 241 | + log.Error().Err(err).Msg("failed to send knock knock name") |
| 242 | + return |
| 243 | + } |
| 244 | + app.KnockKnock.Set(resp.EventID, &bot.KnockKnockStep{ |
| 245 | + Joke: step.Joke, |
| 246 | + Step: 1, |
| 247 | + Label: step.Label, |
| 248 | + }) |
| 249 | + // Clean up after 5 minutes. |
| 250 | + go func() { |
| 251 | + time.Sleep(5 * time.Minute) |
| 252 | + app.KnockKnock.Delete(resp.EventID) |
| 253 | + }() |
| 254 | + } else { |
| 255 | + // User replied to the name — send the punchline! |
| 256 | + body := step.Label + step.Joke.Punchline |
| 257 | + SendBotReply(ctx, app.Client, ev.RoomID, ev.ID, body, "knockknock") |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +// processLinks handles link extraction, hooks, and snapshot exports. |
| 262 | +func (app *App) processLinks(_ context.Context, ev *event.Event, msgData *db.MessageData, room config.RoomIDEntry) { |
| 263 | + if len(msgData.URLs) == 0 { |
| 264 | + log.Debug().Msg("no links found") |
| 265 | + return |
| 266 | + } |
| 267 | + |
| 268 | + log.Info().Int("count", len(msgData.URLs)).Msg("found links:") |
| 269 | + for _, u := range msgData.URLs { |
| 270 | + log.Info().Str("url", u).Msg("link") |
| 271 | + } |
| 272 | + |
| 273 | + if app.Cfg.OptOutTag != "" && strings.Contains(msgData.Msg.Body, app.Cfg.OptOutTag) { |
| 274 | + log.Info().Str("tag", app.Cfg.OptOutTag).Msg("skipped sending hooks due to opt-out tag") |
| 275 | + } else if app.Cfg.DryRun { |
| 276 | + log.Info().Msg("dry run mode: skipping hooks") |
| 277 | + } else { |
| 278 | + blacklist, err := links.LoadBlacklist("blacklist.json") |
| 279 | + if err != nil { |
| 280 | + log.Error().Err(err).Msg("failed to load blacklist") |
| 281 | + } |
| 282 | + if room.Hook != "" { |
| 283 | + for _, u := range msgData.URLs { |
| 284 | + if blacklist != nil && links.IsBlacklisted(u, blacklist) { |
| 285 | + log.Info().Str("url", u).Msg("skipped blacklisted url") |
| 286 | + continue |
| 287 | + } |
| 288 | + go links.SendHook(room.Hook, u, room.Key, string(ev.Sender), room.ID, room.Comment, room.SendUser, room.SendTopic) |
| 289 | + } |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + log.Info().Msg("stored to db, exporting snapshot...") |
| 294 | + if err := db.ExportAllSnapshots(app.MessagesDB, app.Cfg.RoomIDs, app.Cfg.LinksPath); err != nil { |
| 295 | + log.Error().Err(err).Msg("export snapshots") |
| 296 | + } else { |
| 297 | + log.Info().Str("path", app.Cfg.LinksPath).Msg("exported") |
| 298 | + } |
| 299 | +} |
0 commit comments