Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions internal/discord/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 32 additions & 0 deletions internal/syncer/channel_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions internal/syncer/channel_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions internal/syncer/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions internal/syncer/syncer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down