Exception handlers; add history table

Tim Burga 652eb3ca 907b872e

+1
AltBot.Api/AltBot.Api.csproj
··· 14 14 </ItemGroup> 15 15 16 16 <ItemGroup> 17 + <PackageReference Include="idunno.Bluesky" /> 17 18 <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> 18 19 <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> 19 20 <PrivateAssets>all</PrivateAssets>
+1 -1
AltBot.Api/Program.cs
··· 31 31 app.MapApplicationEndpoints(); 32 32 app.MapDefaultEndpoints(); 33 33 34 - await app.RunAsync(); 34 + await app.RunAsync();
+63
AltBot.Api/StartupExtensions.cs
··· 1 1 using AltBot.Core.Models; 2 2 using AltBot.Data; 3 3 using AltBot.ServiceDefaults; 4 + using Duende.IdentityModel.OidcClient; 5 + using idunno.AtProto; 6 + using idunno.Bluesky; 7 + using idunno.Bluesky.Actor; 8 + using idunno.Bluesky.Feed; 9 + using idunno.Bluesky.Graph; 10 + using idunno.Bluesky.Record; 4 11 using Microsoft.EntityFrameworkCore; 12 + using Cid = AltBot.Core.Models.Cid; 13 + using Did = AltBot.Core.Models.Did; 14 + using Like = idunno.Bluesky.Record.Like; 5 15 6 16 namespace AltBot.Api; 7 17 ··· 17 27 // Subscribers endpoints 18 28 app.MapGet("/subscribers", async (DataContext db) => 19 29 { 30 + var agent = new BlueskyAgent(); 31 + 32 + 33 + var likesResult = await agent.GetLikes( 34 + new AtUri("at://did:plc:yb2gz6yxpebbzlundrrfkv4d/app.bsky.labeler.service/self")); 35 + 36 + 37 + if (!likesResult.Succeeded || likesResult.Result.Count == 0) 38 + { 39 + return Results.NotFound(); 40 + } 41 + 42 + var list = new List<ProfileView>(); 43 + list.AddRange(likesResult.Result.Select(x => x.Actor)); 44 + 45 + while (!string.IsNullOrEmpty(likesResult.Result.Cursor)) 46 + { 47 + likesResult = await agent.GetLikes( 48 + new AtUri("at://did:plc:yb2gz6yxpebbzlundrrfkv4d/app.bsky.labeler.service/self"), 49 + cursor: likesResult.Result.Cursor); 50 + 51 + list.AddRange(likesResult.Result.Select(x => x.Actor)); 52 + } 53 + 54 + var x = list.Select(x => $"{x.Handle.Value} - {x.Did}"); 55 + 56 + return Results.Ok(x); 57 + 58 + 59 + 60 + //var followersResult = await agent.GetFollowers(AtIdentifier.Create("did:plc:yb2gz6yxpebbzlundrrfkv4d")); 61 + 62 + //var list = new List<Followers>(); 63 + 64 + //if (followersResult.Succeeded && followersResult.Result.Count > 0) 65 + //{ 66 + 67 + //} 68 + 69 + //var sfsda = new List<Followers>(); 70 + 71 + //do 72 + //{ 73 + // var results = followers.Result.Select(follow => $"{follow.Handle.Value} - {follow.Did}"); 74 + //} 75 + //while (!string.IsNullOrEmpty(followers.Result.Cursor)) 76 + 77 + 78 + 79 + //return Results.Ok(results); 80 + 81 + 82 + 20 83 var subscribers = await db.Subscribers 21 84 .Select(s => new 22 85 {
+5
AltBot.Core/Exceptions/HaltException.cs
··· 1 + namespace AltBot.Core.Exceptions; 2 + 3 + public class HaltException : Exception 4 + { 5 + }
+1
AltBot.Core/Models/Subscriber.cs
··· 9 9 public string? Handle { get; set; } 10 10 public string? Rkey { get; set; } 11 11 public virtual ICollection<ImagePost> Posts { get; set; } = new List<ImagePost>(); 12 + public virtual ICollection<SubscriberHistory> History{ get; set; } = new List<SubscriberHistory>(); 12 13 public virtual LabelLevel Label { get; set; } 13 14 }
+14
AltBot.Core/Models/SubscriberHistory.cs
··· 1 + namespace AltBot.Core.Models; 2 + 3 + public class SubscriberHistory 4 + { 5 + public required Did Did { get; set; } 6 + 7 + public string Category { get; set; } 8 + 9 + public string Action { get; set; } 10 + 11 + public DateTime OccurredAt { get; set; } 12 + 13 + public virtual Subscriber? Subscriber { get; set; } 14 + }
+22
AltBot.Data/DataContext.cs
··· 64 64 entity.Property(x => x.Rkey) 65 65 .HasMaxLength(100); 66 66 }); 67 + 68 + modelBuilder.Entity<SubscriberHistory>(entity => 69 + { 70 + entity.ToTable("subscriber_history"); 71 + 72 + entity.HasKey(x => new {x.Did, x.OccurredAt}); 73 + 74 + entity.Property(x => x.Did) 75 + .HasMaxLength(2048) 76 + .HasConversion(didConverter); 77 + 78 + entity.Property(x => x.Category) 79 + .HasMaxLength(200); 80 + 81 + entity.Property(x => x.Category) 82 + .HasMaxLength(2048); 83 + 84 + entity.HasOne(x => x.Subscriber) 85 + .WithMany(x => x.History) 86 + .HasForeignKey(x => x.Did) 87 + .HasConstraintName("fk_subscriber_history_subscriber"); 88 + }); 67 89 } 68 90 }
+160
AltBot.Data/Migrations/20251023162128_AddHistoryTable.Designer.cs
··· 1 + // <auto-generated /> 2 + using System; 3 + using AltBot.Core.Models; 4 + using AltBot.Data; 5 + using Microsoft.EntityFrameworkCore; 6 + using Microsoft.EntityFrameworkCore.Infrastructure; 7 + using Microsoft.EntityFrameworkCore.Migrations; 8 + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 9 + using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 10 + 11 + #nullable disable 12 + 13 + namespace AltBot.Data.Migrations 14 + { 15 + [DbContext(typeof(DataContext))] 16 + [Migration("20251023162128_AddHistoryTable")] 17 + partial class AddHistoryTable 18 + { 19 + /// <inheritdoc /> 20 + protected override void BuildTargetModel(ModelBuilder modelBuilder) 21 + { 22 + #pragma warning disable 612, 618 23 + modelBuilder 24 + .HasAnnotation("ProductVersion", "9.0.10") 25 + .HasAnnotation("Relational:MaxIdentifierLength", 63); 26 + 27 + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "label", new[] { "bronze", "gold", "hero", "none", "silver" }); 28 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 29 + 30 + modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 31 + { 32 + b.Property<string>("Did") 33 + .HasMaxLength(2048) 34 + .HasColumnType("character varying(2048)") 35 + .HasColumnName("did"); 36 + 37 + b.Property<string>("Cid") 38 + .HasMaxLength(1000) 39 + .HasColumnType("character varying(1000)") 40 + .HasColumnName("cid"); 41 + 42 + b.Property<string>("Rkey") 43 + .HasMaxLength(512) 44 + .HasColumnType("character varying(512)") 45 + .HasColumnName("rkey"); 46 + 47 + b.Property<DateTime>("SeenAt") 48 + .HasColumnType("timestamp with time zone") 49 + .HasColumnName("seen_at"); 50 + 51 + b.Property<bool>("ValidAlt") 52 + .HasColumnType("boolean") 53 + .HasColumnName("valid_alt"); 54 + 55 + b.HasKey("Did", "Cid") 56 + .HasName("pk_image_post"); 57 + 58 + b.ToTable("image_post", (string)null); 59 + }); 60 + 61 + modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 62 + { 63 + b.Property<string>("Did") 64 + .HasMaxLength(2048) 65 + .HasColumnType("character varying(2048)") 66 + .HasColumnName("did"); 67 + 68 + b.Property<bool>("Active") 69 + .ValueGeneratedOnAdd() 70 + .HasColumnType("boolean") 71 + .HasDefaultValue(true) 72 + .HasColumnName("active"); 73 + 74 + b.Property<string>("Handle") 75 + .HasMaxLength(250) 76 + .IsUnicode(true) 77 + .HasColumnType("character varying(250)") 78 + .HasColumnName("handle"); 79 + 80 + b.Property<LabelLevel>("Label") 81 + .HasColumnType("label") 82 + .HasColumnName("label"); 83 + 84 + b.Property<string>("Rkey") 85 + .HasMaxLength(100) 86 + .HasColumnType("character varying(100)") 87 + .HasColumnName("rkey"); 88 + 89 + b.Property<DateTime>("SeenAt") 90 + .HasColumnType("timestamp with time zone") 91 + .HasColumnName("seen_at"); 92 + 93 + b.HasKey("Did") 94 + .HasName("pk_subscriber"); 95 + 96 + b.ToTable("subscriber", (string)null); 97 + }); 98 + 99 + modelBuilder.Entity("AltBot.Core.Models.SubscriberHistory", b => 100 + { 101 + b.Property<string>("Did") 102 + .HasMaxLength(2048) 103 + .HasColumnType("character varying(2048)") 104 + .HasColumnName("did"); 105 + 106 + b.Property<DateTime>("OccurredAt") 107 + .HasColumnType("timestamp with time zone") 108 + .HasColumnName("occurred_at"); 109 + 110 + b.Property<string>("Action") 111 + .IsRequired() 112 + .HasColumnType("text") 113 + .HasColumnName("action"); 114 + 115 + b.Property<string>("Category") 116 + .IsRequired() 117 + .HasMaxLength(2048) 118 + .HasColumnType("character varying(2048)") 119 + .HasColumnName("category"); 120 + 121 + b.HasKey("Did", "OccurredAt") 122 + .HasName("pk_subscriber_history"); 123 + 124 + b.ToTable("subscriber_history", (string)null); 125 + }); 126 + 127 + modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 128 + { 129 + b.HasOne("AltBot.Core.Models.Subscriber", "Subscriber") 130 + .WithMany("Posts") 131 + .HasForeignKey("Did") 132 + .OnDelete(DeleteBehavior.Cascade) 133 + .IsRequired() 134 + .HasConstraintName("fk_image_post_subscriber"); 135 + 136 + b.Navigation("Subscriber"); 137 + }); 138 + 139 + modelBuilder.Entity("AltBot.Core.Models.SubscriberHistory", b => 140 + { 141 + b.HasOne("AltBot.Core.Models.Subscriber", "Subscriber") 142 + .WithMany("History") 143 + .HasForeignKey("Did") 144 + .OnDelete(DeleteBehavior.Cascade) 145 + .IsRequired() 146 + .HasConstraintName("fk_subscriber_history_subscriber"); 147 + 148 + b.Navigation("Subscriber"); 149 + }); 150 + 151 + modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 152 + { 153 + b.Navigation("History"); 154 + 155 + b.Navigation("Posts"); 156 + }); 157 + #pragma warning restore 612, 618 158 + } 159 + } 160 + }
+42
AltBot.Data/Migrations/20251023162128_AddHistoryTable.cs
··· 1 + using System; 2 + using Microsoft.EntityFrameworkCore.Migrations; 3 + 4 + #nullable disable 5 + 6 + namespace AltBot.Data.Migrations 7 + { 8 + /// <inheritdoc /> 9 + public partial class AddHistoryTable : Migration 10 + { 11 + /// <inheritdoc /> 12 + protected override void Up(MigrationBuilder migrationBuilder) 13 + { 14 + migrationBuilder.CreateTable( 15 + name: "subscriber_history", 16 + columns: table => new 17 + { 18 + did = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false), 19 + occurred_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), 20 + category = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false), 21 + action = table.Column<string>(type: "text", nullable: false) 22 + }, 23 + constraints: table => 24 + { 25 + table.PrimaryKey("pk_subscriber_history", x => new { x.did, x.occurred_at }); 26 + table.ForeignKey( 27 + name: "fk_subscriber_history_subscriber", 28 + column: x => x.did, 29 + principalTable: "subscriber", 30 + principalColumn: "did", 31 + onDelete: ReferentialAction.Cascade); 32 + }); 33 + } 34 + 35 + /// <inheritdoc /> 36 + protected override void Down(MigrationBuilder migrationBuilder) 37 + { 38 + migrationBuilder.DropTable( 39 + name: "subscriber_history"); 40 + } 41 + } 42 + }
+42
AltBot.Data/Migrations/DataContextModelSnapshot.cs
··· 93 93 b.ToTable("subscriber", (string)null); 94 94 }); 95 95 96 + modelBuilder.Entity("AltBot.Core.Models.SubscriberHistory", b => 97 + { 98 + b.Property<string>("Did") 99 + .HasMaxLength(2048) 100 + .HasColumnType("character varying(2048)") 101 + .HasColumnName("did"); 102 + 103 + b.Property<DateTime>("OccurredAt") 104 + .HasColumnType("timestamp with time zone") 105 + .HasColumnName("occurred_at"); 106 + 107 + b.Property<string>("Action") 108 + .IsRequired() 109 + .HasColumnType("text") 110 + .HasColumnName("action"); 111 + 112 + b.Property<string>("Category") 113 + .IsRequired() 114 + .HasMaxLength(2048) 115 + .HasColumnType("character varying(2048)") 116 + .HasColumnName("category"); 117 + 118 + b.HasKey("Did", "OccurredAt") 119 + .HasName("pk_subscriber_history"); 120 + 121 + b.ToTable("subscriber_history", (string)null); 122 + }); 123 + 96 124 modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 97 125 { 98 126 b.HasOne("AltBot.Core.Models.Subscriber", "Subscriber") ··· 105 133 b.Navigation("Subscriber"); 106 134 }); 107 135 136 + modelBuilder.Entity("AltBot.Core.Models.SubscriberHistory", b => 137 + { 138 + b.HasOne("AltBot.Core.Models.Subscriber", "Subscriber") 139 + .WithMany("History") 140 + .HasForeignKey("Did") 141 + .OnDelete(DeleteBehavior.Cascade) 142 + .IsRequired() 143 + .HasConstraintName("fk_subscriber_history_subscriber"); 144 + 145 + b.Navigation("Subscriber"); 146 + }); 147 + 108 148 modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 109 149 { 150 + b.Navigation("History"); 151 + 110 152 b.Navigation("Posts"); 111 153 }); 112 154 #pragma warning restore 612, 618
+4
AltBot.ServiceDefaults/AltBot.ServiceDefaults.csproj
··· 25 25 <PackageReference Include="Serilog.Sinks.Debug" /> 26 26 </ItemGroup> 27 27 28 + <ItemGroup> 29 + <ProjectReference Include="..\AltBot.Core\AltBot.Core.csproj" /> 30 + </ItemGroup> 31 + 28 32 </Project>
+29
AltBot.ServiceDefaults/GlobalExceptionHandler.cs
··· 1 + using Microsoft.AspNetCore.Diagnostics; 2 + using Microsoft.AspNetCore.Http; 3 + using Microsoft.AspNetCore.Mvc; 4 + using Microsoft.Extensions.Logging; 5 + 6 + namespace AltBot.ServiceDefaults; 7 + 8 + internal sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler 9 + { 10 + public async ValueTask<bool> TryHandleAsync( 11 + HttpContext httpContext, 12 + Exception exception, 13 + CancellationToken cancellationToken) 14 + { 15 + logger.LogCritical(exception, "Unhandled exception occurred: {Message}", exception.Message); 16 + 17 + var problemDetails = new ProblemDetails 18 + { 19 + Status = StatusCodes.Status500InternalServerError, 20 + Title = "Server error" 21 + }; 22 + 23 + httpContext.Response.StatusCode = problemDetails.Status.Value; 24 + 25 + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); 26 + 27 + return true; 28 + } 29 + }
+37
AltBot.ServiceDefaults/HaltExceptionHandler.cs
··· 1 + using AltBot.Core.Exceptions; 2 + using Microsoft.AspNetCore.Diagnostics; 3 + using Microsoft.AspNetCore.Http; 4 + using Microsoft.AspNetCore.Mvc; 5 + using Microsoft.Extensions.Logging; 6 + 7 + namespace AltBot.ServiceDefaults; 8 + 9 + internal sealed class HaltExceptionHandler(ILogger<HaltExceptionHandler> logger) : IExceptionHandler 10 + { 11 + public async ValueTask<bool> TryHandleAsync( 12 + HttpContext httpContext, 13 + Exception exception, 14 + CancellationToken cancellationToken) 15 + { 16 + if (exception is not HaltException haltException) 17 + { 18 + return false; 19 + } 20 + 21 + logger.LogCritical(haltException, "AltBot crash: {Message}", haltException.Message); 22 + 23 + var problemDetails = new ProblemDetails 24 + { 25 + Status = StatusCodes.Status500InternalServerError, 26 + Title = "AltBot Halted", 27 + Detail = haltException.Message 28 + }; 29 + 30 + httpContext.Response.StatusCode = problemDetails.Status.Value; 31 + 32 + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); 33 + 34 + return true; 35 + } 36 + } 37 +
+3 -1
AltBot.ServiceDefaults/StartupExtensions.cs
··· 4 4 using Microsoft.Extensions.Diagnostics.HealthChecks; 5 5 using Microsoft.Extensions.Hosting; 6 6 using Microsoft.Extensions.Logging; 7 - using Microsoft.Extensions.ServiceDiscovery; 8 7 using OpenTelemetry; 9 8 using OpenTelemetry.Metrics; 10 9 using OpenTelemetry.Trace; ··· 28 27 builder.ConfigureOpenTelemetry(); 29 28 30 29 builder.AddDefaultHealthChecks(); 30 + 31 + builder.Services.AddExceptionHandler<HaltExceptionHandler>(); 32 + builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); 31 33 32 34 builder.Services.AddServiceDiscovery(); 33 35
+1
Directory.Packages.props
··· 8 8 <PackageVersion Include="Aspire.Hosting.AppHost" Version="9.5.1" /> 9 9 <PackageVersion Include="Aspire.Hosting.Testing" Version="9.5.1" /> 10 10 <PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="9.5.1" /> 11 + <PackageVersion Include="idunno.Bluesky" Version="1.1.0" /> 11 12 <PackageVersion Include="Scalar.Aspire" Version="0.6.0" /> 12 13 <!-- Entity Framework --> 13 14 <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />