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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<PackageVersion Include="protobuf-net" Version="3.2.56" />
<PackageVersion Include="Remora.Discord" Version="2026.1.0" />
<PackageVersion Include="Sentry.AspNetCore" Version="6.6.0" />
<PackageVersion Include="System.ServiceModel.Syndication" Version="10.0.0" />
<PackageVersion Include="TimeSpanParserUtil" Version="1.2.0" />
</ItemGroup>
</Project>
3 changes: 2 additions & 1 deletion src/Accord.Bot/BotServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services,
.WithCommandGroup<MuteCommandGroup>()
.WithCommandGroup<UnmuteCommandGroup>()
.WithCommandGroup<ChangelogCommandGroup>()
.WithCommandGroup<HelpForumCommandGroup>();
.WithCommandGroup<HelpForumCommandGroup>()
.WithCommandGroup<RssCommandGroup>();

services
.AddResponder<ChannelUpdateResponder>()
Expand Down
2 changes: 1 addition & 1 deletion src/Accord.Bot/CommandGroups/MuteCommandGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public async Task<IResult> TempMute(IUser user, TimeSpan duration, [Greedy] stri
return await feedbackService.SendContextualAsync("Temporary mutes can only last for a maximum of one day");
}

var now = DateTimeOffset.Now;
var now = DateTimeOffset.UtcNow;
var muteUntil = now + duration;

var modifyReason = $"On behalf of {actingUser.DiscordUserId}";
Expand Down
2 changes: 1 addition & 1 deletion src/Accord.Bot/CommandGroups/ProfileCommandGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ private async Task<string> GetStatus(Snowflake guildSnowflake,
if (guildUser.IsSuccess)
{
if (guildUser.Entity.CommunicationDisabledUntil.HasValue
&& guildUser.Entity.CommunicationDisabledUntil.Value > DateTimeOffset.Now)
&& guildUser.Entity.CommunicationDisabledUntil.Value > DateTimeOffset.UtcNow)
{
var formatted = guildUser.Entity.CommunicationDisabledUntil.Value.Value.ToTimeMarkdown();
return $"Muted until {formatted}";
Expand Down
101 changes: 101 additions & 0 deletions src/Accord.Bot/CommandGroups/RssCommandGroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Accord.Bot.Helpers;
using Accord.Services.Rss;
using MediatR;
using Remora.Commands.Attributes;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Results;

namespace Accord.Bot.CommandGroups;

[Group("rss")]
public class RssCommandGroup(ICommandContext commandContext,
IMediator mediator,
FeedbackService feedbackService) : AccordCommandGroup
{
[Command("list")]
[Description("Lists all RSS feeds in this channel")]
[RequireDiscordPermission(DiscordPermission.Administrator)]
[Ephemeral]
public async Task<IResult> ListFeeds()
{
commandContext.TryGetChannelID(out var channelId);

var feeds = await mediator.Send(new GetFeedsInChannelRequest(channelId.Value));

if (feeds.Count == 0)
{
return await feedbackService.SendContextualAsync("No RSS feeds in this channel");
}

var fields = feeds.Select(feed =>
{
var nextFetch = feed.NextFetchDateTime.HasValue
? DiscordFormatter.TimeToMarkdown(feed.NextFetchDateTime.Value)
: "Disabled (too many failures)";

var status = feed.NumberOfFailedFetches > 0
? $"⚠️ {feed.NumberOfFailedFetches} failed fetches"
: "✅ Active";

var detail = $"URL: {feed.Url}\nNext fetch: {nextFetch}\nStatus: {status}";

if (!string.IsNullOrWhiteSpace(feed.LastFailedFetchResponse))
{
detail += $"\nLast error: {DiscordFormatter.TruncateToEmbedField(feed.LastFailedFetchResponse)}";
}

return new EmbedField($"Feed #{feed.Id}", DiscordFormatter.TruncateToEmbedField(detail), false);
}).ToArray();

var embed = new Embed(
Title: $"RSS Feeds ({feeds.Count} total)",
Fields: fields
);

return await feedbackService.SendContextualEmbedAsync(embed);
}

[Command("add")]
[Description("Adds an RSS feed URL to this channel")]
[RequireDiscordPermission(DiscordPermission.Administrator)]
[Ephemeral]
public async Task<IResult> AddFeed(string url)
{
commandContext.TryGetChannelID(out var channelId);

await mediator.Send(new AddFeedRequest(channelId.Value, url));

return await feedbackService.SendContextualAsync("RSS feed added to this channel");
}

[Command("remove")]
[Description("Removes an RSS feed by its ID")]
[RequireDiscordPermission(DiscordPermission.Administrator)]
[Ephemeral]
public async Task<IResult> RemoveFeed(int id)
{
await mediator.Send(new RemoveFeedRequest(id));

return await feedbackService.SendContextualAsync($"RSS feed #{id} removed");
}

[Command("retry")]
[Description("Resets the retry timer on a failed RSS feed by its ID")]
[RequireDiscordPermission(DiscordPermission.Administrator)]
[Ephemeral]
public async Task<IResult> RetryFeed(int id)
{
await mediator.Send(new RetryFeedRequest(id));

return await feedbackService.SendContextualAsync($"RSS feed #{id} will retry on next poll");
}
}
44 changes: 44 additions & 0 deletions src/Accord.Bot/HostedServices/RssPollingHostedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Accord.Bot.Helpers;
using Accord.Services.Rss;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Rest.Core;

namespace Accord.Bot.HostedServices;

public class RssPollingHostedService(IServiceScopeFactory serviceScopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = serviceScopeFactory.CreateScope();
var services = scope.ServiceProvider;

var mediator = services.GetRequiredService<IMediator>();
var channelApi = services.GetRequiredService<IDiscordRestChannelAPI>();

var feedIds = await mediator.Send(new GetFeedIdsToReadRequest(), stoppingToken);

foreach (var feedId in feedIds)
{
var result = await mediator.Send(new GetNewPostsFromFeedRequest(feedId), stoppingToken);

foreach (var post in result.NewPosts)
{
await channelApi.CreateMessageAsync(new Snowflake(result.DiscordChannelId),
$"**{post.Title}**{Environment.NewLine}{post.Url}",
ct: stoppingToken);
}
}

await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
2 changes: 2 additions & 0 deletions src/Accord.Domain/AccordContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
public DbSet<StarboardChannel> StarboardChannels { get; set; } = null!;
public DbSet<StarboardEntry> StarboardEntries { get; set; } = null!;
public DbSet<StarboardEntryOutput> StarboardEntryOutputs { get; set; } = null!;
public DbSet<RssFeed> RssFeeds { get; set; } = null!;
public DbSet<RssFeedPost> RssFeedPosts { get; set; } = null!;
}
Loading
Loading