Skip to content

Commit c55e845

Browse files
authored
Merge pull request #7 from NullComma/feature/message-archive
feat: add message archive system
2 parents fc075d4 + 042dc1b commit c55e845

3 files changed

Lines changed: 189 additions & 22 deletions

File tree

docker-compose.stage.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
utsuki-bot-stage:
3+
image: utsuki-bot-stage
4+
container_name: utsuki-bot-stage
5+
restart: unless-stopped
6+
environment:
7+
DISCORD_TOKEN_UTSUKI: ${DISCORD_TOKEN_UTSUKI}
8+
AI_ENDPOINT: ${AI_ENDPOINT}
9+
AI_TOKEN: ${AI_TOKEN}
10+
API_KEY_WEATHER: ${API_KEY_WEATHER}
11+
BOT_VERSION: ${BOT_VERSION}
12+
volumes:
13+
- ./JsonData:/app/JsonData
14+
- ./ArchiveData:/app/ArchiveData

src/InteractionHandler.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ async Task ReadyAsync()
5353
{
5454
await _client.SetGameAsync($"v{Program.VERSION} | chrisjogos.com", type: ActivityType.CustomStatus);
5555

56-
// Register the commands globally.
57-
// alternatively you can use _handler.RegisterCommandsGloballyAsync() to register commands to a specific guild.
5856
await _handler.RegisterCommandsGloballyAsync();
5957
}
6058

src/Services/MessageArchiveService.cs

Lines changed: 175 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,31 @@ public class UserStats
2828
public int ChannelCount { get; set; }
2929
}
3030

31+
enum BackfillResult { Complete, NeedRetry, Skipped }
32+
33+
class RetryState
34+
{
35+
public SocketTextChannel Channel;
36+
public ulong OldestMessageId;
37+
public DateTime NextRetryAt;
38+
public int DelaySec;
39+
public const int MaxDelaySec = 300;
40+
}
41+
3142
[Service]
3243
public class MessageArchiveService
3344
{
3445
const string DB_PATH = "ArchiveData/messages.db";
3546

47+
static readonly Dictionary<ulong, ulong> KnownFirstMessages = new()
48+
{
49+
[264800866169651203] = 264813684281311234
50+
};
51+
3652
readonly DiscordSocketClient _discord;
3753
readonly LoggingService _log;
3854
readonly SemaphoreSlim _dbLock = new(1, 1);
55+
readonly List<RetryState> _retryQueue = new();
3956
volatile bool _backfillStarted;
4057
volatile bool _backfillComplete;
4158

@@ -56,10 +73,20 @@ void EnsureDatabase()
5673
using var ctx = CreateContext();
5774
ctx.Database.EnsureCreated();
5875

59-
var hasMessages = ctx.Messages.Any();
60-
_backfillComplete = hasMessages;
76+
var oldest = ctx.Messages.OrderBy(m => m.Timestamp).FirstOrDefault();
77+
_backfillComplete = true;
6178

62-
_log.Info($"Archive database ready at {DB_PATH} (has data: {hasMessages})");
79+
foreach (var (channelId, targetId) in KnownFirstMessages)
80+
{
81+
var hasTarget = ctx.Messages.Any(m => m.MessageId == targetId);
82+
if (!hasTarget)
83+
{
84+
_backfillComplete = false;
85+
break;
86+
}
87+
}
88+
89+
_log.Info($"Archive database ready at {DB_PATH} (has data: {oldest != null}, oldest: {oldest?.Timestamp:O})");
6390
}
6491

6592
public void Start()
@@ -130,13 +157,17 @@ async Task BackfillAllChannelsAsync()
130157

131158
_log.Info("Starting full channel backfill...");
132159

133-
foreach (var guild in _discord.Guilds)
160+
var guilds = _discord.Guilds.OrderBy(g => g.Id).ToList();
161+
foreach (var guild in guilds)
134162
{
135163
foreach (var channel in guild.TextChannels)
136164
{
137165
try
138166
{
139-
await BackfillChannelAsync(channel);
167+
if (await BackfillChannelAsync(channel) == BackfillResult.NeedRetry)
168+
{
169+
// retry handled by background loop
170+
}
140171
}
141172
catch (Exception e)
142173
{
@@ -145,34 +176,137 @@ async Task BackfillAllChannelsAsync()
145176
}
146177
}
147178

148-
_backfillComplete = true;
149-
_log.Info("Full backfill complete. All channels archived.");
179+
if (_retryQueue.Count == 0)
180+
{
181+
_backfillComplete = true;
182+
_log.Info("Full backfill complete. All channels archived.");
183+
}
184+
else
185+
{
186+
_log.Info($"Initial pass done. {_retryQueue.Count} channels need deep archive retry.");
187+
_ = RunRetryLoopAsync();
188+
}
150189
}
151190

