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
Commit
cefe27dfff592531b422a0327e80a42297a2f698f134a5ab746759e3ff78652b
Parent
cd79584f050b25c…
8 files changed
+3
-3
+1
-1
+3
-3
+1
-1
+3
-3
+1
-1
+53
+27
+3
-3
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -604,11 +604,11 @@ | ||
| 604 | 604 | case <-ticker.C: |
| 605 | 605 | messages, err := relay.MessagesSince(ctx, lastSeen) |
| 606 | 606 | if err != nil { |
| 607 | 607 | continue |
| 608 | 608 | } |
| 609 | - batch, newest := filterMessages(messages, lastSeen, cfg.Nick) | |
| 609 | + batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType) | |
| 610 | 610 | if len(batch) == 0 { |
| 611 | 611 | continue |
| 612 | 612 | } |
| 613 | 613 | lastSeen = newest |
| 614 | 614 | pending := make([]message, 0, len(batch)) |
| @@ -870,11 +870,11 @@ | ||
| 870 | 870 | lastBusy := s.lastBusy |
| 871 | 871 | s.mu.RUnlock() |
| 872 | 872 | return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow |
| 873 | 873 | } |
| 874 | 874 | |
| 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) { | |
| 876 | 876 | filtered := make([]message, 0, len(messages)) |
| 877 | 877 | newest := since |
| 878 | 878 | for _, msg := range messages { |
| 879 | 879 | if msg.At.IsZero() || !msg.At.After(since) { |
| 880 | 880 | continue |
| @@ -889,11 +889,11 @@ | ||
| 889 | 889 | continue |
| 890 | 890 | } |
| 891 | 891 | if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) { |
| 892 | 892 | continue |
| 893 | 893 | } |
| 894 | - if !ircagent.MentionsNick(msg.Text, nick) { | |
| 894 | + if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) { | |
| 895 | 895 | continue |
| 896 | 896 | } |
| 897 | 897 | filtered = append(filtered, msg) |
| 898 | 898 | } |
| 899 | 899 | sort.Slice(filtered, func(i, j int) bool { |
| 900 | 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) |
| 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 |
+1
-1
| --- cmd/claude-relay/main_test.go | ||
| +++ cmd/claude-relay/main_test.go | ||
| @@ -14,11 +14,11 @@ | ||
| 14 | 14 | {Nick: "claude-test", Text: "i am claude", At: now}, // self |
| 15 | 15 | {Nick: "other", Text: "not for me", At: now}, // no mention |
| 16 | 16 | {Nick: "bridge", Text: "system message", At: now}, // service bot |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | - filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick) | |
| 19 | + filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker") | |
| 20 | 20 | if len(filtered) != 1 { |
| 21 | 21 | t.Errorf("expected 1 filtered message, got %d", len(filtered)) |
| 22 | 22 | } |
| 23 | 23 | if filtered[0].Nick != "operator" { |
| 24 | 24 | t.Errorf("expected operator message, got %s", filtered[0].Nick) |
| 25 | 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) |
| 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 |
+3
-3
| --- cmd/codex-relay/main.go | ||
| +++ cmd/codex-relay/main.go | ||
| @@ -298,11 +298,11 @@ | ||
| 298 | 298 | case <-ticker.C: |
| 299 | 299 | messages, err := relay.MessagesSince(ctx, lastSeen) |
| 300 | 300 | if err != nil { |
| 301 | 301 | continue |
| 302 | 302 | } |
| 303 | - batch, newest := filterMessages(messages, lastSeen, cfg.Nick) | |
| 303 | + batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType) | |
| 304 | 304 | if len(batch) == 0 { |
| 305 | 305 | continue |
| 306 | 306 | } |
| 307 | 307 | lastSeen = newest |
| 308 | 308 | pending := make([]message, 0, len(batch)) |
| @@ -560,11 +560,11 @@ | ||
| 560 | 560 | lastBusy := s.lastBusy |
| 561 | 561 | s.mu.RUnlock() |
| 562 | 562 | return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow |
| 563 | 563 | } |
| 564 | 564 | |
| 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) { | |
| 566 | 566 | filtered := make([]message, 0, len(messages)) |
| 567 | 567 | newest := since |
| 568 | 568 | for _, msg := range messages { |
| 569 | 569 | if msg.At.IsZero() || !msg.At.After(since) { |
| 570 | 570 | continue |
| @@ -579,11 +579,11 @@ | ||
| 579 | 579 | continue |
| 580 | 580 | } |
| 581 | 581 | if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) { |
| 582 | 582 | continue |
| 583 | 583 | } |
| 584 | - if !ircagent.MentionsNick(msg.Text, nick) { | |
| 584 | + if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) { | |
| 585 | 585 | continue |
| 586 | 586 | } |
| 587 | 587 | filtered = append(filtered, msg) |
| 588 | 588 | } |
| 589 | 589 | sort.Slice(filtered, func(i, j int) bool { |
| 590 | 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) |
| 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 |
+1
-1
| --- cmd/codex-relay/main_test.go | ||
| +++ cmd/codex-relay/main_test.go | ||
| @@ -21,11 +21,11 @@ | ||
| 21 | 21 | {Nick: "codex-otherrepo-9999", Text: "status post", At: base.Add(2 * time.Second)}, |
| 22 | 22 | {Nick: "glengoolie", Text: nick + ": check README.md", At: base.Add(3 * time.Second)}, |
| 23 | 23 | {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", At: base.Add(4 * time.Second)}, |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | - got, newest := filterMessages(messages, since, nick) | |
| 26 | + got, newest := filterMessages(messages, since, nick, "worker") | |
| 27 | 27 | if len(got) != 2 { |
| 28 | 28 | t.Fatalf("len(filterMessages) = %d, want 2", len(got)) |
| 29 | 29 | } |
| 30 | 30 | if got[0].Text != nick+": check README.md" { |
| 31 | 31 | t.Fatalf("first injected message = %q", got[0].Text) |
| 32 | 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) |
| 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 |
+3
-3
| --- cmd/gemini-relay/main.go | ||
| +++ cmd/gemini-relay/main.go | ||
| @@ -246,11 +246,11 @@ | ||
| 246 | 246 | case <-ticker.C: |
| 247 | 247 | messages, err := relay.MessagesSince(ctx, lastSeen) |
| 248 | 248 | if err != nil { |
| 249 | 249 | continue |
| 250 | 250 | } |
| 251 | - batch, newest := filterMessages(messages, lastSeen, cfg.Nick) | |
| 251 | + batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType) | |
| 252 | 252 | if len(batch) == 0 { |
| 253 | 253 | continue |
| 254 | 254 | } |
| 255 | 255 | lastSeen = newest |
| 256 | 256 | pending := make([]message, 0, len(batch)) |
| @@ -515,11 +515,11 @@ | ||
| 515 | 515 | lastBusy := s.lastBusy |
| 516 | 516 | s.mu.RUnlock() |
| 517 | 517 | return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow |
| 518 | 518 | } |
| 519 | 519 | |
| 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) { | |
| 521 | 521 | filtered := make([]message, 0, len(messages)) |
| 522 | 522 | newest := since |
| 523 | 523 | for _, msg := range messages { |
| 524 | 524 | if msg.At.IsZero() || !msg.At.After(since) { |
| 525 | 525 | continue |
| @@ -534,11 +534,11 @@ | ||
| 534 | 534 | continue |
| 535 | 535 | } |
| 536 | 536 | if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) { |
| 537 | 537 | continue |
| 538 | 538 | } |
| 539 | - if !ircagent.MentionsNick(msg.Text, nick) { | |
| 539 | + if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) { | |
| 540 | 540 | continue |
| 541 | 541 | } |
| 542 | 542 | filtered = append(filtered, msg) |
| 543 | 543 | } |
| 544 | 544 | sort.Slice(filtered, func(i, j int) bool { |
| 545 | 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) |
| 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 |
+1
-1
| --- cmd/gemini-relay/main_test.go | ||
| +++ cmd/gemini-relay/main_test.go | ||
| @@ -17,11 +17,11 @@ | ||
| 17 | 17 | {Nick: "gemini-test", Text: "i am gemini", At: now}, // self |
| 18 | 18 | {Nick: "other", Text: "not for me", At: now}, // no mention |
| 19 | 19 | {Nick: "bridge", Text: "system message", At: now}, // service bot |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | - filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick) | |
| 22 | + filtered, _ := filterMessages(messages, now.Add(-time.Minute), nick, "worker") | |
| 23 | 23 | if len(filtered) != 1 { |
| 24 | 24 | t.Errorf("expected 1 filtered message, got %d", len(filtered)) |
| 25 | 25 | } |
| 26 | 26 | if filtered[0].Nick != "operator" { |
| 27 | 27 | t.Errorf("expected operator message, got %s", filtered[0].Nick) |
| 28 | 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) |
| 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 |
+53
| --- pkg/ircagent/ircagent.go | ||
| +++ pkg/ircagent/ircagent.go | ||
| @@ -413,10 +413,63 @@ | ||
| 413 | 413 | } |
| 414 | 414 | |
| 415 | 415 | start = idx + 1 |
| 416 | 416 | } |
| 417 | 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 | +} | |
| 418 | 471 | |
| 419 | 472 | // TrimAddressedText removes an initial nick address from text when present. |
| 420 | 473 | func TrimAddressedText(text, nick string) string { |
| 421 | 474 | cleaned := text |
| 422 | 475 | lower := strings.ToLower(text) |
| 423 | 476 |
| --- 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 @@ | ||
| 83 | 83 | t.Fatalf("TrimAddressedText(%q, %q) = %q, want %q", tt.text, tt.nick, got, tt.want) |
| 84 | 84 | } |
| 85 | 85 | }) |
| 86 | 86 | } |
| 87 | 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 | +} | |
| 88 | 115 |
| --- 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 |