Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixes

- Stop guild and archived-thread pagination with a cursor error when Discord repeats a page instead of hanging sync. Thanks @SebTardif.

### Maintenance

- Require Go 1.27.0, refresh SQLite and terminal dependencies, and update container, analyzer, security-scan, and docs-build tooling.
Expand Down
1 change: 1 addition & 0 deletions docs/commands/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ discrawl sync --with-media
- Heartbeat logs (`message sync waiting`) name the oldest active channel and per-channel page activity if in-flight channels stop completing for a while.
- Every run ends with a `message sync finished` summary.
- Each channel crawl has a bounded runtime budget; pathological channels are deferred and retried next sync.
- Guild and archived-thread pagination reports a cursor error if Discord repeats a page instead of continuing indefinitely.
- Retryable failures and unavailable-channel markers are tracked per channel; stale unavailable markers are cleared after a later successful crawl.
- Marker cleanup is best-effort, so one missing local sync-state row cannot crash the run.
- Member refresh is best-effort and gives up after five minutes without a caller-supplied deadline. Routine latest-only syncs skip it unless `--with-members` is set.
Expand Down
12 changes: 11 additions & 1 deletion internal/discord/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,17 @@ func (c *Client) Guilds(ctx context.Context) ([]*discordgo.UserGuild, error) {
return out, nil
}
out = append(out, page...)
before = page[len(page)-1].ID
if len(page) < 200 {
return out, nil
}
nextBefore := page[len(page)-1].ID
if nextBefore == "" {
return nil, errors.New("guild page missing id")
}
if nextBefore == before {
return nil, errors.New("guild page cursor did not advance")
}
before = nextBefore
}
}

Expand Down Expand Up @@ -375,6 +382,9 @@ func (c *Client) ThreadsArchived(ctx context.Context, channelID string, private
return uniqueChannels(out), nil
}
archiveAt := oldest.ThreadMetadata.ArchiveTimestamp
if before != nil && archiveAt.Equal(*before) {
return nil, fmt.Errorf("channel %s archived thread page cursor did not advance", channelID)
}
before = &archiveAt
}
}
Expand Down
124 changes: 124 additions & 0 deletions internal/discord/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,130 @@ func TestGuildMembersErrorsWhenCursorDoesNotAdvance(t *testing.T) {
require.Equal(t, 2, requests)
}

func TestGuildsErrorsWhenCursorDoesNotAdvance(t *testing.T) {
page := make([]map[string]any, 200)
for i := range page {
page[i] = map[string]any{
"id": fmt.Sprintf("g%03d", i),
"name": "Guild",
}
}

requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v10/users/@me/guilds", func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 5 {
writeJSON([]map[string]any{})(w, r)
return
}
writeJSON(page)(w, r)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

restore := patchDiscordEndpoints(server.URL + "/api/v10/")
t.Cleanup(restore)

client, err := New("token")
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })

guilds, err := client.Guilds(context.Background())
require.ErrorContains(t, err, "guild page cursor did not advance")
require.Nil(t, guilds)
require.Equal(t, 2, requests)
}

func TestGuildsErrorsWhenFullPageHasEmptyID(t *testing.T) {
page := make([]map[string]any, 200)
for i := range 199 {
page[i] = map[string]any{
"id": fmt.Sprintf("g%03d", i),
"name": "Guild",
}
}
page[199] = map[string]any{"id": "", "name": "Guild"}

requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v10/users/@me/guilds", func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 5 {
writeJSON([]map[string]any{})(w, r)
return
}
writeJSON(page)(w, r)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

restore := patchDiscordEndpoints(server.URL + "/api/v10/")
t.Cleanup(restore)

client, err := New("token")
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })

guilds, err := client.Guilds(context.Background())
require.ErrorContains(t, err, "guild page missing id")
require.Nil(t, guilds)
require.Equal(t, 1, requests)
}

func TestThreadsArchivedErrorsWhenCursorDoesNotAdvance(t *testing.T) {
archivedAt := "2024-01-15T12:00:00Z"
page := map[string]any{
"threads": []map[string]any{
{
"id": "t1",
"guild_id": "g1",
"parent_id": "c1",
"name": "archived-public",
"type": 11,
"thread_metadata": map[string]any{
"archived": true,
"auto_archive_duration": 60,
"archive_timestamp": archivedAt,
"locked": false,
"invitable": true,
},
},
},
"members": []any{},
"has_more": true,
}

requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v10/channels/c1/threads/archived/public", func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 5 {
writeJSON(map[string]any{
"threads": []any{},
"members": []any{},
"has_more": false,
})(w, r)
return
}
writeJSON(page)(w, r)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

restore := patchDiscordEndpoints(server.URL + "/api/v10/")
t.Cleanup(restore)

client, err := New("token")
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })

threads, err := client.ThreadsArchived(context.Background(), "c1", false, time.Time{})
require.ErrorContains(t, err, "archived thread page cursor did not advance")
require.Nil(t, threads)
require.Equal(t, 2, requests)
}

func TestLastMemberUserID(t *testing.T) {
t.Parallel()

Expand Down