152-
async Task BackfillChannelAsync(SocketTextChannel channel)
191+
async Task RunRetryLoopAsync()
153192
{
154-
var firstPage = await channel.GetMessagesAsync(1).FlattenAsync();
155-
if (!firstPage.Any())
156-
return;
193+
while (_retryQueue.Count > 0)
194+
{
195+
var now = DateTime.UtcNow;
196+
var due = new List<RetryState>();
157197

158-
var totalSaved = 0;
159-
ulong? oldestMessageId = null;
198+
lock (_retryQueue)
199+
{
200+
due.AddRange(_retryQueue.Where(r => now >= r.NextRetryAt));
201+
}
202+
203+
foreach (var state in due)
204+
{
205+
try
206+
{
207+
var result = await BackfillChannelAsync(state.Channel, state.OldestMessageId);
208+
if (result == BackfillResult.Complete)
209+
{
210+
lock (_retryQueue) _retryQueue.Remove(state);
211+
_log.Info($"Deep archive complete for #{state.Channel.Name}.");
212+
}
213+
else if (result == BackfillResult.NeedRetry)
214+
{
215+
state.DelaySec = Math.Min(state.DelaySec * 2, RetryState.MaxDelaySec);
216+
state.NextRetryAt = DateTime.UtcNow.AddSeconds(state.DelaySec);
217+
}
218+
}
219+
catch (Exception e)
220+
{
221+
state.DelaySec = Math.Min(state.DelaySec * 2, RetryState.MaxDelaySec);
222+
state.NextRetryAt = DateTime.UtcNow.AddSeconds(state.DelaySec);
223+
_log.Error($"Retry failed for #{state.Channel.Name}: {e.Message}. Next retry in {state.DelaySec}s.");
224+
}
225+
}
226+
227+
if (_retryQueue.Count == 0)
228+
{
229+
_backfillComplete = true;
230+
_log.Info("Deep archive retry complete. All channels fully archived.");
231+
return;
232+
}
233+
234+
await Task.Delay(5000);
235+
}
236+
}
237+
238+
async Task<BackfillResult> BackfillChannelAsync(SocketTextChannel channel, ulong? resumeFromId = null)
239+
{
240+
KnownFirstMessages.TryGetValue(channel.Id, out var targetMessageId);
160241

161-
_log.Info($"Backfilling #{channel.Name}...");
242+
if (resumeFromId == null)
243+
{
244+
var firstPage = await channel.GetMessagesAsync(1).FlattenAsync();
245+
if (!firstPage.Any())
246+
return BackfillResult.Skipped;
247+
resumeFromId = firstPage.First().Id;
248+
_log.Info($"Backfilling #{channel.Name}...");
249+
}
250+
251+
var totalSaved = 0;
252+
var oldestMessageId = resumeFromId;
253+
var emptyRetries = 0;
254+
var delaySec = 5;
162255

163256
while (true)
164257
{
165258
List<IMessage> messages;
166-
if (oldestMessageId == null)
259+
try
167260
{
168-
messages = (await channel.GetMessagesAsync(100).FlattenAsync()).ToList();
261+
messages = (await channel.GetMessagesAsync(oldestMessageId.Value, Direction.Before, 100).FlattenAsync()).ToList();
169262
}
170-
else
263+
catch (Exception e)
171264
{
172-
messages = (await channel.GetMessagesAsync(oldestMessageId.Value, Direction.Before, 100).FlattenAsync()).ToList();
265+
_log.Warning($"Backfill fetch error #{channel.Name}: {e.Message}. Retry in {delaySec}s.");
266+
await Task.Delay(TimeSpan.FromSeconds(delaySec));
267+
delaySec = Math.Min(delaySec * 2, 60);
268+
continue;
269+
}
270+
271+
if (messages.Count == 0)
272+
{
273+
if (targetMessageId > 0 && oldestMessageId > targetMessageId)
274+
{
275+
emptyRetries++;
276+
_log.Info($"Deep archive: #{channel.Name} at ID {oldestMessageId}, waiting for Discord cold storage ({emptyRetries}).");
277+
278+
if (!_retryQueue.Any(r => r.Channel.Id == channel.Id))
279+
{
280+
lock (_retryQueue)
281+
{
282+
_retryQueue.Add(new RetryState
283+
{
284+
Channel = channel,
285+
OldestMessageId = oldestMessageId.Value,
286+
NextRetryAt = DateTime.UtcNow.AddSeconds(delaySec),
287+
DelaySec = delaySec
288+
});
289+
}
290+
}
291+
else
292+
{
293+
lock (_retryQueue)
294+
{
295+
var existing = _retryQueue.First(r => r.Channel.Id == channel.Id);
296+
existing.OldestMessageId = oldestMessageId.Value;
297+
existing.NextRetryAt = DateTime.UtcNow.AddSeconds(delaySec);
298+
existing.DelaySec = Math.Min(existing.DelaySec * 2, RetryState.MaxDelaySec);
299+
}
300+
}
301+
302+
return BackfillResult.NeedRetry;
303+
}
304+
305+
break;
173306
}
174307

175-
if (!messages.Any()) break;
308+
emptyRetries = 0;
309+
delaySec = 5;
176310

177311
var newRecords = new List<MessageRecord>();
178312

@@ -227,12 +361,33 @@ async Task BackfillChannelAsync(SocketTextChannel channel)
227361

228362
oldestMessageId = messages.Last().Id;
229363

230-
if (messages.Count < 100) break;
364+
if (messages.Count < 100)
365+
{
366+
if (targetMessageId > 0 && oldestMessageId > targetMessageId)
367+
{
368+
if (!_retryQueue.Any(r => r.Channel.Id == channel.Id))
369+
{
370+
lock (_retryQueue)
371+
{
372+
_retryQueue.Add(new RetryState
373+
{
374+
Channel = channel,
375+
OldestMessageId = oldestMessageId.Value,
376+
NextRetryAt = DateTime.UtcNow.AddSeconds(delaySec),
377+
DelaySec = delaySec
378+
});
379+
}
380+
}
381+
return BackfillResult.NeedRetry;
382+
}
383+
break;
384+
}
231385

232386
await Task.Delay(200);
233387
}
234388

235389
_log.Info($"Backfill for #{channel.Name} complete. Saved {totalSaved} messages.");
390+
return BackfillResult.Complete;
236391
}
237392

238393
ArchiveDbContext CreateContext()

0 commit comments

Comments
 (0)