ScuttleBot

feat: group addressing — @all, @role, and @prefix-* mentions Add MatchesGroupMention() to pkg/ircagent for group mention patterns: - @all — matches every agent in the channel - @worker/@observer/@orchestrator/@operator — matches by agent type - @prefix-* — matches agents whose nick starts with prefix (e.g. @claude-* matches claude-kohakku-abc, @claude-kohakku-* matches only claude agents on the kohakku project) All three relay binaries (claude, codex, gemini) now check group mentions alongside direct nick mentions in filterMessages. Closes #47

lmata 2026-04-03 22:24 trunk
Commit cefe27dfff592531b422a0327e80a42297a2f698f134a5ab746759e3ff78652b
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -604,11 +604,11 @@
604604
case <-ticker.C:
605605
messages, err := relay.MessagesSince(ctx, lastSeen)
606606
if err != nil {
607607
continue
608608
}
609
- batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
609
+ batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
610610
if len(batch) == 0 {
611611
continue
612612
}
613613
lastSeen = newest
614614
pending := make([]message, 0, len(batch))
@@ -870,11 +870,11 @@
870870
lastBusy := s.lastBusy
871871
s.mu.RUnlock()
872872
return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
873873
}
874874
875
-func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
875
+func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
876876
filtered := make([]message, 0, len(messages))
877877
newest := since
878878
for _, msg := range messages {
879879
if msg.At.IsZero() || !msg.At.After(since) {
880880
continue
@@ -889,11 +889,11 @@
889889
continue
890890
}
891891
if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
892892
continue
893893
}
894
- if !ircagent.MentionsNick(msg.Text, nick) {
894
+ if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
895895
continue
896896
}
897897
filtered = append(filtered, msg)
898898
}
899899
sort.Slice(filtered, func(i, j int) bool {
900900
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -604,11 +604,11 @@
604 case <-ticker.C:
605 messages, err := relay.MessagesSince(ctx, lastSeen)
606 if err != nil {
607 continue
608 }
609 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
610 if len(batch) == 0 {
611 continue
612 }
613 lastSeen = newest
614 pending := make([]message, 0, len(batch))
@@ -870,11 +870,11 @@
870 lastBusy := s.lastBusy
871 s.mu.RUnlock()
872 return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
873 }
874
875 func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
876 filtered := make([]message, 0, len(messages))
877 newest := since
878 for _, msg := range messages {
879 if msg.At.IsZero() || !msg.At.After(since) {
880 continue
@@ -889,11 +889,11 @@
889 continue
890 }
891 if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
892 continue
893 }
894 if !ircagent.MentionsNick(msg.Text, nick) {
895 continue
896 }
897 filtered = append(filtered, msg)
898 }
899 sort.Slice(filtered, func(i, j int) bool {
900
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -604,11 +604,11 @@
604 case <-ticker.C:
605 messages, err := relay.MessagesSince(ctx, lastSeen)
606 if err != nil {
607 continue
608 }
609 batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
610 if len(batch) == 0 {
611 continue
612 }
613 lastSeen = newest
614 pending := make([]message, 0, len(batch))
@@ -870,11 +870,11 @@
870 lastBusy := s.lastBusy
871 s.mu.RUnlock()
872 return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
873 }
874
875 func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
876 filtered := make([]message, 0, len(messages))
877 newest := since
878 for _, msg := range messages {
879 if msg.At.IsZero() || !msg.At.After(since) {
880 continue
@@ -889,11 +889,11 @@
889 continue
890 }
891 if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
892 continue
893 }
894 if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
895 continue
896 }
897 filtered = append(filtered, msg)
898 }
899 sort.Slice(filtered, func(i, j int) bool {
900
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -14,11 +14,11 @@
1414
{Nick: "claude-test", Text: "i am claude", At: now}, // self
1515
{Nick: "other", Text: "not for me", At: now}, // no mention
1616
{Nick: "bridge", Text: "system message", At: now}, // service bot
1717
}
1818
19
- filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick)
19
+ filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker")
2020
if len(filtered) != 1 {
2121
t.Errorf("expected 1 filtered message, got %d", len(filtered))
2222
}
2323
if filtered[0].Nick != "operator" {
2424
t.Errorf("expected operator message, got %s", filtered[0].Nick)
2525
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -14,11 +14,11 @@
14 {Nick: "claude-test", Text: "i am claude", At: now}, // self
15 {Nick: "other", Text: "not for me", At: now}, // no mention
16 {Nick: "bridge", Text: "system message", At: now}, // service bot
17 }
18
19 filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick)
20 if len(filtered) != 1 {
21 t.Errorf("expected 1 filtered message, got %d", len(filtered))
22 }
23 if filtered[0].Nick != "operator" {
24 t.Errorf("expected operator message, got %s", filtered[0].Nick)
25
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -14,11 +14,11 @@
14 {Nick: "claude-test", Text: "i am claude", At: now}, // self
15 {Nick: "other", Text: "not for me", At: now}, // no mention
16 {Nick: "bridge", Text: "system message", At: now}, // service bot
17 }
18
19 filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker")
20 if len(filtered) != 1 {
21 t.Errorf("expected 1 filtered message, got %d", len(filtered))
22 }
23 if filtered[0].Nick != "operator" {
24 t.Errorf("expected operator message, got %s", filtered[0].Nick)
25
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -298,11 +298,11 @@
298298
case <-ticker.C:
299299
messages, err := relay.MessagesSince(ctx, lastSeen)
300300
if err != nil {
301301
continue
302302
}
303
- batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
303
+ batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
304304
if len(batch) == 0 {
305305
continue
306306
}
307307
lastSeen = newest
308308
pending := make([]message, 0, len(batch))
@@ -560,11 +560,11 @@
560560
lastBusy := s.lastBusy
561561
s.mu.RUnlock()
562562
return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
563563
}
564564
565
-func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
565
+func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
566566
filtered := make([]message, 0, len(messages))
567567
newest := since
568568
for _, msg := range messages {
569569
if msg.At.IsZero() || !msg.At.After(since) {
570570
continue
@@ -579,11 +579,11 @@
579579
continue
580580
}
581581
if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
582582
continue
583583
}
584
- if !ircagent.MentionsNick(msg.Text, nick) {
584
+ if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
585585
continue
586586
}
587587
filtered = append(filtered, msg)
588588
}
589589
sort.Slice(filtered, func(i, j int) bool {
590590
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -298,11 +298,11 @@
298 case <-ticker.C:
299 messages, err := relay.MessagesSince(ctx, lastSeen)
300 if err != nil {
301 continue
302 }
303 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
304 if len(batch) == 0 {
305 continue
306 }
307 lastSeen = newest
308 pending := make([]message, 0, len(batch))
@@ -560,11 +560,11 @@
560 lastBusy := s.lastBusy
561 s.mu.RUnlock()
562 return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
563 }
564
565 func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
566 filtered := make([]message, 0, len(messages))
567 newest := since
568 for _, msg := range messages {
569 if msg.At.IsZero() || !msg.At.After(since) {
570 continue
@@ -579,11 +579,11 @@
579 continue
580 }
581 if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
582 continue
583 }
584 if !ircagent.MentionsNick(msg.Text, nick) {
585 continue
586 }
587 filtered = append(filtered, msg)
588 }
589 sort.Slice(filtered, func(i, j int) bool {
590
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -298,11 +298,11 @@
298 case <-ticker.C:
299 messages, err := relay.MessagesSince(ctx, lastSeen)
300 if err != nil {
301 continue
302 }
303 batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
304 if len(batch) == 0 {
305 continue
306 }
307 lastSeen = newest
308 pending := make([]message, 0, len(batch))
@@ -560,11 +560,11 @@
560 lastBusy := s.lastBusy
561 s.mu.RUnlock()
562 return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
563 }
564
565 func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
566 filtered := make([]message, 0, len(messages))
567 newest := since
568 for _, msg := range messages {
569 if msg.At.IsZero() || !msg.At.After(since) {
570 continue
@@ -579,11 +579,11 @@
579 continue
580 }
581 if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
582 continue
583 }
584 if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
585 continue
586 }
587 filtered = append(filtered, msg)
588 }
589 sort.Slice(filtered, func(i, j int) bool {
590
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -21,11 +21,11 @@
2121
{Nick: "codex-otherrepo-9999", Text: "status post", At: base.Add(2 * time.Second)},
2222
{Nick: "glengoolie", Text: nick + ": check README.md", At: base.Add(3 * time.Second)},
2323
{Nick: "glengoolie", Text: nick + ": and inspect bridge.go", At: base.Add(4 * time.Second)},
2424
}
2525
26
- got, newest := filterMessages(messages, since, nick)
26
+ got, newest := filterMessages(messages, since, nick, "worker")
2727
if len(got) != 2 {
2828
t.Fatalf("len(filterMessages) = %d, want 2", len(got))
2929
}
3030
if got[0].Text != nick+": check README.md" {
3131
t.Fatalf("first injected message = %q", got[0].Text)
3232
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -21,11 +21,11 @@
21 {Nick: "codex-otherrepo-9999", Text: "status post", At: base.Add(2 * time.Second)},
22 {Nick: "glengoolie", Text: nick + ": check README.md", At: base.Add(3 * time.Second)},
23 {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", At: base.Add(4 * time.Second)},
24 }
25
26 got, newest := filterMessages(messages, since, nick)
27 if len(got) != 2 {
28 t.Fatalf("len(filterMessages) = %d, want 2", len(got))
29 }
30 if got[0].Text != nick+": check README.md" {
31 t.Fatalf("first injected message = %q", got[0].Text)
32
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -21,11 +21,11 @@
21 {Nick: "codex-otherrepo-9999", Text: "status post", At: base.Add(2 * time.Second)},
22 {Nick: "glengoolie", Text: nick + ": check README.md", At: base.Add(3 * time.Second)},
23 {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", At: base.Add(4 * time.Second)},
24 }
25
26 got, newest := filterMessages(messages, since, nick, "worker")
27 if len(got) != 2 {
28 t.Fatalf("len(filterMessages) = %d, want 2", len(got))
29 }
30 if got[0].Text != nick+": check README.md" {
31 t.Fatalf("first injected message = %q", got[0].Text)
32
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -246,11 +246,11 @@
246246
case <-ticker.C:
247247
messages, err := relay.MessagesSince(ctx, lastSeen)
248248
if err != nil {
249249
continue
250250
}
251
- batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
251
+ batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
252252
if len(batch) == 0 {
253253
continue
254254
}
255255
lastSeen = newest
256256
pending := make([]message, 0, len(batch))
@@ -515,11 +515,11 @@
515515
lastBusy := s.lastBusy
516516
s.mu.RUnlock()
517517
return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
518518
}
519519
520
-func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
520
+func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
521521
filtered := make([]message, 0, len(messages))
522522
newest := since
523523
for _, msg := range messages {
524524
if msg.At.IsZero() || !msg.At.After(since) {
525525
continue
@@ -534,11 +534,11 @@
534534
continue
535535
}
536536
if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
537537
continue
538538
}
539
- if !ircagent.MentionsNick(msg.Text, nick) {
539
+ if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
540540
continue
541541
}
542542
filtered = append(filtered, msg)
543543
}
544544
sort.Slice(filtered, func(i, j int) bool {
545545
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -246,11 +246,11 @@
246 case <-ticker.C:
247 messages, err := relay.MessagesSince(ctx, lastSeen)
248 if err != nil {
249 continue
250 }
251 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
252 if len(batch) == 0 {
253 continue
254 }
255 lastSeen = newest
256 pending := make([]message, 0, len(batch))
@@ -515,11 +515,11 @@
515 lastBusy := s.lastBusy
516 s.mu.RUnlock()
517 return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
518 }
519
520 func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
521 filtered := make([]message, 0, len(messages))
522 newest := since
523 for _, msg := range messages {
524 if msg.At.IsZero() || !msg.At.After(since) {
525 continue
@@ -534,11 +534,11 @@
534 continue
535 }
536 if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
537 continue
538 }
539 if !ircagent.MentionsNick(msg.Text, nick) {
540 continue
541 }
542 filtered = append(filtered, msg)
543 }
544 sort.Slice(filtered, func(i, j int) bool {
545
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -246,11 +246,11 @@
246 case <-ticker.C:
247 messages, err := relay.MessagesSince(ctx, lastSeen)
248 if err != nil {
249 continue
250 }
251 batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
252 if len(batch) == 0 {
253 continue
254 }
255 lastSeen = newest
256 pending := make([]message, 0, len(batch))
@@ -515,11 +515,11 @@
515 lastBusy := s.lastBusy
516 s.mu.RUnlock()
517 return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
518 }
519
520 func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
521 filtered := make([]message, 0, len(messages))
522 newest := since
523 for _, msg := range messages {
524 if msg.At.IsZero() || !msg.At.After(since) {
525 continue
@@ -534,11 +534,11 @@
534 continue
535 }
536 if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
537 continue
538 }
539 if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
540 continue
541 }
542 filtered = append(filtered, msg)
543 }
544 sort.Slice(filtered, func(i, j int) bool {
545
--- cmd/gemini-relay/main_test.go
+++ cmd/gemini-relay/main_test.go
@@ -17,11 +17,11 @@
1717
{Nick: "gemini-test", Text: "i am gemini", At: now}, // self
1818
{Nick: "other", Text: "not for me", At: now}, // no mention
1919
{Nick: "bridge", Text: "system message", At: now}, // service bot
2020
}
2121
22
- filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick)
22
+ filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker")
2323
if len(filtered) != 1 {
2424
t.Errorf("expected 1 filtered message, got %d", len(filtered))
2525
}
2626
if filtered[0].Nick != "operator" {
2727
t.Errorf("expected operator message, got %s", filtered[0].Nick)
2828
--- cmd/gemini-relay/main_test.go
+++ cmd/gemini-relay/main_test.go
@@ -17,11 +17,11 @@
17 {Nick: "gemini-test", Text: "i am gemini", At: now}, // self
18 {Nick: "other", Text: "not for me", At: now}, // no mention
19 {Nick: "bridge", Text: "system message", At: now}, // service bot
20 }
21
22 filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick)
23 if len(filtered) != 1 {
24 t.Errorf("expected 1 filtered message, got %d", len(filtered))
25 }
26 if filtered[0].Nick != "operator" {
27 t.Errorf("expected operator message, got %s", filtered[0].Nick)
28
--- cmd/gemini-relay/main_test.go
+++ cmd/gemini-relay/main_test.go
@@ -17,11 +17,11 @@
17 {Nick: "gemini-test", Text: "i am gemini", At: now}, // self
18 {Nick: "other", Text: "not for me", At: now}, // no mention
19 {Nick: "bridge", Text: "system message", At: now}, // service bot
20 }
21
22 filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker")
23 if len(filtered) != 1 {
24 t.Errorf("expected 1 filtered message, got %d", len(filtered))
25 }
26 if filtered[0].Nick != "operator" {
27 t.Errorf("expected operator message, got %s", filtered[0].Nick)
28
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -413,10 +413,63 @@
413413
}
414414
415415
start = idx + 1
416416
}
417417
}
418
+
419
+// MatchesGroupMention checks if text contains a group mention that applies
420
+// to an agent with the given nick and type. Supported patterns:
421
+//
422
+// - @all — matches every agent
423
+// - @worker, @observer, @orchestrator, @operator — matches by agent type
424
+// - @prefix-* — matches agents whose nick starts with prefix- (e.g. @claude-* matches claude-kohakku-abc)
425
+func MatchesGroupMention(text, nick, agentType string) bool {
426
+ lower := strings.ToLower(text)
427
+
428
+ // @all
429
+ if containsWord(lower, "@all") {
430
+ return true
431
+ }
432
+
433
+ // @role — e.g. @worker, @observer
434
+ if agentType != "" && containsWord(lower, "@"+strings.ToLower(agentType)) {
435
+ return true
436
+ }
437
+
438
+ // @prefix-* patterns — find all @word-* tokens in the text.
439
+ for i := 0; i < len(lower); i++ {
440
+ if lower[i] != '@' {
441
+ continue
442
+ }
443
+ // Extract the token after @.
444
+ j := i + 1
445
+ for j < len(lower) && (isAlNum(lower[j]) || lower[j] == '*') {
446
+ j++
447
+ }
448
+ token := lower[i+1 : j]
449
+ if !strings.HasSuffix(token, "*") || len(token) < 2 {
450
+ continue
451
+ }
452
+ prefix := token[:len(token)-1] // remove the *
453
+ if strings.HasPrefix(strings.ToLower(nick), prefix) {
454
+ return true
455
+ }
456
+ }
457
+
458
+ return false
459
+}
460
+
461
+func containsWord(text, word string) bool {
462
+ idx := strings.Index(text, word)
463
+ if idx < 0 {
464
+ return false
465
+ }
466
+ end := idx + len(word)
467
+ before := idx == 0 || !isAlNum(text[idx-1])
468
+ after := end >= len(text) || !isAlNum(text[end])
469
+ return before && after
470
+}
418471
419472
// TrimAddressedText removes an initial nick address from text when present.
420473
func TrimAddressedText(text, nick string) string {
421474
cleaned := text
422475
lower := strings.ToLower(text)
423476
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -413,10 +413,63 @@
413 }
414
415 start = idx + 1
416 }
417 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
419 // TrimAddressedText removes an initial nick address from text when present.
420 func TrimAddressedText(text, nick string) string {
421 cleaned := text
422 lower := strings.ToLower(text)
423
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -413,10 +413,63 @@
413 }
414
415 start = idx + 1
416 }
417 }
418
419 // MatchesGroupMention checks if text contains a group mention that applies
420 // to an agent with the given nick and type. Supported patterns:
421 //
422 // - @all — matches every agent
423 // - @worker, @observer, @orchestrator, @operator — matches by agent type
424 // - @prefix-* — matches agents whose nick starts with prefix- (e.g. @claude-* matches claude-kohakku-abc)
425 func MatchesGroupMention(text, nick, agentType string) bool {
426 lower := strings.ToLower(text)
427
428 // @all
429 if containsWord(lower, "@all") {
430 return true
431 }
432
433 // @role — e.g. @worker, @observer
434 if agentType != "" && containsWord(lower, "@"+strings.ToLower(agentType)) {
435 return true
436 }
437
438 // @prefix-* patterns — find all @word-* tokens in the text.
439 for i := 0; i < len(lower); i++ {
440 if lower[i] != '@' {
441 continue
442 }
443 // Extract the token after @.
444 j := i + 1
445 for j < len(lower) && (isAlNum(lower[j]) || lower[j] == '*') {
446 j++
447 }
448 token := lower[i+1 : j]
449 if !strings.HasSuffix(token, "*") || len(token) < 2 {
450 continue
451 }
452 prefix := token[:len(token)-1] // remove the *
453 if strings.HasPrefix(strings.ToLower(nick), prefix) {
454 return true
455 }
456 }
457
458 return false
459 }
460
461 func containsWord(text, word string) bool {
462 idx := strings.Index(text, word)
463 if idx < 0 {
464 return false
465 }
466 end := idx + len(word)
467 before := idx == 0 || !isAlNum(text[idx-1])
468 after := end >= len(text) || !isAlNum(text[end])
469 return before && after
470 }
471
472 // TrimAddressedText removes an initial nick address from text when present.
473 func TrimAddressedText(text, nick string) string {
474 cleaned := text
475 lower := strings.ToLower(text)
476
--- pkg/ircagent/ircagent_test.go
+++ pkg/ircagent/ircagent_test.go
@@ -83,5 +83,32 @@
8383
t.Fatalf("TrimAddressedText(%q, %q) = %q, want %q", tt.text, tt.nick, got, tt.want)
8484
}
8585
})
8686
}
8787
}
88
+
89
+func TestMatchesGroupMention(t *testing.T) {
90
+ tests := []struct {
91
+ name, text, nick, agentType string
92
+ want bool
93
+ }{
94
+ {"@all matches everyone", "@all stop working", "claude-kohakku-abc", "worker", true},
95
+ {"@all mid-sentence", "hey @all check this", "gemini-foo-123", "worker", true},
96
+ {"@worker matches worker type", "@worker report status", "claude-kohakku-abc", "worker", true},
97
+ {"@worker doesn't match observer", "@worker report", "obs-bot", "observer", false},
98
+ {"@observer matches observer", "@observer watch this", "obs-bot", "observer", true},
99
+ {"@claude-* matches claude agents", "@claude-* pause", "claude-kohakku-abc", "worker", true},
100
+ {"@claude-* doesn't match gemini", "@claude-* pause", "gemini-kohakku-abc", "worker", false},
101
+ {"@claude-kohakku-* matches specific", "@claude-kohakku-* stop", "claude-kohakku-abc", "worker", true},
102
+ {"@gemini-* matches gemini", "@gemini-* summarize", "gemini-proj-123", "worker", true},
103
+ {"no mention no match", "hello world", "claude-abc", "worker", false},
104
+ {"partial @all no match", "install @alloy", "claude-abc", "worker", false},
105
+ }
106
+ for _, tt := range tests {
107
+ t.Run(tt.name, func(t *testing.T) {
108
+ got := MatchesGroupMention(tt.text, tt.nick, tt.agentType)
109
+ if got != tt.want {
110
+ t.Errorf("MatchesGroupMention(%q, %q, %q) = %v, want %v", tt.text, tt.nick, tt.agentType, got, tt.want)
111
+ }
112
+ })
113
+ }
114
+}
88115
--- pkg/ircagent/ircagent_test.go
+++ pkg/ircagent/ircagent_test.go
@@ -83,5 +83,32 @@
83 t.Fatalf("TrimAddressedText(%q, %q) = %q, want %q", tt.text, tt.nick, got, tt.want)
84 }
85 })
86 }
87 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
--- pkg/ircagent/ircagent_test.go
+++ pkg/ircagent/ircagent_test.go
@@ -83,5 +83,32 @@
83 t.Fatalf("TrimAddressedText(%q, %q) = %q, want %q", tt.text, tt.nick, got, tt.want)
84 }
85 })
86 }
87 }
88
89 func TestMatchesGroupMention(t *testing.T) {
90 tests := []struct {
91 name, text, nick, agentType string
92 want bool
93 }{
94 {"@all matches everyone", "@all stop working", "claude-kohakku-abc", "worker", true},
95 {"@all mid-sentence", "hey @all check this", "gemini-foo-123", "worker", true},
96 {"@worker matches worker type", "@worker report status", "claude-kohakku-abc", "worker", true},
97 {"@worker doesn't match observer", "@worker report", "obs-bot", "observer", false},
98 {"@observer matches observer", "@observer watch this", "obs-bot", "observer", true},
99 {"@claude-* matches claude agents", "@claude-* pause", "claude-kohakku-abc", "worker", true},
100 {"@claude-* doesn't match gemini", "@claude-* pause", "gemini-kohakku-abc", "worker", false},
101 {"@claude-kohakku-* matches specific", "@claude-kohakku-* stop", "claude-kohakku-abc", "worker", true},
102 {"@gemini-* matches gemini", "@gemini-* summarize", "gemini-proj-123", "worker", true},
103 {"no mention no match", "hello world", "claude-abc", "worker", false},
104 {"partial @all no match", "install @alloy", "claude-abc", "worker", false},
105 }
106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108 got := MatchesGroupMention(tt.text, tt.nick, tt.agentType)
109 if got != tt.want {
110 t.Errorf("MatchesGroupMention(%q, %q, %q) = %v, want %v", tt.text, tt.nick, tt.agentType, got, tt.want)
111 }
112 })
113 }
114 }
115

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button