From 9c10d697dc7e67372b3a6f769b7decf52ebb60f1 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 4 Jul 2026 14:55:23 +0200 Subject: [PATCH] fix targeted archived thread sync --- CHANGELOG.md | 6 +++ internal/cli/cli_test.go | 4 ++ internal/discord/client.go | 6 +++ internal/syncer/channel_catalog.go | 32 +++++++++++ internal/syncer/channel_catalog_test.go | 70 +++++++++++++++++++++++++ internal/syncer/syncer.go | 1 + internal/syncer/syncer_test.go | 10 ++++ 7 files changed, 129 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466c233..1889a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.11.5 - Unreleased +### Fixes + +- Resolve targeted channel/thread sync requests directly before falling back to + broad thread catalog crawls, so archived thread syncs do not wait on unrelated + guild thread endpoints. + ## 0.11.4 - 2026-07-02 ### Changes diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a005248..ba24836 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3572,6 +3572,10 @@ func (f *fakeDiscordClient) Guild(context.Context, string) (*discordgo.Guild, er return &discordgo.Guild{}, nil } +func (f *fakeDiscordClient) Channel(context.Context, string) (*discordgo.Channel, error) { + return nil, nil +} + func (f *fakeDiscordClient) GuildChannels(context.Context, string) ([]*discordgo.Channel, error) { return nil, nil } diff --git a/internal/discord/client.go b/internal/discord/client.go index d9c913f..79def05 100644 --- a/internal/discord/client.go +++ b/internal/discord/client.go @@ -91,6 +91,12 @@ func (c *Client) Guild(ctx context.Context, guildID string) (*discordgo.Guild, e return c.session.Guild(guildID, discordgo.WithContext(reqCtx)) } +func (c *Client) Channel(ctx context.Context, channelID string) (*discordgo.Channel, error) { + reqCtx, cancel := c.requestContext(ctx) + defer cancel() + return c.session.Channel(channelID, discordgo.WithContext(reqCtx)) +} + func (c *Client) GuildChannels(ctx context.Context, guildID string) ([]*discordgo.Channel, error) { reqCtx, cancel := c.requestContext(ctx) defer cancel() diff --git a/internal/syncer/channel_catalog.go b/internal/syncer/channel_catalog.go index 64ace59..7d59e7f 100644 --- a/internal/syncer/channel_catalog.go +++ b/internal/syncer/channel_catalog.go @@ -59,6 +59,13 @@ func (s *Syncer) channelList(ctx context.Context, guildID string, requested []st selected = selectRequestedChannels(allChannels, storedByID, requestedSet) } + if unresolvedRequestedIDs(selected, requestedSet) > 0 { + if err := s.appendDirectRequestedChannels(ctx, guildID, allChannels, requestedSet); err != nil { + return nil, false, err + } + selected = selectRequestedChannels(allChannels, storedByID, requestedSet) + } + if unresolvedRequestedIDs(selected, requestedSet) > 0 { if err := s.appendThreadCatalog(ctx, allChannels, threadParentIDs(topLevel)); err != nil { return nil, false, err @@ -68,6 +75,31 @@ func (s *Syncer) channelList(ctx context.Context, guildID string, requested []st return selected, true, nil } +func (s *Syncer) appendDirectRequestedChannels( + ctx context.Context, + guildID string, + allChannels map[string]*discordgo.Channel, + requested map[string]struct{}, +) error { + for requestedID := range requested { + if _, ok := allChannels[requestedID]; ok { + continue + } + channel, err := s.client.Channel(ctx, requestedID) + if err != nil { + return fmt.Errorf("fetch requested channel %s: %w", requestedID, err) + } + if channel == nil || channel.ID == "" { + continue + } + if channel.GuildID != "" && guildID != "" && channel.GuildID != guildID { + return fmt.Errorf("requested channel %s belongs to guild %s, not %s", channel.ID, channel.GuildID, guildID) + } + allChannels[channel.ID] = channel + } + return nil +} + func (s *Syncer) liveChannelList(ctx context.Context, guildID string, mode channelCatalogMode) ([]*discordgo.Channel, error) { channels, err := s.client.GuildChannels(ctx, guildID) if err != nil { diff --git a/internal/syncer/channel_catalog_test.go b/internal/syncer/channel_catalog_test.go index 1272251..3e8da22 100644 --- a/internal/syncer/channel_catalog_test.go +++ b/internal/syncer/channel_catalog_test.go @@ -123,6 +123,76 @@ func TestSyncChannelSubsetExpandsRequestedForumThreads(t *testing.T) { require.Equal(t, "t1", results[0].ChannelID) } +func TestSyncChannelSubsetFetchesRequestedArchivedThreadDirectly(t *testing.T) { + t.Parallel() + + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + defer func() { _ = s.Close() }() + + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`})) + + archiveAt := time.Now().UTC().Add(-time.Hour) + client := &fakeClient{ + guilds: []*discordgo.UserGuild{{ID: "g1", Name: "Guild"}}, + guildByID: map[string]*discordgo.Guild{ + "g1": {ID: "g1", Name: "Guild"}, + }, + channels: map[string][]*discordgo.Channel{ + "g1": { + {ID: "f1", GuildID: "g1", Name: "support", Type: discordgo.ChannelTypeGuildForum}, + {ID: "c2", GuildID: "g1", Name: "random", Type: discordgo.ChannelTypeGuildText}, + }, + }, + channelByID: map[string]*discordgo.Channel{ + "t-archived": { + ID: "t-archived", + GuildID: "g1", + ParentID: "f1", + Name: "archived support thread", + Type: discordgo.ChannelTypeGuildPublicThread, + ThreadMetadata: &discordgo.ThreadMetadata{ + Archived: true, + ArchiveTimestamp: archiveAt, + }, + LastMessageID: "10", + }, + }, + messages: map[string][]*discordgo.Message{ + "t-archived": {{ + ID: "10", + GuildID: "g1", + ChannelID: "t-archived", + Content: "archived support body", + Timestamp: archiveAt, + Author: &discordgo.User{ID: "u1", Username: "user"}, + }}, + }, + } + + svc := New(client, s, nil) + stats, err := svc.Sync(ctx, SyncOptions{ + Full: true, + GuildIDs: []string{"g1"}, + ChannelIDs: []string{"t-archived"}, + }) + require.NoError(t, err) + require.Equal(t, 1, stats.Channels) + require.Equal(t, 1, stats.Threads) + require.Equal(t, 1, stats.Messages) + require.Equal(t, 1, client.guildChanCalls) + require.Equal(t, 1, client.channelCalls["t-archived"]) + require.Zero(t, client.threadCalls) + require.Empty(t, client.archivedCalls) + require.Equal(t, 1, client.messageCalls["t-archived"]) + + results, err := s.SearchMessages(ctx, store.SearchOptions{Query: "archived support"}) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, "t-archived", results[0].ChannelID) +} + func TestSyncToleratesArchivedThread403(t *testing.T) { t.Parallel() diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 1f18a33..e519792 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -17,6 +17,7 @@ type Client interface { Self(context.Context) (*discordgo.User, error) Guilds(context.Context) ([]*discordgo.UserGuild, error) Guild(context.Context, string) (*discordgo.Guild, error) + Channel(context.Context, string) (*discordgo.Channel, error) GuildChannels(context.Context, string) ([]*discordgo.Channel, error) ThreadsActive(context.Context, string) ([]*discordgo.Channel, error) GuildThreadsActive(context.Context, string) ([]*discordgo.Channel, error) diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index 2efa893..45e69f0 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -18,6 +18,7 @@ import ( type fakeClient struct { guilds []*discordgo.UserGuild guildByID map[string]*discordgo.Guild + channelByID map[string]*discordgo.Channel channels map[string][]*discordgo.Channel activeThreads map[string][]*discordgo.Channel guildThreads map[string][]*discordgo.Channel @@ -40,6 +41,7 @@ type fakeClient struct { tailCalls int tailHandled chan struct{} messageDelay time.Duration + channelCalls map[string]int guildChanCalls int threadCalls int guildThreadCalls int @@ -67,6 +69,14 @@ func (f *fakeClient) Guild(_ context.Context, guildID string) (*discordgo.Guild, return f.guildByID[guildID], nil } +func (f *fakeClient) Channel(_ context.Context, channelID string) (*discordgo.Channel, error) { + if f.channelCalls == nil { + f.channelCalls = make(map[string]int) + } + f.channelCalls[channelID]++ + return f.channelByID[channelID], nil +} + func (f *fakeClient) GuildChannels(_ context.Context, guildID string) ([]*discordgo.Channel, error) { f.guildChanCalls++ return f.channels[guildID], nil