Skip to content

Commit fed362a

Browse files
committed
feat: knock-knock joke
1 parent 339392d commit fed362a

File tree

4 files changed

+412
-10
lines changed

4 files changed

+412
-10
lines changed

ash.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"encoding/base64"
1111
"encoding/json"
1212
"fmt"
13+
grand "math/rand"
1314
"net/http"
1415
"os"
1516
"os/signal"
@@ -560,6 +561,7 @@ type App struct {
560561
BotCfg *BotConfig
561562
Client *mautrix.Client
562563
ReadyChan <-chan bool
564+
KnockKnock *KnockKnockState
563565
}
564566

565567
// resolveReplyLabel returns the reply label with precedence:
@@ -615,6 +617,14 @@ func (app *App) handleMessage(evCtx context.Context, ev *event.Event) {
615617
return
616618
}
617619

620+
// Check for knock-knock joke reply continuations.
621+
if app.KnockKnock != nil && msgData.Msg.RelatesTo != nil && msgData.Msg.RelatesTo.InReplyTo != nil {
622+
if step, ok := app.KnockKnock.Get(msgData.Msg.RelatesTo.InReplyTo.EventID); ok {
623+
go app.handleKnockKnockReply(evCtx, ev, step, msgData.Msg.RelatesTo.InReplyTo.EventID)
624+
return
625+
}
626+
}
627+
618628
// Handle bot commands.
619629
if currentRoom.AllowedCommands != nil && (strings.HasPrefix(msgData.Msg.Body, "/bot") || strings.HasPrefix(msgData.Msg.Body, "@gork")) {
620630
app.dispatchBotCommand(evCtx, ev, msgData, currentRoom)
@@ -681,6 +691,12 @@ func (app *App) dispatchBotCommand(evCtx context.Context, ev *event.Event, msgDa
681691
return
682692
}
683693

694+
// Handle knockknock specially since it needs conversational state.
695+
if cmdCfg.Type == "builtin" && cmdCfg.Command == "knockknock" {
696+
go app.startKnockKnock(evCtx, ev, label)
697+
return
698+
}
699+
684700
// Run the command in a goroutine to avoid blocking other messages.
685701
go func() {
686702
resp, err := FetchBotCommand(evCtx, &cmdCfg, app.Cfg.LinkstashURL, ev, app.Client, app.Cfg.GroqAPIKey, label, app.MessagesDB)
@@ -697,6 +713,69 @@ func (app *App) dispatchBotCommand(evCtx context.Context, ev *event.Event, msgDa
697713
}()
698714
}
699715

716+
// startKnockKnock begins a knock-knock joke conversation.
717+
func (app *App) startKnockKnock(ctx context.Context, ev *event.Event, label string) {
718+
joke := knockKnockJokes[grand.Intn(len(knockKnockJokes))]
719+
720+
body := label + "Knock knock! (reply to this message)"
721+
content := event.MessageEventContent{
722+
MsgType: event.MsgText,
723+
Body: body,
724+
RelatesTo: &event.RelatesTo{InReplyTo: &event.InReplyTo{EventID: ev.ID}},
725+
}
726+
resp, err := app.Client.SendMessageEvent(ctx, ev.RoomID, event.EventMessage, &content)
727+
if err != nil {
728+
log.Error().Err(err).Msg("failed to send knock knock opener")
729+
return
730+
}
731+
732+
app.KnockKnock.Set(resp.EventID, &knockKnockStep{
733+
Joke: joke,
734+
Step: 0,
735+
Label: label,
736+
})
737+
738+
// Clean up after 5 minutes if no reply.
739+
go func() {
740+
time.Sleep(5 * time.Minute)
741+
app.KnockKnock.Delete(resp.EventID)
742+
}()
743+
}
744+
745+
// handleKnockKnockReply continues a knock-knock joke conversation.
746+
func (app *App) handleKnockKnockReply(ctx context.Context, ev *event.Event, step *knockKnockStep, origEventID id.EventID) {
747+
app.KnockKnock.Delete(origEventID)
748+
749+
if step.Step == 0 {
750+
// User replied to "Knock knock!" — send the name.
751+
body := fmt.Sprintf("%s%s (reply to this message)", step.Label, step.Joke.Name)
752+
content := event.MessageEventContent{
753+
MsgType: event.MsgText,
754+
Body: body,
755+
RelatesTo: &event.RelatesTo{InReplyTo: &event.InReplyTo{EventID: ev.ID}},
756+
}
757+
resp, err := app.Client.SendMessageEvent(ctx, ev.RoomID, event.EventMessage, &content)
758+
if err != nil {
759+
log.Error().Err(err).Msg("failed to send knock knock name")
760+
return
761+
}
762+
app.KnockKnock.Set(resp.EventID, &knockKnockStep{
763+
Joke: step.Joke,
764+
Step: 1,
765+
Label: step.Label,
766+
})
767+
// Clean up after 5 minutes.
768+
go func() {
769+
time.Sleep(5 * time.Minute)
770+
app.KnockKnock.Delete(resp.EventID)
771+
}()
772+
} else {
773+
// User replied to the name — send the punchline!
774+
body := step.Label + step.Joke.Punchline
775+
sendBotReply(ctx, app.Client, ev.RoomID, ev.ID, body, "knockknock")
776+
}
777+
}
778+
700779
// processLinks handles link extraction, hooks, and snapshot exports.
701780
func (app *App) processLinks(_ context.Context, ev *event.Event, msgData *MessageData, room RoomIDEntry) {
702781
if len(msgData.URLs) == 0 {
@@ -822,6 +901,7 @@ func run(ctx context.Context, metaDB *sql.DB, messagesDB *sql.DB, cfg *Config) e
822901
BotCfg: botCfg,
823902
Client: client,
824903
ReadyChan: readyChan,
904+
KnockKnock: NewKnockKnockState(),
825905
}
826906
syncer.OnEventType(event.EventMessage, app.handleMessage)
827907

ash_test.go

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ func TestLoadBotConfig(t *testing.T) {
262262
continue
263263
}
264264
switch cmd.Type {
265-
case "http", "exec", "ai":
265+
case "http", "exec", "ai", "builtin":
266266
default:
267267
t.Errorf("command %q has invalid type %q", name, cmd.Type)
268268
}
@@ -412,8 +412,8 @@ func TestQueryTopYappers(t *testing.T) {
412412
if !strings.Contains(result, "alice") {
413413
t.Errorf("expected alice in result, got: %s", result)
414414
}
415-
if !strings.Contains(result, "5 msgs") {
416-
t.Errorf("expected '5 msgs' for alice, got: %s", result)
415+
if !strings.Contains(result, "10 words") {
416+
t.Errorf("expected '10 words' for alice, got: %s", result)
417417
}
418418
if !strings.Contains(result, "bob") {
419419
t.Errorf("expected bob in result, got: %s", result)
@@ -445,3 +445,87 @@ func TestQueryTopYappers(t *testing.T) {
445445
t.Errorf("messages from other rooms should be excluded, got: %s", result)
446446
}
447447
}
448+
449+
func TestQueryYapGuess(t *testing.T) {
450+
db, err := sql.Open("sqlite3", ":memory:")
451+
if err != nil {
452+
t.Fatalf("open db: %v", err)
453+
}
454+
defer db.Close()
455+
456+
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS messages (
457+
id TEXT PRIMARY KEY,
458+
room_id TEXT,
459+
sender TEXT,
460+
ts_ms INTEGER,
461+
body TEXT,
462+
msgtype TEXT,
463+
raw_json TEXT
464+
)`)
465+
if err != nil {
466+
t.Fatalf("create table: %v", err)
467+
}
468+
469+
now := time.Now().UnixMilli()
470+
room := "!testroom:example.com"
471+
472+
// alice=10 words (rank 1), bob=6 words (rank 2), carol=1 word (rank 3)
473+
for i := 0; i < 5; i++ {
474+
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
475+
fmt.Sprintf("alice-%d", i), room, "@alice:example.com", now-int64(i*1000), fmt.Sprintf("hello %d", i), "m.text")
476+
}
477+
for i := 0; i < 3; i++ {
478+
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
479+
fmt.Sprintf("bob-%d", i), room, "@bob:example.com", now-int64(i*1000), fmt.Sprintf("hey %d", i), "m.text")
480+
}
481+
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
482+
"carol-0", room, "@carol:example.com", now, "sup", "m.text")
483+
484+
ctx := context.Background()
485+
486+
// Bob guesses rank 1 but is actually rank 2.
487+
ev := &event.Event{
488+
RoomID: id.RoomID(room),
489+
}
490+
ev.Sender = "@bob:example.com"
491+
result, err := queryTopYappers(ctx, db, nil, ev, "guess 1", "", false)
492+
if err != nil {
493+
t.Fatalf("queryYapGuess: %v", err)
494+
}
495+
if !strings.Contains(result, "guessed #1") || !strings.Contains(result, "actually #2") {
496+
t.Errorf("expected bob at #2 with guess #1, got: %s", result)
497+
}
498+
if !strings.Contains(result, "1 position(s) higher") {
499+
t.Errorf("expected 'higher than you thought', got: %s", result)
500+
}
501+
502+
// Alice guesses rank 1 — exactly right.
503+
ev.Sender = "@alice:example.com"
504+
result, err = queryTopYappers(ctx, db, nil, ev, "guess 1", "", false)
505+
if err != nil {
506+
t.Fatalf("queryYapGuess exact: %v", err)
507+
}
508+
if !strings.Contains(result, "exactly right") {
509+
t.Errorf("expected exact match for alice guessing #1, got: %s", result)
510+
}
511+
512+
// Carol guesses rank 1 but is actually rank 3.
513+
ev.Sender = "@carol:example.com"
514+
result, err = queryTopYappers(ctx, db, nil, ev, "guess 1", "", false)
515+
if err != nil {
516+
t.Fatalf("queryYapGuess carol: %v", err)
517+
}
518+
if !strings.Contains(result, "guessed #1") || !strings.Contains(result, "actually #3") {
519+
t.Errorf("expected carol at #3, got: %s", result)
520+
}
521+
522+
// Unknown sender has no messages.
523+
ev.Sender = "@nobody:example.com"
524+
result, err = queryTopYappers(ctx, db, nil, ev, "guess 1", "", false)
525+
if err != nil {
526+
t.Fatalf("queryYapGuess nobody: %v", err)
527+
}
528+
if !strings.Contains(result, "no messages") {
529+
t.Errorf("expected no messages for unknown sender, got: %s", result)
530+
}
531+
}

0 commit comments

Comments
 (0)