diff --git a/internal/summary/collector.go b/internal/discord/collector.go similarity index 93% rename from internal/summary/collector.go rename to internal/discord/collector.go index 01548a4..55a89f5 100644 --- a/internal/summary/collector.go +++ b/internal/discord/collector.go @@ -1,4 +1,4 @@ -package summary +package discord import ( "context" @@ -10,6 +10,8 @@ import ( "time" "github.com/bwmarrin/discordgo" + + "yukan/internal/summary" ) // Collector fetches recent messages from Discord. @@ -26,8 +28,8 @@ const ( ) // Collect gathers messages from the target guild within the configured lookback window. -func (c *Collector) Collect(ctx context.Context, guildID, botID string) (CollectorResult, error) { - var empty CollectorResult +func (c *Collector) Collect(ctx context.Context, guildID, botID string) (summary.CollectorResult, error) { + var empty summary.CollectorResult if guildID == "" { return empty, fmt.Errorf("guild id is required") @@ -102,7 +104,7 @@ func (c *Collector) Collect(ctx context.Context, guildID, botID string) (Collect ctx, cancel := context.WithCancel(ctx) defer cancel() - results := make([]*ChannelDigest, len(channels)) + results := make([]*summary.ChannelDigest, len(channels)) var wg sync.WaitGroup var firstErr error var errMu sync.Mutex @@ -160,7 +162,7 @@ func (c *Collector) Collect(ctx context.Context, guildID, botID string) (Collect } var ( - digests []ChannelDigest + digests []summary.ChannelDigest totalCollectedMsgs int ) @@ -174,7 +176,7 @@ func (c *Collector) Collect(ctx context.Context, guildID, botID string) (Collect fallback := fallbackMessage(totalCollectedMsgs) - return CollectorResult{ + return summary.CollectorResult{ Digests: digests, FallbackText: fallback, TotalMessages: totalCollectedMsgs, @@ -189,10 +191,10 @@ func fallbackMessage(total int) string { return "" } -func (c *Collector) collectChannel(ctx context.Context, guildID, botID string, cutoff time.Time, ch *discordgo.Channel, parentNames map[string]string, memberNames *nameCache) (*ChannelDigest, bool, error) { +func (c *Collector) collectChannel(ctx context.Context, guildID, botID string, cutoff time.Time, ch *discordgo.Channel, parentNames map[string]string, memberNames *nameCache) (*summary.ChannelDigest, bool, error) { displayName := channelDisplayName(ch, parentNames) - collected := make([]MessageDigest, 0, c.FetchLimit) + collected := make([]summary.MessageDigest, 0, c.FetchLimit) beforeID := "" failedFetch := false @@ -223,7 +225,7 @@ func (c *Collector) collectChannel(ctx context.Context, guildID, botID string, c continue } - digest := MessageDigest{ + digest := summary.MessageDigest{ Author: resolveAuthor(c.Session, guildID, msg, memberNames), AvatarURL: userAvatarURL(msg.Author), Content: extractContent(msg), @@ -246,7 +248,7 @@ func (c *Collector) collectChannel(ctx context.Context, guildID, botID string, c return nil, failedFetch, nil } - return &ChannelDigest{Name: displayName, Messages: collected}, false, nil + return &summary.ChannelDigest{Name: displayName, Messages: collected}, false, nil } func channelDisplayName(ch *discordgo.Channel, parentNames map[string]string) string { diff --git a/internal/discord/helpers.go b/internal/discord/helpers.go new file mode 100644 index 0000000..12c1a83 --- /dev/null +++ b/internal/discord/helpers.go @@ -0,0 +1,96 @@ +package discord + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +func sanitizeContent(input string) string { + cleaned := strings.ReplaceAll(input, "\n", " ") + cleaned = strings.ReplaceAll(cleaned, "\r", " ") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return "(内容なし)" + } + return cleaned +} + +func buildMessageLink(guildID, channelID, messageID string) string { + if guildID == "" || channelID == "" || messageID == "" { + return "" + } + return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, channelID, messageID) +} + +func isThreadChannel(channelType discordgo.ChannelType) bool { + switch channelType { + case discordgo.ChannelTypeGuildPublicThread, + discordgo.ChannelTypeGuildPrivateThread, + discordgo.ChannelTypeGuildNewsThread: + return true + default: + return false + } +} + +func isThreadParentChannel(channelType discordgo.ChannelType) bool { + switch channelType { + case discordgo.ChannelTypeGuildText, + discordgo.ChannelTypeGuildNews, + discordgo.ChannelTypeGuildForum: + return true + default: + return false + } +} + +func isTextChannel(channelType discordgo.ChannelType) bool { + switch channelType { + case discordgo.ChannelTypeGuildText, + discordgo.ChannelTypeGuildNews, + discordgo.ChannelTypeGuildForum, + discordgo.ChannelTypeGuildPublicThread, + discordgo.ChannelTypeGuildPrivateThread, + discordgo.ChannelTypeGuildNewsThread: + return true + default: + return false + } +} + +func displayNameFromMember(member *discordgo.Member) string { + if member == nil { + return "" + } + if nick := strings.TrimSpace(member.Nick); nick != "" { + return nick + } + return displayNameFromUser(member.User) +} + +func displayNameFromUser(user *discordgo.User) string { + if user == nil { + return "" + } + if global := strings.TrimSpace(user.GlobalName); global != "" { + return global + } + username := strings.TrimSpace(user.Username) + if username == "" { + return "" + } + discriminator := strings.TrimSpace(user.Discriminator) + if discriminator != "" && discriminator != "0" { + return fmt.Sprintf("%s#%s", username, discriminator) + } + return username +} + +func userAvatarURL(user *discordgo.User) string { + if user == nil { + return "" + } + return user.AvatarURL("") +} diff --git a/internal/discord/helpers_test.go b/internal/discord/helpers_test.go new file mode 100644 index 0000000..e6d4726 --- /dev/null +++ b/internal/discord/helpers_test.go @@ -0,0 +1,73 @@ +package discord + +import ( + "testing" + + "github.com/bwmarrin/discordgo" +) + +func TestSanitizeContent(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "hello", "hello"}, + {"newlines", "a\nb\r\nc", "a b c"}, + {"whitespace only", " \n ", "(内容なし)"}, + {"empty", "", "(内容なし)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeContent(tt.input); got != tt.want { + t.Errorf("sanitizeContent(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBuildMessageLink(t *testing.T) { + if got := buildMessageLink("g", "c", "m"); got != "https://discord.com/channels/g/c/m" { + t.Errorf("buildMessageLink = %q", got) + } + for _, args := range [][3]string{{"", "c", "m"}, {"g", "", "m"}, {"g", "c", ""}} { + if got := buildMessageLink(args[0], args[1], args[2]); got != "" { + t.Errorf("buildMessageLink(%v) = %q, want empty", args, got) + } + } +} + +func TestDisplayNameFromUser(t *testing.T) { + tests := []struct { + name string + user *discordgo.User + want string + }{ + {"nil", nil, ""}, + {"global name wins", &discordgo.User{GlobalName: "グローバル", Username: "user1"}, "グローバル"}, + {"username without discriminator", &discordgo.User{Username: "user1", Discriminator: "0"}, "user1"}, + {"legacy discriminator", &discordgo.User{Username: "user1", Discriminator: "1234"}, "user1#1234"}, + {"empty", &discordgo.User{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := displayNameFromUser(tt.user); got != tt.want { + t.Errorf("displayNameFromUser = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDisplayNameFromMember(t *testing.T) { + member := &discordgo.Member{Nick: "ニック", User: &discordgo.User{GlobalName: "グローバル"}} + if got := displayNameFromMember(member); got != "ニック" { + t.Errorf("nick should win, got %q", got) + } + member = &discordgo.Member{User: &discordgo.User{GlobalName: "グローバル"}} + if got := displayNameFromMember(member); got != "グローバル" { + t.Errorf("fallback to user, got %q", got) + } + if got := displayNameFromMember(nil); got != "" { + t.Errorf("nil member should return empty, got %q", got) + } +} diff --git a/internal/summary/helpers.go b/internal/summary/helpers.go index d9df6cc..b9e1f1d 100644 --- a/internal/summary/helpers.go +++ b/internal/summary/helpers.go @@ -1,11 +1,7 @@ package summary import ( - "fmt" - "strings" "unicode/utf8" - - "github.com/bwmarrin/discordgo" ) func truncateRunes(input string, limit int) string { @@ -17,91 +13,3 @@ func truncateRunes(input string, limit int) string { } return string([]rune(input)[:limit]) + "…" } - -func sanitizeContent(input string) string { - cleaned := strings.ReplaceAll(input, "\n", " ") - cleaned = strings.ReplaceAll(cleaned, "\r", " ") - cleaned = strings.TrimSpace(cleaned) - if cleaned == "" { - return "(内容なし)" - } - return cleaned -} - -func buildMessageLink(guildID, channelID, messageID string) string { - if guildID == "" || channelID == "" || messageID == "" { - return "" - } - return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, channelID, messageID) -} - -func isThreadChannel(channelType discordgo.ChannelType) bool { - switch channelType { - case discordgo.ChannelTypeGuildPublicThread, - discordgo.ChannelTypeGuildPrivateThread, - discordgo.ChannelTypeGuildNewsThread: - return true - default: - return false - } -} - -func isThreadParentChannel(channelType discordgo.ChannelType) bool { - switch channelType { - case discordgo.ChannelTypeGuildText, - discordgo.ChannelTypeGuildNews, - discordgo.ChannelTypeGuildForum: - return true - default: - return false - } -} - -func isTextChannel(channelType discordgo.ChannelType) bool { - switch channelType { - case discordgo.ChannelTypeGuildText, - discordgo.ChannelTypeGuildNews, - discordgo.ChannelTypeGuildForum, - discordgo.ChannelTypeGuildPublicThread, - discordgo.ChannelTypeGuildPrivateThread, - discordgo.ChannelTypeGuildNewsThread: - return true - default: - return false - } -} - -func displayNameFromMember(member *discordgo.Member) string { - if member == nil { - return "" - } - if nick := strings.TrimSpace(member.Nick); nick != "" { - return nick - } - return displayNameFromUser(member.User) -} - -func displayNameFromUser(user *discordgo.User) string { - if user == nil { - return "" - } - if global := strings.TrimSpace(user.GlobalName); global != "" { - return global - } - username := strings.TrimSpace(user.Username) - if username == "" { - return "" - } - discriminator := strings.TrimSpace(user.Discriminator) - if discriminator != "" && discriminator != "0" { - return fmt.Sprintf("%s#%s", username, discriminator) - } - return username -} - -func userAvatarURL(user *discordgo.User) string { - if user == nil { - return "" - } - return user.AvatarURL("") -} diff --git a/internal/summary/helpers_test.go b/internal/summary/helpers_test.go index a65b779..48a5ce6 100644 --- a/internal/summary/helpers_test.go +++ b/internal/summary/helpers_test.go @@ -2,8 +2,6 @@ package summary import ( "testing" - - "github.com/bwmarrin/discordgo" ) func TestTruncateRunes(t *testing.T) { @@ -30,69 +28,3 @@ func TestTruncateRunes(t *testing.T) { }) } } - -func TestSanitizeContent(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {"plain", "hello", "hello"}, - {"newlines", "a\nb\r\nc", "a b c"}, - {"whitespace only", " \n ", "(内容なし)"}, - {"empty", "", "(内容なし)"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := sanitizeContent(tt.input); got != tt.want { - t.Errorf("sanitizeContent(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestBuildMessageLink(t *testing.T) { - if got := buildMessageLink("g", "c", "m"); got != "https://discord.com/channels/g/c/m" { - t.Errorf("buildMessageLink = %q", got) - } - for _, args := range [][3]string{{"", "c", "m"}, {"g", "", "m"}, {"g", "c", ""}} { - if got := buildMessageLink(args[0], args[1], args[2]); got != "" { - t.Errorf("buildMessageLink(%v) = %q, want empty", args, got) - } - } -} - -func TestDisplayNameFromUser(t *testing.T) { - tests := []struct { - name string - user *discordgo.User - want string - }{ - {"nil", nil, ""}, - {"global name wins", &discordgo.User{GlobalName: "グローバル", Username: "user1"}, "グローバル"}, - {"username without discriminator", &discordgo.User{Username: "user1", Discriminator: "0"}, "user1"}, - {"legacy discriminator", &discordgo.User{Username: "user1", Discriminator: "1234"}, "user1#1234"}, - {"empty", &discordgo.User{}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := displayNameFromUser(tt.user); got != tt.want { - t.Errorf("displayNameFromUser = %q, want %q", got, tt.want) - } - }) - } -} - -func TestDisplayNameFromMember(t *testing.T) { - member := &discordgo.Member{Nick: "ニック", User: &discordgo.User{GlobalName: "グローバル"}} - if got := displayNameFromMember(member); got != "ニック" { - t.Errorf("nick should win, got %q", got) - } - member = &discordgo.Member{User: &discordgo.User{GlobalName: "グローバル"}} - if got := displayNameFromMember(member); got != "グローバル" { - t.Errorf("fallback to user, got %q", got) - } - if got := displayNameFromMember(nil); got != "" { - t.Errorf("nil member should return empty, got %q", got) - } -} diff --git a/main.go b/main.go index e401d38..c29287c 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,7 @@ func run() error { } service := &summary.Service{ - Collector: &summary.Collector{ + Collector: &discord.Collector{ Session: session, Lookback: cfg.Summary.Lookback, FetchLimit: cfg.Summary.FetchLimit,