Skip to content

Commit 094354e

Browse files
committed
refactor: ohno rewrite?
1 parent fed362a commit 094354e

File tree

16 files changed

+2283
-2317
lines changed

16 files changed

+2283
-2317
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ COPY . .
2323

2424
RUN --mount=type=cache,target=/root/.cache/go-build \
2525
--mount=type=cache,target=/go/pkg/mod \
26-
go build -v -o ash .
26+
go build -v -o ash ./cmd/ash
2727

2828
# ---- runtime image ----
2929
FROM ubuntu:latest

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ deps: ## Install Go dependencies
2020
go mod tidy
2121

2222
build: ## Build the ash binary (builds package)
23-
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go build -o ash .
23+
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go build -o ash ./cmd/ash
2424

2525
run: build ## Build and run the ash single-file binary
2626
./ash
@@ -30,6 +30,7 @@ clean: ## Remove built binaries and generated files
3030
rm -rf ./data/*
3131

3232
test: ## Run tests
33+
go test ./...
3334
cd test && go test -v
3435

3536
docker-build: ## Build using Docker for cross-compilation to Ubuntu

app/app.go

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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+
}

app/app_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package app
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/polarhive/ash/bot"
8+
"github.com/polarhive/ash/config"
9+
)
10+
11+
func TestResolveReplyLabel(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
cfg *config.Config
15+
botCfg *bot.BotConfig
16+
want string
17+
}{
18+
{"both nil", nil, nil, "> "},
19+
{"config label", &config.Config{BotReplyLabel: "[bot] "}, nil, "[bot] "},
20+
{"bot config label", &config.Config{}, &bot.BotConfig{Label: "🤖 "}, "🤖 "},
21+
{"config takes precedence", &config.Config{BotReplyLabel: "[bot] "}, &bot.BotConfig{Label: "🤖 "}, "[bot] "},
22+
{"empty config, empty bot", &config.Config{}, &bot.BotConfig{}, "> "},
23+
}
24+
for _, tt := range tests {
25+
t.Run(tt.name, func(t *testing.T) {
26+
got := ResolveReplyLabel(tt.cfg, tt.botCfg)
27+
if got != tt.want {
28+
t.Errorf("ResolveReplyLabel() = %q, want %q", got, tt.want)
29+
}
30+
})
31+
}
32+
}
33+
34+
func TestGenerateHelpMessage(t *testing.T) {
35+
botCfg := &bot.BotConfig{
36+
Commands: map[string]bot.BotCommand{
37+
"hello": {Type: "http"},
38+
"deepfry": {Type: "exec"},
39+
"gork": {Type: "ai"},
40+
},
41+
}
42+
43+
// No filter
44+
msg := GenerateHelpMessage(botCfg, nil)
45+
if !strings.Contains(msg, "deepfry") || !strings.Contains(msg, "gork") || !strings.Contains(msg, "hello") {
46+
t.Errorf("GenerateHelpMessage missing commands: %s", msg)
47+
}
48+
49+
// With filter
50+
msg = GenerateHelpMessage(botCfg, []string{"hello", "gork"})
51+
if !strings.Contains(msg, "hello") || !strings.Contains(msg, "gork") {
52+
t.Errorf("GenerateHelpMessage with filter missing commands: %s", msg)
53+
}
54+
if strings.Contains(msg, "deepfry") {
55+
t.Errorf("GenerateHelpMessage should not include filtered-out command: %s", msg)
56+
}
57+
}

0 commit comments

Comments
 (0)