├── Directory.Build.targets ├── docs ├── image01.png └── image02.png ├── .clang-format ├── ResXManager.config.xml ├── RuItUnion.FeedbackBot ├── appsettings.Development.json ├── Services │ ├── TopicTitleGenerator.cs │ ├── FeedbackMetricsService.cs │ └── ReplyUserIdAdvancedResolver.cs ├── GlobalUsings.cs ├── RuItUnion.FeedbackBot.csproj.DotSettings ├── appsettings.json ├── Options │ └── AppOptions.cs ├── Middlewares │ ├── ThreadCommandFilterMiddleware.cs │ ├── AdminRoleSyncMiddleware.cs │ ├── MessageEditorMiddleware.cs │ ├── MessageCopierMiddleware.cs │ └── MessageForwarderMiddleware.cs ├── Properties │ ├── launchSettings.json │ ├── Resources.resx │ ├── Resources.en.resx │ └── Resources.Designer.cs ├── Dockerfile ├── RuItUnion.FeedbackBot.csproj ├── Commands │ └── ThreadController.cs └── Program.cs ├── RuItUnion.FeedbackBot.AppHost ├── appsettings.Development.json ├── appsettings.json ├── Program.cs ├── RuItUnion.FeedbackBot.AppHost.csproj └── Properties │ └── launchSettings.json ├── RuItUnion.FeedbackBot.Data ├── GlobalUsings.cs ├── IFeedbackBotContext.cs ├── FeedbackBotContextFactory.cs ├── RuItUnion.FeedbackBot.Data.csproj ├── Migrations │ ├── 20250828231422_ServiceAdminRole.cs │ ├── 20241117151116_Init.cs │ ├── 20241117151116_Init.Designer.cs │ ├── FeedbackBotContextModelSnapshot.cs │ └── 20250828231422_ServiceAdminRole.Designer.cs ├── Models │ ├── DbReply.cs │ └── DbTopic.cs └── FeedbackBotContext.cs ├── launchSettings.json ├── docker-compose.override.yml ├── feedback_bot.env ├── RuItUnion.FeedbackBot.Data.Old ├── RuItUnion.FeedbackBot.Data.Old.csproj ├── Models │ ├── User.cs │ ├── Reply.cs │ └── Topic.cs ├── OldDatabaseContext.cs ├── Migrator.cs └── Migrations │ ├── 20240614122306_Initial.cs │ ├── DatabaseContextModelSnapshot.cs │ └── 20240614122306_Initial.Designer.cs ├── .dockerignore ├── Directory.Build.props ├── .github └── workflows │ ├── build.yml │ └── publish-image.yml ├── RuItUnion.FeedbackBot.slnx ├── docker-compose.dcproj ├── RuItUnion.FeedbackBot.ServiceDefaults ├── RuItUnion.FeedbackBot.ServiceDefaults.csproj └── Extensions.cs ├── RuItUnion.FeedbackBot.Tests ├── RuItUnion.FeedbackBot.Tests.csproj └── ThreadCommandFilterTests.cs ├── Directory.Packages.props ├── docker-compose.yml ├── .gitattributes ├── README.md ├── .gitignore └── .editorconfig /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/image01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruitunion-org/feedback-bot/HEAD/docs/image01.png -------------------------------------------------------------------------------- /docs/image02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruitunion-org/feedback-bot/HEAD/docs/image02.png -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: JavaScript 3 | BasedOnStyle: LLVM 4 | ColumnLimit: 120 5 | IndentWidth: 4 6 | TabWidth: 4 7 | ... 8 | -------------------------------------------------------------------------------- /ResXManager.config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | ru-RU 4 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.AppHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // Global using directives 2 | 3 | global using Microsoft.EntityFrameworkCore; 4 | global using Microsoft.EntityFrameworkCore.Metadata.Builders; 5 | global using TgBotFrame.Commands.Authorization.Models; -------------------------------------------------------------------------------- /launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Docker Compose": { 4 | "commandName": "DockerCompose", 5 | "commandVersion": "1.0", 6 | "serviceActions": { 7 | "feedback_bot": "StartDebugging" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | feedback_bot: 3 | environment: 4 | - ASPNETCORE_ENVIRONMENT=Development 5 | ports: 6 | - target: 8080 7 | published: 10000 8 | - target: 8443 9 | published: 10001 10 | database: 11 | ports: 12 | - target: 5432 13 | published: 5432 -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/IFeedbackBotContext.cs: -------------------------------------------------------------------------------- 1 | using RuItUnion.FeedbackBot.Data.Models; 2 | using TgBotFrame.Commands.Authorization.Interfaces; 3 | 4 | namespace RuItUnion.FeedbackBot.Data; 5 | 6 | public interface IFeedbackBotContext : IAuthorizationData 7 | { 8 | DbSet Replies { get; init; } 9 | DbSet Topics { get; init; } 10 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Services/TopicTitleGenerator.cs: -------------------------------------------------------------------------------- 1 | using RuItUnion.FeedbackBot.Data.Models; 2 | 3 | namespace RuItUnion.FeedbackBot.Services; 4 | 5 | public class TopicTitleGenerator 6 | { 7 | public virtual string GetTopicTitle(in DbTopic topic) 8 | { 9 | string result = topic.ToString(); 10 | return result.Length > 128 ? result[..127] + @"…" : result; 11 | } 12 | } -------------------------------------------------------------------------------- /feedback_bot.env: -------------------------------------------------------------------------------- 1 | # Telergam bot token 2 | ConnectionStrings__Telegram="" 3 | 4 | # Telergam Chat ID 5 | AppOptions__FeedbackChatId= 6 | 7 | # Answer for start command 8 | AppOptions__Start="" 9 | 10 | # Enable data transfer if version 0.1.* was previously used 11 | Migrator__EnableMigratorFromV01=true 12 | 13 | # Automatically apply database schema changes 14 | Migrator__UpdateDatabase=true -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/RuItUnion.FeedbackBot.Data.Old.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // Global using directives 2 | 3 | global using Microsoft.EntityFrameworkCore; 4 | global using Microsoft.Extensions.Options; 5 | global using RuItUnion.FeedbackBot.Data; 6 | global using RuItUnion.FeedbackBot.Options; 7 | global using RuItUnion.FeedbackBot.Services; 8 | global using Telegram.Bot; 9 | global using Telegram.Bot.Types; 10 | global using TgBotFrame.Commands.Extensions; 11 | global using TgBotFrame.Middleware; 12 | global using static RuItUnion.FeedbackBot.Properties.Resources; -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | !**/.gitignore 26 | !.git/HEAD 27 | !.git/config 28 | !.git/packed-refs 29 | !.git/refs/heads/** -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.AppHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "Telegram": "" 11 | }, 12 | "Migrator": { 13 | "EnableMigratorFromV01": true, 14 | "UpdateDatabase": true 15 | }, 16 | "AppOptions": { 17 | "Start": "Текст /start", 18 | "FeedbackChatId": 0 19 | } 20 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Models/User.cs: -------------------------------------------------------------------------------- 1 | namespace RuItUnion.FeedbackBot.Data.Old.Models; 2 | 3 | public record User 4 | { 5 | public bool Banned { get; set; } 6 | public long TopicId { get; init; } 7 | public Topic? Topic { get; init; } 8 | public required long Id { get; init; } 9 | public int Version { get; set; } 10 | 11 | public void Ban() 12 | { 13 | Banned = true; 14 | Version++; 15 | } 16 | 17 | public void Unban() 18 | { 19 | Banned = false; 20 | Version++; 21 | } 22 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Models/Reply.cs: -------------------------------------------------------------------------------- 1 | namespace RuItUnion.FeedbackBot.Data.Old.Models; 2 | 3 | public record Reply 4 | { 5 | public long TopicId { get; init; } 6 | 7 | public Topic? Topic { get; init; } 8 | 9 | /// 10 | /// Id сообщения в боте 11 | /// 12 | public required long BotMessageId { get; init; } 13 | 14 | /// 15 | /// Id сообщения в чате обратной связи 16 | /// 17 | public required long Id { get; init; } 18 | 19 | public int Version { get; set; } 20 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/RuItUnion.FeedbackBot.csproj.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | Pessimistic 3 | Library -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Models/Topic.cs: -------------------------------------------------------------------------------- 1 | namespace RuItUnion.FeedbackBot.Data.Old.Models; 2 | 3 | public record Topic 4 | { 5 | public bool IsOpen { get; set; } 6 | public long UserId { get; init; } 7 | public User? User { get; init; } 8 | public ICollection Replies { get; init; } = []; 9 | public long Id { get; init; } 10 | public int Version { get; set; } 11 | 12 | public void Open() 13 | { 14 | IsOpen = true; 15 | Version++; 16 | } 17 | 18 | public void Close() 19 | { 20 | IsOpen = false; 21 | Version++; 22 | } 23 | } -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | enable 4 | enable 5 | true 6 | 7 | 13 | $(NoWarn);NU1507;NETSDK1201;PRI257;CS1591 14 | 15 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.AppHost/Program.cs: -------------------------------------------------------------------------------- 1 | using Projects; 2 | 3 | IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); 4 | 5 | IResourceBuilder tg = builder.AddConnectionString("Telegram"); 6 | 7 | IResourceBuilder db = builder.AddPostgres("RuItUnion-FeedbackBot-Database") 8 | .WithDataVolume("RuItUnion-FeedbackBot-Database-Data") 9 | .WithImageTag("17-alpine"); 10 | 11 | builder.AddProject("RuItUnion-FeedbackBot") 12 | .WithReference(db).WithReference(tg) 13 | .WithHttpHealthCheck("/health"); 14 | 15 | await builder.Build().RunAsync(); -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "OTEL_SERVICE_NAME": "RuItUnion.FeedbackBot.App", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information", 6 | "Microsoft.AspNetCore": "Warning" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | "RateLimit": { 11 | "Limit": 1, 12 | "Interval": "00:00:01" 13 | }, 14 | "ConnectionStrings": { 15 | "Telegram": "", 16 | "RuItUnion-FeedbackBot-Database": "" 17 | }, 18 | "AppOptions": { 19 | "FeedbackChatId": 0, 20 | "Start": "" 21 | }, 22 | "Migrator": { 23 | "EnableMigratorFromV01": false, 24 | "UpdateDatabase": true 25 | } 26 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build-and-test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build-and-test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v4 18 | with: 19 | dotnet-version: 10.0.x 20 | - name: Check code style 21 | run: dotnet format ./RuItUnion.FeedbackBot.slnx --no-restore --verify-no-changes --verbosity normal 22 | - name: Build 23 | run: dotnet build ./RuItUnion.FeedbackBot.slnx 24 | - name: Test 25 | run: dotnet test ./RuItUnion.FeedbackBot.slnx 26 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Options/AppOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace RuItUnion.FeedbackBot.Options; 4 | 5 | public record AppOptions 6 | { 7 | public const string NAME = "AppOptions"; 8 | 9 | public required long FeedbackChatId 10 | { 11 | get; 12 | init => field = value < 0 13 | ? value 14 | : long.Parse(@"-100" + value.ToString(@"D", CultureInfo.InvariantCulture), NumberStyles.AllowLeadingSign, 15 | CultureInfo.InvariantCulture); 16 | } 17 | 18 | public required string Start { get; init; } 19 | public string? DbConnectionString { get; init; } 20 | public string? FeedbackBotToken { get; init; } 21 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/FeedbackBotContextFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Design; 2 | 3 | namespace RuItUnion.FeedbackBot.Data; 4 | 5 | public class FeedbackBotContextFactory : IDesignTimeDbContextFactory 6 | { 7 | public FeedbackBotContext CreateDbContext(string[] args) 8 | { 9 | DbContextOptionsBuilder builder = new(); 10 | string connectionString = args.Length != 0 11 | ? string.Join(' ', args) 12 | : @"User ID=postgres;Password=postgres;Host=localhost;Port=5432;"; 13 | Console.WriteLine(@"connectionString = " + connectionString); 14 | return new(builder 15 | .UseNpgsql(connectionString) 16 | .Options); 17 | } 18 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.AppHost/RuItUnion.FeedbackBot.AppHost.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Exe 7 | net10.0 8 | enable 9 | enable 10 | true 11 | 2c134864-117b-4f1a-a03a-ed23061a15dd 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/RuItUnion.FeedbackBot.Data.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docker-compose.dcproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2.1 6 | Linux 7 | False 8 | 13443994-44e4-41d8-8a4f-1849cee163f6 9 | LaunchBrowser 10 | {Scheme}://localhost:{ServicePort}/health 11 | ruitunion.feedbackbot 12 | ruitunion-feedbackbot 13 | 14 | 15 | 16 | docker-compose.yml 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Middlewares/ThreadCommandFilterMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace RuItUnion.FeedbackBot.Middlewares; 2 | 3 | public class ThreadCommandFilterMiddleware(IOptions options, IFeedbackBotContext db) : FrameMiddleware 4 | { 5 | private readonly long _chatId = options.Value.FeedbackChatId; 6 | 7 | public override async Task InvokeAsync(Update update, FrameContext context, CancellationToken ct = default) 8 | { 9 | string? commandName = context.GetCommandName(); 10 | if (string.IsNullOrEmpty(commandName) 11 | || string.Equals(commandName, @"start", StringComparison.OrdinalIgnoreCase) 12 | || (_chatId == context.GetChatId() && context.GetThreadId() is not null) 13 | || await db.RoleMembers.AnyAsync(x => x.RoleId == -1 && x.UserId == context.GetUserId(), ct) 14 | .ConfigureAwait(false)) 15 | { 16 | await Next(update, context, ct).ConfigureAwait(false); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Migrations/20250828231422_ServiceAdminRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace RuItUnion.FeedbackBot.Data.Migrations 6 | { 7 | /// 8 | public partial class ServiceAdminRole : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.InsertData( 14 | schema: "FeedbackBot", 15 | table: "Roles", 16 | columns: new[] { "Id", "MentionEnabled", "Name" }, 17 | values: new object[] { -50, false, "service_admin" }); 18 | } 19 | 20 | /// 21 | protected override void Down(MigrationBuilder migrationBuilder) 22 | { 23 | migrationBuilder.DeleteData( 24 | schema: "FeedbackBot", 25 | table: "Roles", 26 | keyColumn: "Id", 27 | keyValue: -50); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.ServiceDefaults/RuItUnion.FeedbackBot.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Tests/RuItUnion.FeedbackBot.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/OldDatabaseContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using RuItUnion.FeedbackBot.Data.Old.Models; 3 | 4 | namespace RuItUnion.FeedbackBot.Data.Old; 5 | 6 | public class OldDatabaseContext(DbContextOptions options) : DbContext(options) 7 | { 8 | public DbSet Topic { get; set; } = null!; 9 | public DbSet Users { get; set; } = null!; 10 | public DbSet Replies { get; set; } = null!; 11 | 12 | protected override void OnModelCreating(ModelBuilder modelBuilder) 13 | { 14 | modelBuilder.Entity().Property(x => x.Version).IsConcurrencyToken(); 15 | modelBuilder.Entity().Property(r => r.Version).IsConcurrencyToken(); 16 | modelBuilder.Entity().Property(r => r.Version).IsConcurrencyToken(); 17 | 18 | modelBuilder.Entity().HasMany(x => x.Replies).WithOne(x => x.Topic); 19 | modelBuilder.Entity() 20 | .HasOne(x => x.User) 21 | .WithOne(x => x.Topic) 22 | .HasForeignKey(x => x.UserId); 23 | modelBuilder.Entity() 24 | .HasOne(x => x.Topic) 25 | .WithOne(x => x.User) 26 | .HasForeignKey(x => x.TopicId); 27 | } 28 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.AppHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "https://localhost:17202;http://localhost:15273", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21118", 13 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22028" 14 | } 15 | }, 16 | "http": { 17 | "commandName": "Project", 18 | "dotnetRunMessages": true, 19 | "launchBrowser": true, 20 | "applicationUrl": "http://localhost:15273", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_ENVIRONMENT": "Development", 24 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19265", 25 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20035" 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Models/DbReply.cs: -------------------------------------------------------------------------------- 1 | namespace RuItUnion.FeedbackBot.Data.Models; 2 | 3 | public class DbReply : IEntityTypeConfiguration, IEquatable 4 | { 5 | public required int ChatMessageId { get; init; } 6 | public required int ChatThreadId { get; init; } 7 | public required int UserMessageId { get; init; } 8 | 9 | public uint Version { get; init; } 10 | 11 | public DbTopic Topic { get; init; } = null!; 12 | 13 | public void Configure(EntityTypeBuilder entity) 14 | { 15 | entity.HasKey(x => x.ChatMessageId); 16 | entity.Property(x => x.ChatMessageId).ValueGeneratedNever(); 17 | 18 | entity.Property(x => x.Version).IsRowVersion(); 19 | 20 | entity.HasOne(x => x.Topic).WithMany(x => x.Replies).HasForeignKey(x => x.ChatThreadId).IsRequired() 21 | .OnDelete(DeleteBehavior.Cascade).HasPrincipalKey(x => x.ThreadId); 22 | } 23 | 24 | public bool Equals(DbReply? other) => 25 | other is not null && (ReferenceEquals(this, other) || ChatMessageId == other.ChatMessageId); 26 | 27 | public override bool Equals(object? obj) => 28 | obj is not null 29 | && (ReferenceEquals(this, obj) 30 | || (obj.GetType() == GetType() && Equals((DbReply)obj))); 31 | 32 | public override int GetHashCode() => ChatMessageId; 33 | 34 | public override string ToString() => $"{ChatMessageId:D}"; 35 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "weatherforecast", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "http://localhost:5108" 12 | }, 13 | "IIS Express": { 14 | "commandName": "IISExpress", 15 | "launchBrowser": true, 16 | "launchUrl": "weatherforecast", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "Container (Dockerfile)": { 22 | "commandName": "Docker", 23 | "launchBrowser": true, 24 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/weatherforecast", 25 | "environmentVariables": { 26 | "ASPNETCORE_HTTP_PORTS": "8080" 27 | }, 28 | "publishAllPorts": true, 29 | "useSSL": false 30 | } 31 | }, 32 | "$schema": "http://json.schemastore.org/launchsettings.json", 33 | "iisSettings": { 34 | "windowsAuthentication": false, 35 | "anonymousAuthentication": true, 36 | "iisExpress": { 37 | "applicationUrl": "http://localhost:65068", 38 | "sslPort": 0 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base 2 | ENV \ 3 | DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=0 \ 4 | LC_ALL=ru_RU.UTF-8 \ 5 | LANG=ru_RU.UTF-8 6 | RUN apk add --no-cache \ 7 | icu-data-full \ 8 | icu-libs \ 9 | curl 10 | USER $APP_UID 11 | WORKDIR /app 12 | EXPOSE 8080 13 | 14 | FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build 15 | ARG BUILD_CONFIGURATION=Release 16 | WORKDIR /src 17 | COPY ["Directory.Packages.props", "."] 18 | COPY ["Directory.Build.props", "."] 19 | COPY ["Directory.Build.targets", "."] 20 | COPY ["RuItUnion.FeedbackBot/RuItUnion.FeedbackBot.csproj", "RuItUnion.FeedbackBot/"] 21 | COPY ["RuItUnion.FeedbackBot.Data.Old/RuItUnion.FeedbackBot.Data.Old.csproj", "RuItUnion.FeedbackBot.Data.Old/"] 22 | COPY ["RuItUnion.FeedbackBot.Data/RuItUnion.FeedbackBot.Data.csproj", "RuItUnion.FeedbackBot.Data/"] 23 | COPY ["RuItUnion.FeedbackBot.ServiceDefaults/RuItUnion.FeedbackBot.ServiceDefaults.csproj", "RuItUnion.FeedbackBot.ServiceDefaults/"] 24 | RUN dotnet restore "./RuItUnion.FeedbackBot/RuItUnion.FeedbackBot.csproj" 25 | COPY . . 26 | WORKDIR "/src/RuItUnion.FeedbackBot" 27 | RUN dotnet build "./RuItUnion.FeedbackBot.csproj" -c $BUILD_CONFIGURATION -o /app/build 28 | 29 | FROM build AS publish 30 | ARG BUILD_CONFIGURATION=Release 31 | RUN dotnet publish "./RuItUnion.FeedbackBot.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 32 | 33 | FROM base AS final 34 | WORKDIR /app 35 | COPY --from=publish /app/publish . 36 | HEALTHCHECK --interval=30s --timeout=10s CMD curl -f http://localhost:8080/health || exit 1 37 | ENTRYPOINT ["dotnet", "RuItUnion.FeedbackBot.dll"] -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/FeedbackBotContext.cs: -------------------------------------------------------------------------------- 1 | using RuItUnion.FeedbackBot.Data.Models; 2 | using TgBotFrame.Commands.Authorization.Interfaces; 3 | 4 | namespace RuItUnion.FeedbackBot.Data; 5 | 6 | public class FeedbackBotContext(DbContextOptions options) : DbContext(options), IFeedbackBotContext 7 | { 8 | public DbSet Replies { get; init; } = null!; 9 | public DbSet Topics { get; init; } = null!; 10 | 11 | Task IAuthorizationData.SaveChangesAsync(CancellationToken cancellationToken) => 12 | base.SaveChangesAsync(cancellationToken); 13 | 14 | public DbSet Roles { get; init; } = null!; 15 | public DbSet RoleMembers { get; init; } = null!; 16 | public DbSet Bans { get; init; } = null!; 17 | public DbSet Users { get; init; } = null!; 18 | 19 | protected override void OnModelCreating(ModelBuilder modelBuilder) 20 | { 21 | base.OnModelCreating(modelBuilder); 22 | 23 | modelBuilder.HasDefaultSchema("FeedbackBot"); 24 | 25 | modelBuilder.Entity().HasData([ 26 | new() 27 | { 28 | Name = "service_admin", 29 | Id = -50, 30 | MentionEnabled = false, 31 | }, 32 | ]); 33 | 34 | IAuthorizationData.OnModelCreating(modelBuilder); 35 | modelBuilder.Entity(builder => { builder.Property("Version").IsRowVersion(); }); 36 | modelBuilder.Entity(builder => { builder.Property("Version").IsRowVersion(); }); 37 | modelBuilder.Entity(builder => { builder.Property("Version").IsRowVersion(); }); 38 | modelBuilder.Entity(builder => { builder.Property("Version").IsRowVersion(); }); 39 | 40 | modelBuilder.ApplyConfigurationsFromAssembly(typeof(FeedbackBotContext).Assembly); 41 | } 42 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Services/FeedbackMetricsService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.Metrics; 2 | 3 | namespace RuItUnion.FeedbackBot.Services; 4 | 5 | public sealed class FeedbackMetricsService : IDisposable 6 | { 7 | private readonly Counter _messagesCopied; 8 | private readonly Counter _messagesDeleted; 9 | private readonly Counter _messagesEdited; 10 | private readonly Counter _messagesForwarded; 11 | private readonly Meter _meter; 12 | 13 | public FeedbackMetricsService(IMeterFactory meterFactory) 14 | { 15 | _meter = meterFactory.Create(@"FeedbackBot"); 16 | 17 | _messagesCopied = _meter.CreateCounter(@"messages_copied"); 18 | _messagesForwarded = _meter.CreateCounter(@"messages_forwarded"); 19 | _messagesEdited = _meter.CreateCounter(@"messages_edited"); 20 | _messagesDeleted = _meter.CreateCounter(@"messages_deleted"); 21 | } 22 | 23 | public void Dispose() => _meter.Dispose(); 24 | 25 | public void IncMessagesCopied(in int threadId, in long authorId) => 26 | _messagesCopied.Add( 27 | 1, 28 | new(@"thread_id", threadId), 29 | new(@"author_id", authorId)); 30 | 31 | public void IncMessagesForwarded(in int threadId, in long authorId) => 32 | _messagesForwarded.Add( 33 | 1, 34 | new(@"thread_id", threadId), 35 | new(@"author_id", authorId)); 36 | 37 | public void IncMessagesEdited(in int threadId, in long authorId) => 38 | _messagesEdited.Add( 39 | 1, 40 | new(@"thread_id", threadId), 41 | new(@"author_id", authorId)); 42 | 43 | public void IncMessagesDeleted(in int threadId, in long authorId) => 44 | _messagesDeleted.Add( 45 | 1, 46 | new(@"thread_id", threadId), 47 | new(@"author_id", authorId)); 48 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Models/DbTopic.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace RuItUnion.FeedbackBot.Data.Models; 4 | 5 | public class DbTopic : IEntityTypeConfiguration, IEquatable 6 | { 7 | public int Id { get; init; } 8 | public required int ThreadId { get; set; } 9 | public required long UserChatId { get; init; } 10 | public bool IsOpen { get; set; } = true; 11 | public uint Version { get; init; } 12 | 13 | public DbUser User { get; init; } = null!; 14 | public IList Replies { get; init; } = null!; 15 | 16 | public void Configure(EntityTypeBuilder entity) 17 | { 18 | entity.HasKey(x => x.Id); 19 | 20 | entity.Property(x => x.ThreadId); 21 | entity.Property(x => x.UserChatId); 22 | entity.Property(x => x.IsOpen); 23 | entity.Property(x => x.Version).IsRowVersion(); 24 | 25 | entity.HasIndex(x => x.UserChatId).IsUnique(); 26 | entity.HasIndex(x => x.ThreadId).IsUnique(); 27 | 28 | entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserChatId).IsRequired() 29 | .OnDelete(DeleteBehavior.Cascade); 30 | entity.HasMany(x => x.Replies).WithOne(x => x.Topic).HasForeignKey(x => x.ChatThreadId).IsRequired() 31 | .OnDelete(DeleteBehavior.Cascade).HasPrincipalKey(x => x.ThreadId); 32 | } 33 | 34 | public bool Equals(DbTopic? other) => other is not null && (ReferenceEquals(this, other) || Id == other.Id); 35 | 36 | public override bool Equals(object? obj) => 37 | obj is not null 38 | && (ReferenceEquals(this, obj) 39 | || (obj.GetType() == GetType() && Equals((DbTopic)obj))); 40 | 41 | public override int GetHashCode() => Id; 42 | 43 | public override string ToString() => 44 | $"{(IsOpen ? "\ud83d\udfe9" : "\ud83d\udfe5")}\t{User?.ToString() ?? UserChatId.ToString(@"D", CultureInfo.InvariantCulture)}"; 45 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Services/ReplyUserIdAdvancedResolver.cs: -------------------------------------------------------------------------------- 1 | using Telegram.Bot.Types.Enums; 2 | using TgBotFrame.Commands.Authorization.Services; 3 | 4 | namespace RuItUnion.FeedbackBot.Services; 5 | 6 | public class ReplyUserIdAdvancedResolver(IFeedbackBotContext db) : ReplyUserIdResolver 7 | { 8 | public override async ValueTask GetReplyUserId(Update update, CancellationToken ct = default) 9 | { 10 | if (update.Message?.ReplyToMessage?.From?.IsBot != true || update.Message.MessageThreadId is null) 11 | { 12 | return update.Message?.ReplyToMessage?.From?.Id; 13 | } 14 | 15 | long userId = 0; 16 | switch (update.Message?.ReplyToMessage?.ForwardOrigin?.Type) 17 | { 18 | case MessageOriginType.Chat: 19 | userId = ((MessageOriginChat)update.Message.ReplyToMessage.ForwardOrigin).SenderChat.Id; 20 | break; 21 | case MessageOriginType.Channel: 22 | userId = ((MessageOriginChannel)update.Message.ReplyToMessage.ForwardOrigin).Chat.Id; 23 | break; 24 | case MessageOriginType.User: 25 | userId = ((MessageOriginUser)update.Message.ReplyToMessage.ForwardOrigin).SenderUser.Id; 26 | break; 27 | case MessageOriginType.HiddenUser: 28 | if (update.Message?.MessageThreadId is not null 29 | && update.Message.ReplyToMessage?.Type is < MessageType.ForumTopicCreated 30 | or > MessageType.GeneralForumTopicUnhidden 31 | && update.Message?.ReplyToMessage?.From?.IsBot == true 32 | && update.Message.ReplyToMessage.ForwardOrigin is not null) 33 | { 34 | userId = await db.Topics 35 | .Where(x => x.ThreadId == update.Message.MessageThreadId) 36 | .Select(x => x.UserChatId) 37 | .FirstOrDefaultAsync(ct).ConfigureAwait(false); 38 | } 39 | 40 | break; 41 | case null: 42 | break; 43 | default: 44 | throw new ArgumentOutOfRangeException(); 45 | } 46 | 47 | return userId == 0 ? null : userId; 48 | } 49 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Middlewares/AdminRoleSyncMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Telegram.Bot.Types.Enums; 2 | 3 | namespace RuItUnion.FeedbackBot.Middlewares; 4 | 5 | public class AdminRoleSyncMiddleware( 6 | IOptions options, 7 | ITelegramBotClient botClient, 8 | IFeedbackBotContext db, 9 | ILogger logger) 10 | : FrameMiddleware 11 | { 12 | private readonly long _chatId = options.Value.FeedbackChatId; 13 | 14 | public override async Task InvokeAsync(Update update, FrameContext context, CancellationToken ct = default) 15 | { 16 | if (update.Message?.Chat.Id == _chatId) 17 | { 18 | if (update.Message.LeftChatMember is not null) 19 | { 20 | await db.RoleMembers.Where(x => x.UserId == update.Message.LeftChatMember.Id).ExecuteDeleteAsync(ct) 21 | .ConfigureAwait(false); 22 | logger.LogInformation(@"Removed bot's admin rights for user {user} with telegram id = {id:D}", 23 | update.Message.LeftChatMember.Username, 24 | update.Message.LeftChatMember.Id); 25 | } 26 | else if (context.GetCommandName() is not null) 27 | { 28 | long? chatId = context.GetChatId()!; 29 | long? userId = context.GetUserId()!; 30 | ChatMember[] admins = await botClient.GetChatAdministrators(chatId, ct).ConfigureAwait(false); 31 | bool isChatAdmin = admins.Any(x => 32 | x.User.Id == userId && x.Status is ChatMemberStatus.Creator or ChatMemberStatus.Administrator); 33 | if (isChatAdmin && !await db.RoleMembers.AnyAsync(x => x.UserId == userId && x.RoleId == -1, ct) 34 | .ConfigureAwait(false)) 35 | { 36 | await db.RoleMembers.AddAsync(new() 37 | { 38 | RoleId = -1, 39 | UserId = userId.Value, 40 | }, ct).ConfigureAwait(false); 41 | await db.SaveChangesAsync(ct).ConfigureAwait(false); 42 | } 43 | 44 | logger.LogInformation(@"Granted bot's admin rights for user with telegram id = {id:D}", 45 | userId); 46 | } 47 | } 48 | 49 | await Next(update, context, ct).ConfigureAwait(false); 50 | } 51 | } -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | postgres_data: 3 | driver: local 4 | 5 | services: 6 | feedback_bot: 7 | container_name: "RuItUnion.FeedbackBot.App" 8 | image: ghcr.io/ruitunion-org/feedback-bot:v1.4.0 9 | build: 10 | context: . 11 | dockerfile: RuItUnion.FeedbackBot/Dockerfile 12 | healthcheck: 13 | test: curl -f http://localhost:8080/health || exit 1 14 | interval: 30s 15 | timeout: 10s 16 | env_file: 17 | - path: feedback_bot.env 18 | required: true 19 | environment: 20 | ConnectionStrings__RuItUnion-FeedbackBot-Database: "Host=database;Port=5432;Username=postgres;Password=guX4Gk9xaYI3DJcdg4s05t" 21 | OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" 22 | OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" 23 | OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" 24 | ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" 25 | HTTP_PORTS: "8080" 26 | OTEL_EXPORTER_OTLP_ENDPOINT: "http://dashboard:18889" 27 | OTEL_SERVICE_NAME: "RuItUnion-FeedbackBot" 28 | restart: always 29 | depends_on: 30 | database: 31 | condition: service_healthy 32 | deploy: 33 | resources: 34 | limits: 35 | memory: 256m 36 | 37 | database: 38 | container_name: "RuItUnion.FeedbackBot.Database" 39 | image: "docker.io/library/postgres:18" 40 | healthcheck: 41 | test: pg_isready -h localhost -U $$POSTGRES_USER 42 | interval: 10s 43 | timeout: 10s 44 | retries: 5 45 | environment: 46 | POSTGRES_HOST_AUTH_METHOD: "scram-sha-256" 47 | POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256 --locale-provider=icu --icu-locale=ru-RU --encoding=UTF8" 48 | POSTGRES_USER: "postgres" 49 | POSTGRES_PASSWORD: "guX4Gk9xaYI3DJcdg4s05t" 50 | OTEL_EXPORTER_OTLP_ENDPOINT: "http://dashboard:18889" 51 | OTEL_SERVICE_NAME: "RuItUnion-FeedbackBot-Database" 52 | volumes: 53 | - "postgres_data:/var/lib/postgresql" 54 | restart: unless-stopped 55 | shm_size: 128mb 56 | deploy: 57 | resources: 58 | limits: 59 | memory: 128m 60 | 61 | dashboard: 62 | container_name: "RuItUnion.FeedbackBot.Dashboard" 63 | image: "mcr.microsoft.com/dotnet/aspire-dashboard:9" 64 | environment: 65 | DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "false" 66 | Dashboard__ApplicationName: "RuItUnion.FeedbackBot" 67 | ports: 68 | - target: 18888 69 | published: 18888 70 | restart: unless-stopped 71 | deploy: 72 | resources: 73 | limits: 74 | memory: 64m -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Migrator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Logging; 3 | using RuItUnion.FeedbackBot.Data.Models; 4 | using RuItUnion.FeedbackBot.Data.Old.Models; 5 | 6 | namespace RuItUnion.FeedbackBot.Data.Old; 7 | 8 | public class Migrator(IFeedbackBotContext newContext, OldDatabaseContext oldContext, ILogger logger) 9 | { 10 | public virtual async Task Migrate(CancellationToken cancellationToken = default) 11 | { 12 | string[] oldMigrations = 13 | (await oldContext.Database.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false)).ToArray(); 14 | if (!oldMigrations.Any(x => x.EndsWith("_Initial"))) 15 | { 16 | logger.LogInformation("No old migration in database, skipping..."); 17 | return; 18 | } 19 | 20 | foreach (User oldUser in oldContext.Users.AsNoTracking()) 21 | { 22 | await newContext.Users.AddAsync(new() 23 | { 24 | Id = oldUser.Id, 25 | FirstName = string.Empty, 26 | LastName = null, 27 | UserName = null, 28 | }, cancellationToken).ConfigureAwait(false); 29 | if (oldUser.Banned) 30 | { 31 | await newContext.Bans.AddAsync(new() 32 | { 33 | UserId = oldUser.Id, 34 | Until = DateTime.MaxValue, 35 | Description = string.Empty, 36 | }, cancellationToken).ConfigureAwait(false); 37 | } 38 | } 39 | 40 | foreach (Topic topic in oldContext.Topic.AsNoTracking().Include(x => x.User).Include(x => x.Replies)) 41 | { 42 | await newContext.Topics.AddAsync(new() 43 | { 44 | ThreadId = (int)topic.Id, 45 | UserChatId = topic.UserId, 46 | IsOpen = topic.IsOpen, 47 | Replies = topic.Replies.Select(x => new DbReply 48 | { 49 | ChatThreadId = (int)x.TopicId, 50 | UserMessageId = (int)x.BotMessageId, 51 | ChatMessageId = (int)x.Id, 52 | }).ToList(), 53 | }, cancellationToken).ConfigureAwait(false); 54 | } 55 | 56 | await newContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); 57 | 58 | int deleted = await oldContext.Replies.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); 59 | deleted += await oldContext.Topic.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); 60 | deleted += await oldContext.Users.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); 61 | 62 | if (deleted > 0) 63 | { 64 | logger.LogWarning("Use /sync command in group chat for update topic headers"); 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Middlewares/MessageEditorMiddleware.cs: -------------------------------------------------------------------------------- 1 | using RuItUnion.FeedbackBot.Data.Models; 2 | 3 | namespace RuItUnion.FeedbackBot.Middlewares; 4 | 5 | public class MessageEditorMiddleware( 6 | IOptions options, 7 | ITelegramBotClient botClient, 8 | IFeedbackBotContext db, 9 | ILogger logger, 10 | FeedbackMetricsService feedbackMetricsService) : FrameMiddleware 11 | { 12 | private readonly long _chatId = options.Value.FeedbackChatId; 13 | 14 | public override async Task InvokeAsync(Update update, FrameContext context, CancellationToken ct = default) 15 | { 16 | if (update.EditedMessage is not null) 17 | { 18 | await ProcessEditedMessage(update.EditedMessage, context, ct).ConfigureAwait(false); 19 | } 20 | 21 | await Next(update, context, ct).ConfigureAwait(false); 22 | } 23 | 24 | private async Task ProcessEditedMessage(Message editedMessage, FrameContext context, CancellationToken ct = default) 25 | { 26 | if (editedMessage.Chat.Id == _chatId) 27 | { 28 | DbReply? reply = await db.Replies.AsNoTracking().Include(x => x.Topic).FirstOrDefaultAsync(x => 29 | x.ChatThreadId == editedMessage.MessageThreadId 30 | && x.ChatMessageId == editedMessage.MessageId, ct).ConfigureAwait(false); 31 | if (reply is null) 32 | { 33 | logger.LogWarning(@"Reply {messageId} in topic {topicId} not found in DB", 34 | editedMessage.MessageId, editedMessage.MessageThreadId); 35 | } 36 | else 37 | { 38 | if (reply.UserMessageId < 0) 39 | { 40 | return; 41 | } 42 | 43 | await botClient.EditMessageText(reply.Topic.UserChatId, reply.UserMessageId, 44 | editedMessage.Text!, cancellationToken: ct).ConfigureAwait(false); 45 | OnSuccess(editedMessage, reply); 46 | } 47 | } 48 | else 49 | { 50 | await botClient.SendMessage( 51 | editedMessage.Chat.Id, 52 | ResourceManager.GetString(nameof(MessageEditorMiddleware_NotSupported), 53 | context.GetCultureInfo())!, 54 | messageThreadId: editedMessage.MessageThreadId, cancellationToken: ct) 55 | .ConfigureAwait(false); 56 | logger.LogInformation( 57 | @"User {username} with id = {userId} tried to edit message {messageId} in chat {chatId}", 58 | editedMessage.From?.Username, 59 | editedMessage.From?.Id ?? 0L, 60 | editedMessage.Id, 61 | editedMessage.Chat.Id); 62 | } 63 | } 64 | 65 | protected virtual void OnSuccess(Message message, DbReply reply) 66 | { 67 | logger.LogInformation(@"Edited message {messageId} in chat {chatId}", reply.UserMessageId, 68 | reply.Topic.UserChatId); 69 | feedbackMetricsService.IncMessagesEdited(reply.ChatThreadId, message.From?.Id ?? 0L); 70 | } 71 | } -------------------------------------------------------------------------------- /.github/workflows/publish-image.yml: -------------------------------------------------------------------------------- 1 | # 2 | name: Create and publish a Docker image 3 | 4 | on: 5 | push: 6 | tags: 7 | - v* 8 | 9 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 15 | jobs: 16 | build-and-push-image: 17 | runs-on: ubuntu-latest 18 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 19 | permissions: 20 | contents: read 21 | packages: write 22 | attestations: write 23 | id-token: write 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | - name: Log in to the Container registry 28 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 34 | - name: Extract metadata (tags, labels) for Docker 35 | id: meta 36 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 37 | with: 38 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 40 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 41 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 42 | - name: Build and push Docker image 43 | id: push 44 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 45 | with: 46 | context: . 47 | file: RuItUnion.FeedbackBot/Dockerfile 48 | push: true 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | 52 | # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." 53 | - name: Generate artifact attestation 54 | uses: actions/attest-build-provenance@v1 55 | with: 56 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 57 | subject-digest: ${{ steps.push.outputs.digest }} 58 | push-to-registry: true 59 | 60 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Migrations/20240614122306_Initial.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 3 | 4 | #nullable disable 5 | 6 | namespace Bot.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Topic", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "bigint", nullable: false) 19 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 20 | Version = table.Column(type: "integer", nullable: false), 21 | IsOpen = table.Column(type: "boolean", nullable: false), 22 | UserId = table.Column(type: "bigint", nullable: false) 23 | }, 24 | constraints: table => 25 | { 26 | table.PrimaryKey("PK_Topic", x => x.Id); 27 | }); 28 | 29 | migrationBuilder.CreateTable( 30 | name: "Replies", 31 | columns: table => new 32 | { 33 | Id = table.Column(type: "bigint", nullable: false) 34 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 35 | Version = table.Column(type: "integer", nullable: false), 36 | TopicId = table.Column(type: "bigint", nullable: false), 37 | BotMessageId = table.Column(type: "bigint", nullable: false) 38 | }, 39 | constraints: table => 40 | { 41 | table.PrimaryKey("PK_Replies", x => x.Id); 42 | table.ForeignKey( 43 | name: "FK_Replies_Topic_TopicId", 44 | column: x => x.TopicId, 45 | principalTable: "Topic", 46 | principalColumn: "Id", 47 | onDelete: ReferentialAction.Cascade); 48 | }); 49 | 50 | migrationBuilder.CreateTable( 51 | name: "Users", 52 | columns: table => new 53 | { 54 | Id = table.Column(type: "bigint", nullable: false) 55 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 56 | Version = table.Column(type: "integer", nullable: false), 57 | Banned = table.Column(type: "boolean", nullable: false), 58 | TopicId = table.Column(type: "bigint", nullable: false) 59 | }, 60 | constraints: table => 61 | { 62 | table.PrimaryKey("PK_Users", x => x.Id); 63 | table.ForeignKey( 64 | name: "FK_Users_Topic_TopicId", 65 | column: x => x.TopicId, 66 | principalTable: "Topic", 67 | principalColumn: "Id", 68 | onDelete: ReferentialAction.Cascade); 69 | }); 70 | 71 | migrationBuilder.CreateIndex( 72 | name: "IX_Replies_TopicId", 73 | table: "Replies", 74 | column: "TopicId"); 75 | 76 | migrationBuilder.CreateIndex( 77 | name: "IX_Users_TopicId", 78 | table: "Users", 79 | column: "TopicId", 80 | unique: true); 81 | } 82 | 83 | /// 84 | protected override void Down(MigrationBuilder migrationBuilder) 85 | { 86 | migrationBuilder.DropTable( 87 | name: "Replies"); 88 | 89 | migrationBuilder.DropTable( 90 | name: "Users"); 91 | 92 | migrationBuilder.DropTable( 93 | name: "Topic"); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feedback Bot 2 | 3 | [![build-and-test](https://github.com/ruitunion-org/feedback-bot/actions/workflows/build.yml/badge.svg)](https://github.com/ruitunion-org/feedback-bot/actions/workflows/build.yml) 4 | 5 | A free and open-source Telegram Bot that allows you to anonymously 6 | chat with multiple users in one Telegram Chat. 7 | 8 | When a new user interacts with the Feedback Bot by sending a message, 9 | the bot forwards the message to a specific topic dedicated to that user 10 | within the Feedback Chat. If a topic for the user does not already 11 | exist, the bot creates a new one. This ensures that each user's 12 | message is organized in its own topic, allowing for clear and efficient 13 | interaction. 14 | 15 | Created and supported by [Russian IT Union](https://ruitunion.org/en/about/). 16 | 17 | ## 📝 Prerequisites 18 | 19 | 1. Telegram Bot Token; 20 | 2. Telegram Chat ID. 21 | 22 | ## 🛠️ Prepare the Environment 23 | 24 | ### Telegram bot 25 | 26 | 1. Create a bot with [@BotFather](https://t.me/BotFather); 27 | 2. After successfully creating the bot, 28 | you will receive a **Telegram Bot Token**. 29 | 30 | ### Feedback Chat 31 | 32 | 1. Create a private chat; 33 | 2. Enable topics; 34 | 3. Get the **Telegram Chat ID**; 35 | 36 | > You can get the ID of any user / chat / bot using desktop Telegram client. 37 | To do that go to *Settings* / *Advanced* / *Experimental settings* and 38 | enable *Show peer ID in Profile*. Now you will be able to see all the IDs. 39 | 40 | ![how_to_get_id](./docs/image01.png) 41 | 42 | 4. Add the bot from the previous step and make it Administrator; 43 | 5. The only permission required for the bot is to manage topics. 44 | 45 | ![required_permissions](./docs/image02.png) 46 | 47 | ## 🚀 Run 48 | 49 | ### From GitHub Container Registry 50 | 51 | 1. Download `docker-compose.yml` & `feedback_bot.env` files into one folder; 52 | 2. Edit `feedback_bot.env` with any text editor and replace these values: 53 | - `` with **Telegram Bot Token**, 54 | - `` with **Telegram Chat ID**, 55 | - `` with your greeting message your new users; 56 | 3. Run the following command: 57 | 58 | ```sh 59 | docker compose up -d 60 | ``` 61 | 62 | ### From source code 63 | 64 | 1. Clone this repo; 65 | 2. Edit `feedback_bot.env` with any text editor and replace these values: 66 | - `` with **Telegram Bot Token**, 67 | - `` with **Telegram Chat ID**, 68 | - `` with your greeting message your new users; 69 | 3. Edit `docker-compose.yml`: in `feedback_bot` section change `image` 70 | value to `ghcr.io/ruitunion-org/feedback-bot:local` 71 | 3. Run the following commands: 72 | 73 | ```sh 74 | docker build -t ghcr.io/ruitunion-org/feedback-bot:local -f ./RuItUnion.FeedbackBot/Dockerfile . 75 | docker compose up -d 76 | ``` 77 | 78 | ## 🌟 Features 79 | 80 | ### Commands 81 | 82 | Commands other than `/start` are only available in group chat. 83 | Use `/help` to get information about all commands. 84 | 85 | - `/start` - Starts the bot and displays a welcome message. 86 | - `/help` - Displays a list of all commands with their descriptions. 87 | - `/delete` - Removes a reply in the user chat. 88 | - `/open` - Opens a topic in the feedback chat. 89 | - `/close` - Closes a topic in the feedback chat. 90 | - `/ban` - Bans the user. 91 | - `/unban` - Unbans the user. 92 | 93 | ### Permissions 94 | 95 | | Command | Bot User | Chat User | Chat Admin | 96 | | --------- | -------- | --------- | ---------- | 97 | | `/help` | ✅ | ✅ | ✅ | 98 | | `/start` | ✅ | ✅ | ✅ | 99 | | `/delete` | ❌ | ✅ | ✅ | 100 | | `/open` | ❌ | ❌ | ✅ | 101 | | `/close` | ❌ | ❌ | ✅ | 102 | | `/ban` | ❌ | ❌ | ✅ | 103 | | `/unban` | ❌ | ❌ | ✅ | 104 | 105 | ## 🤝 Contributing 106 | 107 | Contributions are welcome. Here are some ways you can help: 108 | 109 | 1. **Report bugs**: If you find a bug, please report it by creating an 110 | issue on GitHub; 111 | 2. **Request features**: Have an idea for a new feature? 112 | Let us know by creating a feature request; 113 | 3. **Submit pull requests**: If you'd like to fix a bug or add a feature, 114 | feel free to fork the repository and submit a pull request. 115 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Tests/ThreadCommandFilterTests.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Microsoft.Extensions.Options; 3 | using RuItUnion.FeedbackBot.Middlewares; 4 | using RuItUnion.FeedbackBot.Options; 5 | using Telegram.Bot.Types; 6 | using TgBotFrame.Middleware; 7 | 8 | namespace RuItUnion.FeedbackBot.Tests; 9 | 10 | public class ThreadCommandFilterTests 11 | { 12 | private const int CHAT_ID = -100123; 13 | 14 | private readonly ThreadCommandFilterMiddleware _commandFilterMiddleware = new(new OptionsWrapper(new() 15 | { 16 | FeedbackChatId = CHAT_ID, 17 | Start = "", 18 | }), null!); 19 | 20 | public ThreadCommandFilterTests() 21 | { 22 | Delegate = DelegateAction; 23 | PropertyInfo property = typeof(ThreadCommandFilterMiddleware).GetProperty("Next", BindingFlags.NonPublic 24 | | BindingFlags.GetProperty 25 | | BindingFlags.SetProperty 26 | | BindingFlags.Instance) ?? throw new MethodAccessException(); 27 | property.SetValue(_commandFilterMiddleware, Delegate); 28 | Passed = false; 29 | } 30 | 31 | private bool Passed { get; set; } 32 | private FrameUpdateDelegate Delegate { get; } 33 | 34 | private Task DelegateAction(Update update, FrameContext context, CancellationToken ct = default) 35 | { 36 | Passed = true; 37 | return Task.CompletedTask; 38 | } 39 | 40 | [Fact] 41 | public async Task PassCommand() 42 | { 43 | await _commandFilterMiddleware.InvokeAsync(new() 44 | { 45 | Message = new() 46 | { 47 | Id = 1, 48 | Text = "123", 49 | }, 50 | }, new() 51 | { 52 | Properties = 53 | { 54 | { "CommandName", "help" }, 55 | { "ChatId", (long?)CHAT_ID }, 56 | { "UserId", (long?)CHAT_ID }, 57 | { "ThreadId", (int?)2 }, 58 | }, 59 | }, CancellationToken.None); 60 | 61 | Assert.True(Passed); 62 | } 63 | 64 | [Fact] 65 | public async Task PassCommandNotInDm() 66 | { 67 | await _commandFilterMiddleware.InvokeAsync(new() 68 | { 69 | Message = new() 70 | { 71 | Id = 1, 72 | Text = "123", 73 | }, 74 | }, new() 75 | { 76 | Properties = 77 | { 78 | { "CommandName", "help" }, 79 | { "ChatId", (long?)CHAT_ID }, 80 | { "UserId", (long?)CHAT_ID + 1 }, 81 | { "ThreadId", (int?)2 }, 82 | }, 83 | }, CancellationToken.None); 84 | 85 | Assert.True(Passed); 86 | } 87 | 88 | [Fact] 89 | public async Task PassStart() 90 | { 91 | await _commandFilterMiddleware.InvokeAsync(new() 92 | { 93 | Message = new() 94 | { 95 | Id = 1, 96 | Text = "123", 97 | }, 98 | }, new() 99 | { 100 | Properties = 101 | { 102 | { "CommandName", "start" }, 103 | { "ChatId", 1 }, 104 | { "ThreadId", null }, 105 | }, 106 | }, CancellationToken.None); 107 | 108 | Assert.True(Passed); 109 | } 110 | 111 | [Fact] 112 | public async Task PassEmpty() 113 | { 114 | await _commandFilterMiddleware.InvokeAsync(new() 115 | { 116 | Message = new() 117 | { 118 | Id = 1, 119 | Text = "123", 120 | }, 121 | }, new() 122 | { 123 | Properties = 124 | { 125 | { "CommandName", null }, 126 | { "ChatId", 1 }, 127 | { "ThreadId", null }, 128 | }, 129 | }, CancellationToken.None); 130 | 131 | Assert.True(Passed); 132 | } 133 | 134 | //[Fact] 135 | //public async Task NonPassCommand() 136 | //{ 137 | // await _commandFilterMiddleware.InvokeAsync(new() 138 | // { 139 | // Message = new() 140 | // { 141 | // Id = 1, 142 | // Text = "123", 143 | // }, 144 | // }, new() 145 | // { 146 | // Properties = 147 | // { 148 | // { "CommandName", "open" }, 149 | // { "ChatId", (long?)1L }, 150 | // { "ThreadId", null }, 151 | // }, 152 | // }, CancellationToken.None); 153 | 154 | // Assert.False(Passed); 155 | //} 156 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Migrations/DatabaseContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Bot; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using RuItUnion.FeedbackBot.Data.Old; 8 | 9 | #nullable disable 10 | 11 | namespace Bot.Migrations 12 | { 13 | [DbContext(typeof(OldDatabaseContext))] 14 | partial class DatabaseContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "8.0.6") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Bot.Reply", b => 26 | { 27 | b.Property("Id") 28 | .ValueGeneratedOnAdd() 29 | .HasColumnType("bigint"); 30 | 31 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 32 | 33 | b.Property("BotMessageId") 34 | .HasColumnType("bigint"); 35 | 36 | b.Property("TopicId") 37 | .HasColumnType("bigint"); 38 | 39 | b.Property("Version") 40 | .IsConcurrencyToken() 41 | .HasColumnType("integer"); 42 | 43 | b.HasKey("Id"); 44 | 45 | b.HasIndex("TopicId"); 46 | 47 | b.ToTable("Replies"); 48 | }); 49 | 50 | modelBuilder.Entity("Bot.Topic", b => 51 | { 52 | b.Property("Id") 53 | .ValueGeneratedOnAdd() 54 | .HasColumnType("bigint"); 55 | 56 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 57 | 58 | b.Property("IsOpen") 59 | .HasColumnType("boolean"); 60 | 61 | b.Property("UserId") 62 | .HasColumnType("bigint"); 63 | 64 | b.Property("Version") 65 | .IsConcurrencyToken() 66 | .HasColumnType("integer"); 67 | 68 | b.HasKey("Id"); 69 | 70 | b.ToTable("Topic"); 71 | }); 72 | 73 | modelBuilder.Entity("Bot.User", b => 74 | { 75 | b.Property("Id") 76 | .ValueGeneratedOnAdd() 77 | .HasColumnType("bigint"); 78 | 79 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 80 | 81 | b.Property("Banned") 82 | .HasColumnType("boolean"); 83 | 84 | b.Property("TopicId") 85 | .HasColumnType("bigint"); 86 | 87 | b.Property("Version") 88 | .IsConcurrencyToken() 89 | .HasColumnType("integer"); 90 | 91 | b.HasKey("Id"); 92 | 93 | b.HasIndex("TopicId") 94 | .IsUnique(); 95 | 96 | b.ToTable("Users"); 97 | }); 98 | 99 | modelBuilder.Entity("Bot.Reply", b => 100 | { 101 | b.HasOne("Bot.Topic", "Topic") 102 | .WithMany("Replies") 103 | .HasForeignKey("TopicId") 104 | .OnDelete(DeleteBehavior.Cascade) 105 | .IsRequired(); 106 | 107 | b.Navigation("Topic"); 108 | }); 109 | 110 | modelBuilder.Entity("Bot.User", b => 111 | { 112 | b.HasOne("Bot.Topic", "Topic") 113 | .WithOne("User") 114 | .HasForeignKey("Bot.User", "TopicId") 115 | .OnDelete(DeleteBehavior.Cascade) 116 | .IsRequired(); 117 | 118 | b.Navigation("Topic"); 119 | }); 120 | 121 | modelBuilder.Entity("Bot.Topic", b => 122 | { 123 | b.Navigation("Replies"); 124 | 125 | b.Navigation("User"); 126 | }); 127 | #pragma warning restore 612, 618 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Middlewares/MessageCopierMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Frozen; 2 | using RuItUnion.FeedbackBot.Data.Models; 3 | using Telegram.Bot.Exceptions; 4 | using Telegram.Bot.Types.Enums; 5 | 6 | namespace RuItUnion.FeedbackBot.Middlewares; 7 | 8 | public class MessageCopierMiddleware( 9 | IOptions options, 10 | ITelegramBotClient botClient, 11 | IFeedbackBotContext db, 12 | FeedbackMetricsService feedbackMetricsService, 13 | ILogger logger) : FrameMiddleware 14 | { 15 | private static readonly FrozenSet _highVoltageEmoji = 16 | new[] { new ReactionTypeEmoji { Emoji = @"⚡" } }.ToFrozenSet(); 17 | 18 | private readonly long _chatId = options.Value.FeedbackChatId; 19 | 20 | public override async Task InvokeAsync(Update update, FrameContext context, CancellationToken ct = new()) 21 | { 22 | if (string.IsNullOrEmpty(context.GetCommandName()) 23 | && update.Message?.MessageThreadId is not null 24 | && update.Message.Chat.Id == _chatId 25 | && update.Message.ReplyToMessage?.Type is < MessageType.ForumTopicCreated 26 | or > MessageType.GeneralForumTopicUnhidden 27 | && update.Message.ReplyToMessage.From?.Username == context.GetBotUsername()) 28 | { 29 | DbTopic? topic = await db.Topics.AsNoTracking() 30 | .FirstOrDefaultAsync(x => x.ThreadId == update.Message.MessageThreadId, ct).ConfigureAwait(false); 31 | if (topic?.ThreadId is null 32 | || update.Message.ReplyToMessage.ForwardOrigin?.Type 33 | is not MessageOriginType.HiddenUser 34 | and not MessageOriginType.User) 35 | { 36 | return; 37 | } 38 | 39 | if (update.Message.Entities?.Any(x => x.Type is MessageEntityType.Mention or MessageEntityType.TextMention) 40 | ?? false) 41 | { 42 | await botClient.SendMessage(_chatId, 43 | ResourceManager.GetString(nameof(MessageCopierMiddleware_CopyWithUserNotAllowed), 44 | context.GetCultureInfo())!, 45 | ParseMode.MarkdownV2, 46 | messageThreadId: update.Message.MessageThreadId, replyParameters: new() 47 | { 48 | ChatId = _chatId, 49 | MessageId = update.Message.MessageId, 50 | AllowSendingWithoutReply = true, 51 | }, cancellationToken: ct).ConfigureAwait(false); 52 | return; 53 | } 54 | 55 | MessageId result; 56 | try 57 | { 58 | result = await botClient.CopyMessage(topic.UserChatId, update.Message.Chat.Id, 59 | update.Message.MessageId, 60 | cancellationToken: ct).ConfigureAwait(false); 61 | } 62 | catch (ApiRequestException ex) 63 | when (ex.Message == @"Forbidden: bot was blocked by the user") 64 | 65 | { 66 | await botClient.SendMessage(_chatId, 67 | ResourceManager.GetString(nameof(MessageCopier_BotBanned), context.GetCultureInfo())!, 68 | messageThreadId: update.Message.MessageThreadId, replyParameters: new() 69 | { 70 | ChatId = _chatId, 71 | MessageId = update.Message.MessageId, 72 | AllowSendingWithoutReply = true, 73 | }, cancellationToken: ct).ConfigureAwait(false); 74 | logger.LogInformation(@"Bot has been banned in chat with id = {chatId}", update.Message.Chat.Id); 75 | return; 76 | } 77 | 78 | await db.Replies.AddAsync(new() 79 | { 80 | ChatThreadId = topic.ThreadId, 81 | ChatMessageId = update.Message.MessageId, 82 | UserMessageId = result.Id, 83 | }, ct).ConfigureAwait(false); 84 | await db.SaveChangesAsync(ct).ConfigureAwait(false); 85 | await botClient.SetMessageReaction(update.Message.Chat.Id, update.Message.MessageId, _highVoltageEmoji, 86 | cancellationToken: ct).ConfigureAwait(false); 87 | await OnSuccess(update.Message, topic).ConfigureAwait(false); 88 | } 89 | 90 | await Next(update, context, ct).ConfigureAwait(false); 91 | } 92 | 93 | protected virtual ValueTask OnSuccess(Message message, DbTopic topic) 94 | { 95 | logger.LogInformation(@"Copied message {messageId} from topic {topicId} to chat with id = {userId}", 96 | message.MessageId, 97 | message.MessageThreadId, 98 | topic.UserChatId); 99 | feedbackMetricsService.IncMessagesCopied(topic.ThreadId, topic.UserChatId); 100 | return ValueTask.CompletedTask; 101 | } 102 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/RuItUnion.FeedbackBot.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | Linux 8 | ..\docker-compose.dcproj 9 | false 10 | true 11 | True 12 | ru 13 | True 14 | snupkg 15 | GPL-3.0-only 16 | True 17 | https://github.com/ruitunion-org/feedback-bot 18 | https://github.com/ruitunion-org/feedback-bot 19 | 1.4.4 20 | RuItUnion 21 | telegram;bot 22 | RuItUnion.FeedbackBot 23 | README.md 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 51 | 52 | 53 | 54 | 55 | 56 | <_ReferenceCopyLocalPaths 57 | Include="@(ReferenceCopyLocalPaths->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->WithMetadataValue('PrivateAssets', 'All'))" /> 58 | 59 | 60 | 61 | 64 | 65 | 66 | 67 | 69 | 70 | 71 | 72 | 73 | 74 | All 75 | 76 | 77 | All 78 | 79 | 80 | All 81 | 82 | 83 | 84 | 85 | 86 | True 87 | True 88 | Resources.resx 89 | 90 | 91 | 92 | 93 | 94 | PublicResXFileCodeGenerator 95 | Resources.Designer.cs 96 | 97 | 98 | 99 | 100 | 101 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data.Old/Migrations/20240614122306_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Bot; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using RuItUnion.FeedbackBot.Data.Old; 9 | 10 | #nullable disable 11 | 12 | namespace Bot.Migrations 13 | { 14 | [DbContext(typeof(OldDatabaseContext))] 15 | [Migration("20240614122306_Initial")] 16 | partial class Initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.6") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("Bot.Reply", b => 29 | { 30 | b.Property("Id") 31 | .ValueGeneratedOnAdd() 32 | .HasColumnType("bigint"); 33 | 34 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 35 | 36 | b.Property("BotMessageId") 37 | .HasColumnType("bigint"); 38 | 39 | b.Property("TopicId") 40 | .HasColumnType("bigint"); 41 | 42 | b.Property("Version") 43 | .IsConcurrencyToken() 44 | .HasColumnType("integer"); 45 | 46 | b.HasKey("Id"); 47 | 48 | b.HasIndex("TopicId"); 49 | 50 | b.ToTable("Replies"); 51 | }); 52 | 53 | modelBuilder.Entity("Bot.Topic", b => 54 | { 55 | b.Property("Id") 56 | .ValueGeneratedOnAdd() 57 | .HasColumnType("bigint"); 58 | 59 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 60 | 61 | b.Property("IsOpen") 62 | .HasColumnType("boolean"); 63 | 64 | b.Property("UserId") 65 | .HasColumnType("bigint"); 66 | 67 | b.Property("Version") 68 | .IsConcurrencyToken() 69 | .HasColumnType("integer"); 70 | 71 | b.HasKey("Id"); 72 | 73 | b.ToTable("Topic"); 74 | }); 75 | 76 | modelBuilder.Entity("Bot.User", b => 77 | { 78 | b.Property("Id") 79 | .ValueGeneratedOnAdd() 80 | .HasColumnType("bigint"); 81 | 82 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 83 | 84 | b.Property("Banned") 85 | .HasColumnType("boolean"); 86 | 87 | b.Property("TopicId") 88 | .HasColumnType("bigint"); 89 | 90 | b.Property("Version") 91 | .IsConcurrencyToken() 92 | .HasColumnType("integer"); 93 | 94 | b.HasKey("Id"); 95 | 96 | b.HasIndex("TopicId") 97 | .IsUnique(); 98 | 99 | b.ToTable("Users"); 100 | }); 101 | 102 | modelBuilder.Entity("Bot.Reply", b => 103 | { 104 | b.HasOne("Bot.Topic", "Topic") 105 | .WithMany("Replies") 106 | .HasForeignKey("TopicId") 107 | .OnDelete(DeleteBehavior.Cascade) 108 | .IsRequired(); 109 | 110 | b.Navigation("Topic"); 111 | }); 112 | 113 | modelBuilder.Entity("Bot.User", b => 114 | { 115 | b.HasOne("Bot.Topic", "Topic") 116 | .WithOne("User") 117 | .HasForeignKey("Bot.User", "TopicId") 118 | .OnDelete(DeleteBehavior.Cascade) 119 | .IsRequired(); 120 | 121 | b.Navigation("Topic"); 122 | }); 123 | 124 | modelBuilder.Entity("Bot.Topic", b => 125 | { 126 | b.Navigation("Replies"); 127 | 128 | b.Navigation("User"); 129 | }); 130 | #pragma warning restore 612, 618 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Commands/ThreadController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Frozen; 2 | using RuItUnion.FeedbackBot.Data.Models; 3 | using Telegram.Bot.Exceptions; 4 | using TgBotFrame.Commands; 5 | using TgBotFrame.Commands.Attributes; 6 | using TgBotFrame.Commands.Authorization.Attributes; 7 | 8 | namespace RuItUnion.FeedbackBot.Commands; 9 | 10 | [CommandController("Thread")] 11 | public class ThreadController( 12 | IOptions options, 13 | ITelegramBotClient botClient, 14 | IFeedbackBotContext db, 15 | TopicTitleGenerator topicTitleGenerator, 16 | FeedbackMetricsService feedbackMetricsService) 17 | : CommandControllerBase 18 | { 19 | protected static readonly FrozenSet EyesEmoji = 20 | new[] { new ReactionTypeEmoji { Emoji = @"👀" } }.ToFrozenSet(); 21 | 22 | protected static readonly FrozenSet HighVoltageEmoji = 23 | new[] { new ReactionTypeEmoji { Emoji = @"⚡" } }.ToFrozenSet(); 24 | 25 | protected readonly long ChatId = options.Value.FeedbackChatId; 26 | 27 | [Command(nameof(Open))] 28 | [Restricted("admin")] 29 | public virtual async Task Open() 30 | { 31 | int? threadId = Context.GetThreadId(); 32 | if (threadId is not null) 33 | { 34 | await UpdateTopicStatus(threadId.Value, true).ConfigureAwait(false); 35 | } 36 | } 37 | 38 | [Command(nameof(Close))] 39 | [Restricted("admin")] 40 | public virtual async Task Close() 41 | { 42 | int? threadId = Context.GetThreadId(); 43 | if (threadId is not null) 44 | { 45 | await UpdateTopicStatus(threadId.Value, false).ConfigureAwait(false); 46 | } 47 | } 48 | 49 | [Command(nameof(Delete))] 50 | public virtual async Task Delete() 51 | { 52 | int? messageId = Update.Message?.ReplyToMessage?.MessageId; 53 | if (messageId is null) 54 | { 55 | await botClient.SendMessage(ChatId, 56 | ResourceManager.GetString(nameof(ThreadController_Delete_NotReply), Context.GetCultureInfo())!, 57 | messageThreadId: Update.Message?.MessageThreadId) 58 | .ConfigureAwait(false); 59 | return; 60 | } 61 | 62 | DbReply? reply = await db.Replies.AsTracking().Include(x => x.Topic) 63 | .FirstOrDefaultAsync(x => x.ChatMessageId == messageId, CancellationToken) 64 | .ConfigureAwait(false); 65 | if (reply is not null && reply.UserMessageId >= 0) 66 | { 67 | await botClient.DeleteMessage(reply.Topic.UserChatId, reply.UserMessageId, CancellationToken) 68 | .ConfigureAwait(false); 69 | await botClient.DeleteMessage(ChatId, messageId.Value, CancellationToken).ConfigureAwait(false); 70 | db.Replies.Remove(reply); 71 | await db.SaveChangesAsync(CancellationToken).ConfigureAwait(false); 72 | feedbackMetricsService.IncMessagesDeleted(reply.ChatThreadId, Context.GetUserId() ?? 0L); 73 | } 74 | else 75 | { 76 | await botClient.SendMessage(ChatId, 77 | ResourceManager.GetString(nameof(ThreadController_Delete_NotFound), Context.GetCultureInfo())!, 78 | messageThreadId: Update.Message?.MessageThreadId) 79 | .ConfigureAwait(false); 80 | } 81 | } 82 | 83 | [Command(nameof(Sync))] 84 | [Restricted("service_admin")] 85 | public virtual async Task Sync() 86 | { 87 | DbTopic[] topics = await db.Topics.Include(x => x.User).AsTracking().ToArrayAsync().ConfigureAwait(false); 88 | long? chatId = Context.GetChatId(); 89 | int? messageId = Context.GetMessageId(); 90 | if (chatId is not null && messageId is not null) 91 | { 92 | await botClient.SetMessageReaction(chatId, messageId.Value, EyesEmoji).ConfigureAwait(false); 93 | } 94 | 95 | await Task.WhenAll(topics.Select(x => UpdateTopicStatus(x.ThreadId, x.IsOpen, x))).ConfigureAwait(false); 96 | 97 | 98 | if (chatId is not null && messageId is not null) 99 | { 100 | await botClient.SetMessageReaction(chatId, messageId.Value, HighVoltageEmoji).ConfigureAwait(false); 101 | } 102 | } 103 | 104 | protected virtual async Task UpdateTopicStatus(int threadId, bool isOpen, DbTopic? topic = null) 105 | { 106 | topic ??= await db.Topics.AsTracking().Include(x => x.User) 107 | .FirstOrDefaultAsync(x => x.ThreadId == threadId).ConfigureAwait(false); 108 | 109 | if (topic is not null) 110 | { 111 | if (topic.IsOpen != isOpen) 112 | { 113 | topic.IsOpen = isOpen; 114 | await db.SaveChangesAsync().ConfigureAwait(false); 115 | } 116 | 117 | try 118 | { 119 | await botClient.EditForumTopic(ChatId, topic.ThreadId, topicTitleGenerator.GetTopicTitle(topic)) 120 | .ConfigureAwait(false); 121 | } 122 | catch (ApiRequestException e) when (e.Message == @"Bad Request: TOPIC_NOT_MODIFIED") 123 | { 124 | } 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Npgsql; 3 | using OpenTelemetry.Metrics; 4 | using RuItUnion.FeedbackBot.Data.Old; 5 | using RuItUnion.FeedbackBot.Middlewares; 6 | using RuItUnion.FeedbackBot.ServiceDefaults; 7 | using TgBotFrame.Commands.Authorization.Extensions; 8 | using TgBotFrame.Commands.Authorization.Interfaces; 9 | using TgBotFrame.Commands.Authorization.Services; 10 | using TgBotFrame.Commands.Help.Extensions; 11 | using TgBotFrame.Commands.Injection; 12 | using TgBotFrame.Commands.RateLimit.Middleware; 13 | using TgBotFrame.Commands.RateLimit.Options; 14 | using TgBotFrame.Commands.Start; 15 | using TgBotFrame.Injection; 16 | using TgBotFrame.Services; 17 | 18 | WebApplicationBuilder builder = WebApplication.CreateBuilder(args); 19 | 20 | builder.Services.Configure(builder.Configuration.GetSection(nameof(AppOptions))); 21 | builder.Services.AddSingleton(); 22 | 23 | builder.AddServiceDefaults(); 24 | 25 | builder.Services.AddOpenTelemetry().WithMetrics(providerBuilder => 26 | { 27 | string[] metrics = 28 | [ 29 | @"Npgsql", 30 | @"TgBotFrame", 31 | @"TgBotFrame.Commands", 32 | @"FeedbackBot", 33 | ]; 34 | 35 | providerBuilder.AddInstrumentation(); 36 | providerBuilder.AddInstrumentation(); 37 | providerBuilder.AddNpgsqlInstrumentation(); 38 | 39 | providerBuilder.AddMeter(metrics); 40 | }).WithTracing(providerBuilder => { providerBuilder.AddNpgsql(); }); 41 | 42 | string tgToken = builder.Configuration.GetConnectionString(@"Telegram") 43 | ?? builder.Configuration[@$"{nameof(AppOptions)}:{nameof(AppOptions.FeedbackBotToken)}"] 44 | ?? throw new KeyNotFoundException(); 45 | builder.Services.AddTelegramHttpClient(); 46 | builder.Services.AddSingleton(provider => 47 | { 48 | IHttpClientFactory factory = provider.GetRequiredService(); 49 | return new(tgToken, factory.CreateClient(nameof(ITelegramBotClient))); 50 | }); 51 | 52 | builder.AddNpgsqlDataSource(@"RuItUnion-FeedbackBot-Database", settings => 53 | { 54 | settings.DisableMetrics = true; 55 | settings.DisableTracing = true; 56 | settings.DisableHealthChecks = false; 57 | }); 58 | 59 | builder.Services.AddSingleton(); 60 | builder.Services.AddDbContext((provider, optionsBuilder) => 61 | optionsBuilder.UseNpgsql(provider.GetRequiredService())); 62 | builder.Services.AddScoped(); 63 | builder.Services.AddScoped(); 64 | builder.Services.AddScoped(); 65 | 66 | bool useMigrator = !string.Equals(builder.Configuration[@"Migrator:EnableMigratorFromV01"], @"false", 67 | StringComparison.OrdinalIgnoreCase); 68 | if (useMigrator) 69 | { 70 | builder.Services.AddDbContext((provider, optionsBuilder) => 71 | optionsBuilder.UseNpgsql(provider.GetRequiredService())); 72 | builder.Services.AddScoped(); 73 | } 74 | 75 | builder.Services.Configure(builder.Configuration.GetSection(@"RateLimit")); 76 | 77 | builder.Services.AddTgBotFrameCommands(commandsBuilder => 78 | { 79 | commandsBuilder.TryAddCommandMiddleware(); 80 | 81 | commandsBuilder.TryAddCommandMiddleware(); 82 | commandsBuilder.AddAuthorization(); 83 | 84 | commandsBuilder.TryAddCommandMiddleware(); 85 | commandsBuilder.TryAddCommandMiddleware(); 86 | commandsBuilder.TryAddCommandMiddleware(); 87 | commandsBuilder.TryAddCommandMiddleware(); 88 | 89 | commandsBuilder.AddStartCommand(builder.Configuration[@$"{nameof(AppOptions)}:{nameof(AppOptions.Start)}"] 90 | ?? throw new KeyNotFoundException()); 91 | commandsBuilder.AddHelpCommand(); 92 | commandsBuilder.TryAddControllers(Assembly.GetEntryAssembly()!); 93 | }); 94 | 95 | builder.Services.AddHealthChecks() 96 | .AddCheck(@"telegram"); 97 | 98 | WebApplication app = builder.Build(); 99 | 100 | app.MapDefaultEndpoints(); 101 | 102 | IOptions appOptions = app.Services.GetRequiredService>(); 103 | if (appOptions.Value.FeedbackChatId == 0L) 104 | { 105 | throw new InvalidOperationException(@"AppOptions:FeedbackChatId configuration value is 0"); 106 | } 107 | 108 | if (string.Equals(builder.Configuration[@"Migrator:UpdateDatabase"], @"true", 109 | StringComparison.OrdinalIgnoreCase)) 110 | { 111 | IServiceScopeFactory scopeFactory = app.Services.GetRequiredService(); 112 | await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); 113 | FeedbackBotContext db = scope.ServiceProvider.GetRequiredService(); 114 | await db.Database.MigrateAsync().ConfigureAwait(false); 115 | } 116 | 117 | if (useMigrator) 118 | { 119 | IServiceScopeFactory scopeFactory = app.Services.GetRequiredService(); 120 | await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); 121 | Migrator migrator = scope.ServiceProvider.GetRequiredService(); 122 | await migrator.Migrate(CancellationToken.None).ConfigureAwait(false); 123 | } 124 | 125 | await app.RunAsync().ConfigureAwait(false); -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.ServiceDefaults/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Routing; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Diagnostics.HealthChecks; 5 | using Microsoft.Extensions.Hosting; 6 | using Microsoft.Extensions.Logging; 7 | using OpenTelemetry; 8 | using OpenTelemetry.Metrics; 9 | using OpenTelemetry.Trace; 10 | 11 | namespace RuItUnion.FeedbackBot.ServiceDefaults; 12 | 13 | // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. 14 | // This project should be referenced by each service project in your solution. 15 | // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults 16 | public static class Extensions 17 | { 18 | public static WebApplication MapDefaultEndpoints(this WebApplication app) 19 | { 20 | // Adding health checks endpoints to applications in non-development environments has security implications. 21 | // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. 22 | 23 | RouteGroupBuilder healthChecks = app.MapGroup(""); 24 | 25 | healthChecks.CacheOutput("HealthChecks").WithRequestTimeout("HealthChecks"); 26 | 27 | // All health checks must pass for app to be considered ready to accept traffic after starting 28 | app.MapHealthChecks("/health"); 29 | 30 | // Only health checks tagged with the "live" tag must pass for app to be considered alive 31 | app.MapHealthChecks("/alive", new() 32 | { 33 | Predicate = r => r.Tags.Contains("live"), 34 | }); 35 | 36 | app.MapPrometheusScrapingEndpoint(); 37 | 38 | return app; 39 | } 40 | 41 | extension(TBuilder builder) where TBuilder : IHostApplicationBuilder 42 | { 43 | public TBuilder AddServiceDefaults() 44 | { 45 | builder.ConfigureOpenTelemetry(); 46 | 47 | builder.Services.AddRequestTimeouts(); 48 | builder.Services.AddOutputCache(); 49 | builder.AddDefaultHealthChecks(); 50 | 51 | builder.Services.AddServiceDiscovery(); 52 | 53 | builder.Services.ConfigureHttpClientDefaults(http => 54 | { 55 | // Turn on resilience by default 56 | //http.AddStandardResilienceHandler(); 57 | 58 | // Turn on service discovery by default 59 | http.AddServiceDiscovery(); 60 | }); 61 | 62 | // Uncomment the following to restrict the allowed schemes for service discovery. 63 | // builder.Services.Configure(options => 64 | // { 65 | // options.AllowedSchemes = ["https"]; 66 | // }); 67 | 68 | return builder; 69 | } 70 | 71 | private TBuilder ConfigureOpenTelemetry() 72 | { 73 | builder.Logging.AddOpenTelemetry(logging => 74 | { 75 | logging.IncludeFormattedMessage = true; 76 | logging.IncludeScopes = true; 77 | }); 78 | 79 | builder.Services.AddOpenTelemetry() 80 | .WithMetrics(metrics => 81 | { 82 | metrics.AddAspNetCoreInstrumentation() 83 | .AddHttpClientInstrumentation() 84 | .AddRuntimeInstrumentation(); 85 | 86 | metrics.AddPrometheusExporter(); 87 | 88 | string[] metricsNames = 89 | [ 90 | "System.Runtime", 91 | "System.Net.NameResolution", 92 | "System.Net.Http", 93 | "Microsoft.Extensions.Diagnostics.ResourceMonitoring", 94 | "Microsoft.Extensions.Diagnostics.HealthChecks", 95 | "Microsoft.AspNetCore.Hosting", 96 | "Microsoft.AspNetCore.Routing", 97 | "Microsoft.AspNetCore.Diagnostics", 98 | "Microsoft.AspNetCore.RateLimiting", 99 | "Microsoft.AspNetCore.HeaderParsing", 100 | "Microsoft.AspNetCore.Http.Connections", 101 | "Microsoft.AspNetCore.Server.Kestrel", 102 | "Microsoft.EntityFrameworkCore", 103 | ]; 104 | 105 | metrics.AddMeter(metricsNames); 106 | }) 107 | .WithTracing(tracing => 108 | { 109 | tracing.AddSource(builder.Environment.ApplicationName) 110 | .AddAspNetCoreInstrumentation() 111 | // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) 112 | //.AddGrpcClientInstrumentation() 113 | .AddHttpClientInstrumentation(); 114 | }); 115 | 116 | builder.AddOpenTelemetryExporters(); 117 | 118 | return builder; 119 | } 120 | 121 | private TBuilder AddOpenTelemetryExporters() 122 | { 123 | bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); 124 | 125 | if (useOtlpExporter) 126 | { 127 | builder.Services.AddOpenTelemetry().UseOtlpExporter(); 128 | } 129 | 130 | // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) 131 | //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) 132 | //{ 133 | // builder.Services.AddOpenTelemetry() 134 | // .UseAzureMonitor(); 135 | //} 136 | 137 | return builder; 138 | } 139 | 140 | private TBuilder AddDefaultHealthChecks() 141 | { 142 | builder.Services.AddRequestTimeouts(static timeouts => 143 | timeouts.AddPolicy("HealthChecks", TimeSpan.FromSeconds(5))); 144 | 145 | builder.Services.AddOutputCache(static caching => 146 | caching.AddPolicy("HealthChecks", 147 | static policy => policy.Expire(TimeSpan.FromSeconds(10)))); 148 | 149 | builder.Services.AddHealthChecks() 150 | // Add a default liveness check to ensure app is responsive 151 | .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); 152 | 153 | return builder; 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Properties/Resources.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | text/microsoft-resx 90 | 91 | 92 | 1.3 93 | 94 | 95 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 96 | 97 | 98 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 99 | 100 | 101 | Привет! Этот бот перенаправит все ваши сообщения в чат операторам, а их ответы - перенаправит вам. 102 | 103 | 104 | Изменение сообщений не передается, в случае, если вам необходимо изменить текст сообщения — отправьте новое сообщение 105 | 106 | 107 | Данную команду необходимо использовать, отвечая ей на сообщение 108 | 109 | 110 | Данное сообщение не было отправлено пользователю, его достаточно удалить стандартными средствами Telegram 111 | 112 | 113 | Команды данной категории позволяют управлять темами и сообщениями в ней 114 | 115 | 116 | Тема 117 | 118 | 119 | Открывает тему 120 | 121 | 122 | Закрывает тему 123 | 124 | 125 | Удаляет сообщение в чате с пользователем. Команду необходимо использовать, отвечая ей на сообщение пользователя. 126 | 127 | 128 | Пользователь заблокировал бота, пересылка сообщения невозможна 129 | 130 | 131 | Обновляет названия всех тем в чате 132 | 133 | 134 | Информация о пользователе: 135 | 136 | Имя: {0}; 137 | Фамилия: {1}; 138 | Имя пользователя: {2}; 139 | ИД: <a href="tg://openmessage?user_id={3:D}">{3:D}</a>; 140 | Язык: {4}. 141 | 142 | 143 | 144 | 145 | 146 | Произошла ошибка при пересылке, ваше сообщение не было доставлено 147 | 148 | 149 | Создана новая тема <a href="https://t.me/c/{0:D}/{1:D}">{2}</a> для пользователя <a href="tg://openmessage?user_id={3:D}">{4}</a> 150 | 151 | 152 | > *Пересылка сообщения с упоминанием других пользователей недоступна* 153 | 154 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Middlewares/MessageForwarderMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using RuItUnion.FeedbackBot.Data.Models; 3 | using Telegram.Bot.Exceptions; 4 | using Telegram.Bot.Types.Enums; 5 | using TgBotFrame.Commands.Authorization.Models; 6 | 7 | namespace RuItUnion.FeedbackBot.Middlewares; 8 | 9 | public class MessageForwarderMiddleware( 10 | IOptions options, 11 | ITelegramBotClient botClient, 12 | IFeedbackBotContext db, 13 | FeedbackMetricsService feedbackMetricsService, 14 | TopicTitleGenerator topicTitleGenerator, 15 | ILogger logger) : FrameMiddleware 16 | { 17 | private readonly long _chatId = options.Value.FeedbackChatId; 18 | 19 | public override async Task InvokeAsync(Update update, FrameContext context, CancellationToken ct = default) 20 | { 21 | if (update.Message is not null && update.Message?.Chat.Id != _chatId 22 | && string.IsNullOrEmpty(context.GetCommandName())) 23 | { 24 | try 25 | { 26 | await ProcessMessage(update.Message!, context, ct).ConfigureAwait(false); 27 | } 28 | catch (Exception e) 29 | { 30 | await botClient.SendMessage(update.Message!.Chat.Id, 31 | ResourceManager.GetString(nameof(MessageForwarderMiddleware_Exception), context.GetCultureInfo())!, 32 | replyParameters: new() 33 | { 34 | AllowSendingWithoutReply = true, 35 | ChatId = update.Message!.Chat.Id, 36 | MessageId = update.Message!.Id, 37 | }, 38 | cancellationToken: ct).ConfigureAwait(false); 39 | logger.LogError(e, @"Exception during message forwarding"); 40 | } 41 | } 42 | 43 | await Next(update, context, ct).ConfigureAwait(false); 44 | } 45 | 46 | private async Task ProcessMessage(Message message, FrameContext context, CancellationToken ct = default) 47 | { 48 | DbTopic? dbTopic = await db.Topics.AsNoTracking().FirstOrDefaultAsync(x => x.UserChatId == message.Chat.Id, ct) 49 | .ConfigureAwait(false); 50 | if (dbTopic is null) 51 | { 52 | dbTopic = await CreateTopic(message, context, ct).ConfigureAwait(false); 53 | } 54 | else if (!dbTopic.IsOpen) 55 | { 56 | await OpenTopic(message, context, dbTopic, ct).ConfigureAwait(false); 57 | } 58 | 59 | try 60 | { 61 | await botClient.ForwardMessage(_chatId, message.Chat.Id, message.MessageId, 62 | dbTopic.ThreadId, false, false, null, null, null, ct).ConfigureAwait(false); 63 | logger.LogInformation(@"Forwarded message {messageId} from chat {chatId} to topic {topicId}", message.Id, 64 | message.Chat.Id, dbTopic.ThreadId); 65 | } 66 | catch (ApiRequestException e) when (string.Equals(e.Message, @"Bad Request: message thread not found", 67 | StringComparison.OrdinalIgnoreCase)) 68 | { 69 | logger.LogWarning(@"Topic {topicId} not found in chat, creating new topic...", dbTopic.ThreadId); 70 | await db.Topics.Where(x => x.Id == dbTopic.Id).Take(1).ExecuteDeleteAsync(ct).ConfigureAwait(false); 71 | await ProcessMessage(message, context, ct).ConfigureAwait(false); 72 | } 73 | 74 | feedbackMetricsService.IncMessagesForwarded(dbTopic.ThreadId, message.From?.Id ?? 0L); 75 | } 76 | 77 | protected virtual async Task OpenTopic(Message message, FrameContext context, DbTopic dbTopic, 78 | CancellationToken ct = default) 79 | { 80 | db.Topics.Update(dbTopic).State = EntityState.Unchanged; 81 | dbTopic.IsOpen = true; 82 | int? threadId = dbTopic.ThreadId; 83 | if (threadId is null or < 0) 84 | { 85 | db.Topics.Remove(dbTopic); 86 | await db.SaveChangesAsync(ct).ConfigureAwait(false); 87 | await ProcessMessage(message, context, ct).ConfigureAwait(false); 88 | } 89 | 90 | await db.SaveChangesAsync(ct).ConfigureAwait(false); 91 | 92 | try 93 | { 94 | await botClient.ReopenForumTopic(_chatId, threadId!.Value, ct).ConfigureAwait(false); 95 | } 96 | catch (ApiRequestException e) when (e.Message == @"Bad Request: TOPIC_NOT_MODIFIED") 97 | { 98 | } 99 | 100 | try 101 | { 102 | await botClient.EditForumTopic(_chatId, threadId!.Value, topicTitleGenerator.GetTopicTitle(dbTopic), 103 | cancellationToken: ct).ConfigureAwait(false); 104 | } 105 | catch (ApiRequestException e) when (e.Message == @"Bad Request: TOPIC_NOT_MODIFIED") 106 | { 107 | } 108 | 109 | logger.LogInformation(@"Reopened topic {topicId}", threadId!.Value); 110 | } 111 | 112 | protected virtual async Task CreateTopic(Message message, FrameContext context, 113 | CancellationToken ct = default) 114 | { 115 | DbUser user = await db.Users.AsTracking().FirstAsync(x => x.Id == message.From!.Id, ct) 116 | .ConfigureAwait(false); 117 | 118 | DbTopic dbTopic = new() 119 | { 120 | ThreadId = 0, 121 | IsOpen = true, 122 | UserChatId = message.Chat.Id, 123 | User = user, 124 | }; 125 | string topicName = topicTitleGenerator.GetTopicTitle(dbTopic); 126 | ForumTopic topic = await botClient 127 | .CreateForumTopic(_chatId, topicName, cancellationToken: ct) 128 | .ConfigureAwait(false); 129 | dbTopic.ThreadId = topic.MessageThreadId; 130 | await db.Topics.AddAsync(dbTopic, ct).ConfigureAwait(false); 131 | await db.SaveChangesAsync(ct).ConfigureAwait(false); 132 | logger.LogInformation(@"Created topic {topicId} for user {username} with id = {userId}", topic.MessageThreadId, 133 | user.UserName, user.Id); 134 | await CreateInfoMessage(context, dbTopic, user, topicName, ct).ConfigureAwait(false); 135 | return dbTopic; 136 | } 137 | 138 | protected virtual async Task CreateInfoMessage(FrameContext context, DbTopic topic, DbUser user, string topicTitle, 139 | CancellationToken ct = default) 140 | { 141 | CultureInfo culture = context.GetCultureInfo(); 142 | string noData = ResourceManager.GetString(nameof(UserInfoMessage_NoData), culture)!; 143 | string message = ResourceManager.GetString(nameof(UserInfoMessage), culture)!; 144 | string username = user.UserName is not null ? @"@" + user.UserName : noData; 145 | message = string.Format(message, user.FirstName, user.LastName ?? noData, username, user.Id, 146 | culture.NativeName); 147 | 148 | Message result = await botClient 149 | .SendMessage(_chatId, message, messageThreadId: topic.ThreadId, cancellationToken: ct, 150 | parseMode: ParseMode.Html) 151 | .ConfigureAwait(false); 152 | logger.LogInformation(@"Sent head message for topic {topicId}", result.MessageThreadId); 153 | await db.Replies.AddAsync(new() 154 | { 155 | ChatMessageId = result.Id, 156 | ChatThreadId = topic.ThreadId, 157 | UserMessageId = -1, 158 | }, ct).ConfigureAwait(false); 159 | await Task.WhenAll(db.SaveChangesAsync(ct), 160 | botClient.PinChatMessage(result.Chat.Id, result.MessageId, cancellationToken: ct)).ConfigureAwait(false); 161 | 162 | string generalText = string.Format( 163 | ResourceManager.GetString(nameof(UserInfoGeneralMessage), culture)!, 164 | -(_chatId + 1000000000000), result.Id, topicTitle, topic.User.Id, username); 165 | await botClient.SendMessage(_chatId, generalText, ParseMode.Html, cancellationToken: ct).ConfigureAwait(false); 166 | } 167 | } -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Properties/Resources.en.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | Hello! This bot will forward all your messages to the chat operators, and their responses will be forwarded to you. 122 | 123 | 124 | Message changes are not transmitted, if you need to edit the text of the message - send a new message 125 | 126 | 127 | This command must be used in response to a user message 128 | 129 | 130 | This message was not sent to the user, it is enough to delete it using standard Telegram tools 131 | 132 | 133 | The commands in this category allow you to manage topics and messages in it 134 | 135 | 136 | Topic 137 | 138 | 139 | Opens a topic 140 | 141 | 142 | Closes a topic 143 | 144 | 145 | Deletes a message in a user chat. The command must be used in response to a user's message. 146 | 147 | 148 | The user has blocked the bot, forwarding the message is not possible 149 | 150 | 151 | Updates the titles of all topics in the chat 152 | 153 | 154 | There was an error sending your message, it was not delivered 155 | 156 | 157 | 158 | 159 | 160 | User information: 161 | 162 | First name: {0}; 163 | Last name: {1}; 164 | Username: {2}; 165 | ID: <a href="tg://openmessage?user_id={3:D}">{3:D}</a>; 166 | Language: {4}. 167 | 168 | 169 | New topic <a href="https://t.me/c/{0:D}/{1:D}">{2}</a> created for user <a href="tg://openmessage?user_id={3:D}">{4}</a> 170 | 171 | 172 | > *Forwarding messages mentioning other users is not available* 173 | 174 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | # but not Directory.Build.rsp, as it configures directory-level build defaults 86 | !Directory.Build.rsp 87 | *.sbr 88 | *.tlb 89 | *.tli 90 | *.tlh 91 | *.tmp 92 | *.tmp_proj 93 | *_wpftmp.csproj 94 | *.log 95 | *.tlog 96 | *.vspscc 97 | *.vssscc 98 | .builds 99 | *.pidb 100 | *.svclog 101 | *.scc 102 | 103 | # Chutzpah Test files 104 | _Chutzpah* 105 | 106 | # Visual C++ cache files 107 | ipch/ 108 | *.aps 109 | *.ncb 110 | *.opendb 111 | *.opensdf 112 | *.sdf 113 | *.cachefile 114 | *.VC.db 115 | *.VC.VC.opendb 116 | 117 | # Visual Studio profiler 118 | *.psess 119 | *.vsp 120 | *.vspx 121 | *.sap 122 | 123 | # Visual Studio Trace Files 124 | *.e2e 125 | 126 | # TFS 2012 Local Workspace 127 | $tf/ 128 | 129 | # Guidance Automation Toolkit 130 | *.gpState 131 | 132 | # ReSharper is a .NET coding add-in 133 | _ReSharper*/ 134 | *.[Rr]e[Ss]harper 135 | *.DotSettings.user 136 | 137 | # TeamCity is a build add-in 138 | _TeamCity* 139 | 140 | # DotCover is a Code Coverage Tool 141 | *.dotCover 142 | 143 | # AxoCover is a Code Coverage Tool 144 | .axoCover/* 145 | !.axoCover/settings.json 146 | 147 | # Coverlet is a free, cross platform Code Coverage Tool 148 | coverage*.json 149 | coverage*.xml 150 | coverage*.info 151 | 152 | # Visual Studio code coverage results 153 | *.coverage 154 | *.coveragexml 155 | 156 | # NCrunch 157 | _NCrunch_* 158 | .*crunch*.local.xml 159 | nCrunchTemp_* 160 | 161 | # MightyMoose 162 | *.mm.* 163 | AutoTest.Net/ 164 | 165 | # Web workbench (sass) 166 | .sass-cache/ 167 | 168 | # Installshield output folder 169 | [Ee]xpress/ 170 | 171 | # DocProject is a documentation generator add-in 172 | DocProject/buildhelp/ 173 | DocProject/Help/*.HxT 174 | DocProject/Help/*.HxC 175 | DocProject/Help/*.hhc 176 | DocProject/Help/*.hhk 177 | DocProject/Help/*.hhp 178 | DocProject/Help/Html2 179 | DocProject/Help/html 180 | 181 | # Click-Once directory 182 | publish/ 183 | 184 | # Publish Web Output 185 | *.[Pp]ublish.xml 186 | *.azurePubxml 187 | # Note: Comment the next line if you want to checkin your web deploy settings, 188 | # but database connection strings (with potential passwords) will be unencrypted 189 | *.pubxml 190 | *.publishproj 191 | 192 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 193 | # checkin your Azure Web App publish settings, but sensitive information contained 194 | # in these scripts will be unencrypted 195 | PublishScripts/ 196 | 197 | # NuGet Packages 198 | *.nupkg 199 | # NuGet Symbol Packages 200 | *.snupkg 201 | # The packages folder can be ignored because of Package Restore 202 | **/[Pp]ackages/* 203 | # except build/, which is used as an MSBuild target. 204 | !**/[Pp]ackages/build/ 205 | # Uncomment if necessary however generally it will be regenerated when needed 206 | #!**/[Pp]ackages/repositories.config 207 | # NuGet v3's project.json files produces more ignorable files 208 | *.nuget.props 209 | *.nuget.targets 210 | 211 | # Microsoft Azure Build Output 212 | csx/ 213 | *.build.csdef 214 | 215 | # Microsoft Azure Emulator 216 | ecf/ 217 | rcf/ 218 | 219 | # Windows Store app package directories and files 220 | AppPackages/ 221 | BundleArtifacts/ 222 | Package.StoreAssociation.xml 223 | _pkginfo.txt 224 | *.appx 225 | *.appxbundle 226 | *.appxupload 227 | 228 | # Visual Studio cache files 229 | # files ending in .cache can be ignored 230 | *.[Cc]ache 231 | # but keep track of directories ending in .cache 232 | !?*.[Cc]ache/ 233 | 234 | # Others 235 | ClientBin/ 236 | ~$* 237 | *~ 238 | *.dbmdl 239 | *.dbproj.schemaview 240 | *.jfm 241 | *.pfx 242 | *.publishsettings 243 | orleans.codegen.cs 244 | 245 | # Including strong name files can present a security risk 246 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 247 | #*.snk 248 | 249 | # Since there are multiple workflows, uncomment next line to ignore bower_components 250 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 251 | #bower_components/ 252 | 253 | # RIA/Silverlight projects 254 | Generated_Code/ 255 | 256 | # Backup & report files from converting an old project file 257 | # to a newer Visual Studio version. Backup files are not needed, 258 | # because we have git ;-) 259 | _UpgradeReport_Files/ 260 | Backup*/ 261 | UpgradeLog*.XML 262 | UpgradeLog*.htm 263 | ServiceFabricBackup/ 264 | *.rptproj.bak 265 | 266 | # SQL Server files 267 | *.mdf 268 | *.ldf 269 | *.ndf 270 | 271 | # Business Intelligence projects 272 | *.rdl.data 273 | *.bim.layout 274 | *.bim_*.settings 275 | *.rptproj.rsuser 276 | *- [Bb]ackup.rdl 277 | *- [Bb]ackup ([0-9]).rdl 278 | *- [Bb]ackup ([0-9][0-9]).rdl 279 | 280 | # Microsoft Fakes 281 | FakesAssemblies/ 282 | 283 | # GhostDoc plugin setting file 284 | *.GhostDoc.xml 285 | 286 | # Node.js Tools for Visual Studio 287 | .ntvs_analysis.dat 288 | node_modules/ 289 | 290 | # Visual Studio 6 build log 291 | *.plg 292 | 293 | # Visual Studio 6 workspace options file 294 | *.opt 295 | 296 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 297 | *.vbw 298 | 299 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 300 | *.vbp 301 | 302 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 303 | *.dsw 304 | *.dsp 305 | 306 | # Visual Studio 6 technical files 307 | *.ncb 308 | *.aps 309 | 310 | # Visual Studio LightSwitch build output 311 | **/*.HTMLClient/GeneratedArtifacts 312 | **/*.DesktopClient/GeneratedArtifacts 313 | **/*.DesktopClient/ModelManifest.xml 314 | **/*.Server/GeneratedArtifacts 315 | **/*.Server/ModelManifest.xml 316 | _Pvt_Extensions 317 | 318 | # Paket dependency manager 319 | .paket/paket.exe 320 | paket-files/ 321 | 322 | # FAKE - F# Make 323 | .fake/ 324 | 325 | # CodeRush personal settings 326 | .cr/personal 327 | 328 | # Python Tools for Visual Studio (PTVS) 329 | __pycache__/ 330 | *.pyc 331 | 332 | # Cake - Uncomment if you are using it 333 | # tools/** 334 | # !tools/packages.config 335 | 336 | # Tabs Studio 337 | *.tss 338 | 339 | # Telerik's JustMock configuration file 340 | *.jmconfig 341 | 342 | # BizTalk build output 343 | *.btp.cs 344 | *.btm.cs 345 | *.odx.cs 346 | *.xsd.cs 347 | 348 | # OpenCover UI analysis results 349 | OpenCover/ 350 | 351 | # Azure Stream Analytics local run output 352 | ASALocalRun/ 353 | 354 | # MSBuild Binary and Structured Log 355 | *.binlog 356 | 357 | # NVidia Nsight GPU debugger configuration file 358 | *.nvuser 359 | 360 | # MFractors (Xamarin productivity tool) working folder 361 | .mfractor/ 362 | 363 | # Local History for Visual Studio 364 | .localhistory/ 365 | 366 | # Visual Studio History (VSHistory) files 367 | .vshistory/ 368 | 369 | # BeatPulse healthcheck temp database 370 | healthchecksdb 371 | 372 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 373 | MigrationBackup/ 374 | 375 | # Ionide (cross platform F# VS Code tools) working folder 376 | .ionide/ 377 | 378 | # Fody - auto-generated XML schema 379 | FodyWeavers.xsd 380 | 381 | # VS Code files for those working on multiple tools 382 | .vscode/* 383 | !.vscode/settings.json 384 | !.vscode/tasks.json 385 | !.vscode/launch.json 386 | !.vscode/extensions.json 387 | *.code-workspace 388 | 389 | # Local History for Visual Studio Code 390 | .history/ 391 | 392 | # Windows Installer files from build outputs 393 | *.cab 394 | *.msi 395 | *.msix 396 | *.msm 397 | *.msp 398 | 399 | # JetBrains Rider 400 | *.sln.iml 401 | 402 | # data folder 403 | [Dd]ata/ 404 | 405 | # Aspir8 files 406 | aspirate-output 407 | aspirate-state.json 408 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot/Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // Этот код создан программой. 4 | // Исполняемая версия:4.0.30319.42000 5 | // 6 | // Изменения в этом файле могут привести к неправильной работе и будут потеряны в случае 7 | // повторной генерации кода. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace RuItUnion.FeedbackBot.Properties { 12 | using System; 13 | 14 | 15 | /// 16 | /// Класс ресурса со строгой типизацией для поиска локализованных строк и т.д. 17 | /// 18 | // Этот класс создан автоматически классом StronglyTypedResourceBuilder 19 | // с помощью такого средства, как ResGen или Visual Studio. 20 | // Чтобы добавить или удалить член, измените файл .ResX и снова запустите ResGen 21 | // с параметром /str или перестройте свой проект VS. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | public class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Возвращает кэшированный экземпляр ResourceManager, использованный этим классом. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | public static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("RuItUnion.FeedbackBot.Properties.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Перезаписывает свойство CurrentUICulture текущего потока для всех 51 | /// обращений к ресурсу с помощью этого класса ресурса со строгой типизацией. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | public static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Ищет локализованную строку, похожую на Команды данной категории позволяют управлять темами и сообщениями в ней. 65 | /// 66 | public static string Category_Description_Thread { 67 | get { 68 | return ResourceManager.GetString("Category_Description_Thread", resourceCulture); 69 | } 70 | } 71 | 72 | /// 73 | /// Ищет локализованную строку, похожую на Тема. 74 | /// 75 | public static string Category_Name_Thread { 76 | get { 77 | return ResourceManager.GetString("Category_Name_Thread", resourceCulture); 78 | } 79 | } 80 | 81 | /// 82 | /// Ищет локализованную строку, похожую на Закрывает тему. 83 | /// 84 | public static string Command_Description_Close { 85 | get { 86 | return ResourceManager.GetString("Command_Description_Close", resourceCulture); 87 | } 88 | } 89 | 90 | /// 91 | /// Ищет локализованную строку, похожую на Удаляет сообщение в чате с пользователем. Команду необходимо использовать, отвечая ей на сообщение пользователя.. 92 | /// 93 | public static string Command_Description_Delete { 94 | get { 95 | return ResourceManager.GetString("Command_Description_Delete", resourceCulture); 96 | } 97 | } 98 | 99 | /// 100 | /// Ищет локализованную строку, похожую на Открывает тему. 101 | /// 102 | public static string Command_Description_Open { 103 | get { 104 | return ResourceManager.GetString("Command_Description_Open", resourceCulture); 105 | } 106 | } 107 | 108 | /// 109 | /// Ищет локализованную строку, похожую на Обновляет названия всех тем в чате. 110 | /// 111 | public static string Command_Description_Sync { 112 | get { 113 | return ResourceManager.GetString("Command_Description_Sync", resourceCulture); 114 | } 115 | } 116 | 117 | /// 118 | /// Ищет локализованную строку, похожую на Пользователь заблокировал бота, пересылка сообщения невозможна. 119 | /// 120 | public static string MessageCopier_BotBanned { 121 | get { 122 | return ResourceManager.GetString("MessageCopier_BotBanned", resourceCulture); 123 | } 124 | } 125 | 126 | /// 127 | /// Ищет локализованную строку, похожую на > *Пересылка сообщения с упоминанием других пользователей недоступна*. 128 | /// 129 | public static string MessageCopierMiddleware_CopyWithUserNotAllowed { 130 | get { 131 | return ResourceManager.GetString("MessageCopierMiddleware_CopyWithUserNotAllowed", resourceCulture); 132 | } 133 | } 134 | 135 | /// 136 | /// Ищет локализованную строку, похожую на Изменение сообщений не передается, в случае, если вам необходимо изменить текст сообщения — отправьте новое сообщение. 137 | /// 138 | public static string MessageEditorMiddleware_NotSupported { 139 | get { 140 | return ResourceManager.GetString("MessageEditorMiddleware_NotSupported", resourceCulture); 141 | } 142 | } 143 | 144 | /// 145 | /// Ищет локализованную строку, похожую на Произошла ошибка при пересылке, ваше сообщение не было доставлено. 146 | /// 147 | public static string MessageForwarderMiddleware_Exception { 148 | get { 149 | return ResourceManager.GetString("MessageForwarderMiddleware_Exception", resourceCulture); 150 | } 151 | } 152 | 153 | /// 154 | /// Ищет локализованную строку, похожую на Привет! Этот бот перенаправит все ваши сообщения в чат операторам, а их ответы - перенаправит вам.. 155 | /// 156 | public static string StartMessage { 157 | get { 158 | return ResourceManager.GetString("StartMessage", resourceCulture); 159 | } 160 | } 161 | 162 | /// 163 | /// Ищет локализованную строку, похожую на Данное сообщение не было отправлено пользователю, его достаточно удалить стандартными средствами Telegram. 164 | /// 165 | public static string ThreadController_Delete_NotFound { 166 | get { 167 | return ResourceManager.GetString("ThreadController_Delete_NotFound", resourceCulture); 168 | } 169 | } 170 | 171 | /// 172 | /// Ищет локализованную строку, похожую на Данную команду необходимо использовать, отвечая ей на сообщение. 173 | /// 174 | public static string ThreadController_Delete_NotReply { 175 | get { 176 | return ResourceManager.GetString("ThreadController_Delete_NotReply", resourceCulture); 177 | } 178 | } 179 | 180 | /// 181 | /// Ищет локализованную строку, похожую на Создана новая тема <a href="https://t.me/c/{0:D}/{1:D}">{2}</a> для пользователя <a href="tg://openmessage?user_id={3:D}">{4}</a>. 182 | /// 183 | public static string UserInfoGeneralMessage { 184 | get { 185 | return ResourceManager.GetString("UserInfoGeneralMessage", resourceCulture); 186 | } 187 | } 188 | 189 | /// 190 | /// Ищет локализованную строку, похожую на Информация о пользователе: 191 | /// 192 | ///Имя: {0}; 193 | ///Фамилия: {1}; 194 | ///Имя пользователя: {2}; 195 | ///ИД: <a href="tg://openmessage?user_id={3:D}">{3:D}</a>; 196 | ///Язык: {4}.. 197 | /// 198 | public static string UserInfoMessage { 199 | get { 200 | return ResourceManager.GetString("UserInfoMessage", resourceCulture); 201 | } 202 | } 203 | 204 | /// 205 | /// Ищет локализованную строку, похожую на —. 206 | /// 207 | public static string UserInfoMessage_NoData { 208 | get { 209 | return ResourceManager.GetString("UserInfoMessage_NoData", resourceCulture); 210 | } 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Migrations/20241117151116_Init.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 4 | 5 | #nullable disable 6 | 7 | #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional 8 | 9 | namespace RuItUnion.FeedbackBot.Data.Migrations 10 | { 11 | /// 12 | public partial class Init : Migration 13 | { 14 | /// 15 | protected override void Up(MigrationBuilder migrationBuilder) 16 | { 17 | migrationBuilder.EnsureSchema( 18 | name: "FeedbackBot"); 19 | 20 | migrationBuilder.CreateTable( 21 | name: "Roles", 22 | schema: "FeedbackBot", 23 | columns: table => new 24 | { 25 | Id = table.Column(type: "integer", nullable: false) 26 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 27 | Name = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), 28 | MentionEnabled = table.Column(type: "boolean", nullable: false), 29 | xmin = table.Column(type: "xid", rowVersion: true, nullable: false) 30 | }, 31 | constraints: table => 32 | { 33 | table.PrimaryKey("PK_Roles", x => x.Id); 34 | table.UniqueConstraint("AK_Roles_Name", x => x.Name); 35 | }); 36 | 37 | migrationBuilder.CreateTable( 38 | name: "Users", 39 | schema: "FeedbackBot", 40 | columns: table => new 41 | { 42 | Id = table.Column(type: "bigint", nullable: false), 43 | UserName = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), 44 | FirstName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), 45 | LastName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), 46 | xmin = table.Column(type: "xid", rowVersion: true, nullable: false) 47 | }, 48 | constraints: table => 49 | { 50 | table.PrimaryKey("PK_Users", x => x.Id); 51 | }); 52 | 53 | migrationBuilder.CreateTable( 54 | name: "Bans", 55 | schema: "FeedbackBot", 56 | columns: table => new 57 | { 58 | Id = table.Column(type: "uuid", nullable: false), 59 | UserId = table.Column(type: "bigint", nullable: false), 60 | Until = table.Column(type: "timestamp with time zone", nullable: false), 61 | Description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), 62 | xmin = table.Column(type: "xid", rowVersion: true, nullable: false) 63 | }, 64 | constraints: table => 65 | { 66 | table.PrimaryKey("PK_Bans", x => x.Id); 67 | table.ForeignKey( 68 | name: "FK_Bans_Users_UserId", 69 | column: x => x.UserId, 70 | principalSchema: "FeedbackBot", 71 | principalTable: "Users", 72 | principalColumn: "Id", 73 | onDelete: ReferentialAction.Cascade); 74 | }); 75 | 76 | migrationBuilder.CreateTable( 77 | name: "RoleMembers", 78 | schema: "FeedbackBot", 79 | columns: table => new 80 | { 81 | RoleId = table.Column(type: "integer", nullable: false), 82 | UserId = table.Column(type: "bigint", nullable: false), 83 | xmin = table.Column(type: "xid", rowVersion: true, nullable: false) 84 | }, 85 | constraints: table => 86 | { 87 | table.PrimaryKey("PK_RoleMembers", x => new { x.RoleId, x.UserId }); 88 | table.ForeignKey( 89 | name: "FK_RoleMembers_Roles_RoleId", 90 | column: x => x.RoleId, 91 | principalSchema: "FeedbackBot", 92 | principalTable: "Roles", 93 | principalColumn: "Id", 94 | onDelete: ReferentialAction.Cascade); 95 | table.ForeignKey( 96 | name: "FK_RoleMembers_Users_UserId", 97 | column: x => x.UserId, 98 | principalSchema: "FeedbackBot", 99 | principalTable: "Users", 100 | principalColumn: "Id", 101 | onDelete: ReferentialAction.Cascade); 102 | }); 103 | 104 | migrationBuilder.CreateTable( 105 | name: "Topics", 106 | schema: "FeedbackBot", 107 | columns: table => new 108 | { 109 | Id = table.Column(type: "integer", nullable: false) 110 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 111 | ThreadId = table.Column(type: "integer", nullable: false), 112 | UserChatId = table.Column(type: "bigint", nullable: false), 113 | IsOpen = table.Column(type: "boolean", nullable: false), 114 | xmin = table.Column(type: "xid", rowVersion: true, nullable: false) 115 | }, 116 | constraints: table => 117 | { 118 | table.PrimaryKey("PK_Topics", x => x.Id); 119 | table.UniqueConstraint("AK_Topics_ThreadId", x => x.ThreadId); 120 | table.ForeignKey( 121 | name: "FK_Topics_Users_UserChatId", 122 | column: x => x.UserChatId, 123 | principalSchema: "FeedbackBot", 124 | principalTable: "Users", 125 | principalColumn: "Id", 126 | onDelete: ReferentialAction.Cascade); 127 | }); 128 | 129 | migrationBuilder.CreateTable( 130 | name: "Replies", 131 | schema: "FeedbackBot", 132 | columns: table => new 133 | { 134 | ChatMessageId = table.Column(type: "integer", nullable: false), 135 | ChatThreadId = table.Column(type: "integer", nullable: false), 136 | UserMessageId = table.Column(type: "integer", nullable: false), 137 | xmin = table.Column(type: "xid", rowVersion: true, nullable: false) 138 | }, 139 | constraints: table => 140 | { 141 | table.PrimaryKey("PK_Replies", x => x.ChatMessageId); 142 | table.ForeignKey( 143 | name: "FK_Replies_Topics_ChatThreadId", 144 | column: x => x.ChatThreadId, 145 | principalSchema: "FeedbackBot", 146 | principalTable: "Topics", 147 | principalColumn: "ThreadId", 148 | onDelete: ReferentialAction.Cascade); 149 | }); 150 | 151 | migrationBuilder.InsertData( 152 | schema: "FeedbackBot", 153 | table: "Roles", 154 | columns: new[] { "Id", "MentionEnabled", "Name" }, 155 | values: new object[,] 156 | { 157 | { -2, false, "ban_list" }, 158 | { -1, false, "admin" } 159 | }); 160 | 161 | migrationBuilder.CreateIndex( 162 | name: "IX_Bans_UserId_Until", 163 | schema: "FeedbackBot", 164 | table: "Bans", 165 | columns: new[] { "UserId", "Until" }); 166 | 167 | migrationBuilder.CreateIndex( 168 | name: "IX_Replies_ChatThreadId", 169 | schema: "FeedbackBot", 170 | table: "Replies", 171 | column: "ChatThreadId"); 172 | 173 | migrationBuilder.CreateIndex( 174 | name: "IX_RoleMembers_RoleId", 175 | schema: "FeedbackBot", 176 | table: "RoleMembers", 177 | column: "RoleId"); 178 | 179 | migrationBuilder.CreateIndex( 180 | name: "IX_RoleMembers_UserId", 181 | schema: "FeedbackBot", 182 | table: "RoleMembers", 183 | column: "UserId"); 184 | 185 | migrationBuilder.CreateIndex( 186 | name: "IX_Topics_ThreadId", 187 | schema: "FeedbackBot", 188 | table: "Topics", 189 | column: "ThreadId", 190 | unique: true); 191 | 192 | migrationBuilder.CreateIndex( 193 | name: "IX_Topics_UserChatId", 194 | schema: "FeedbackBot", 195 | table: "Topics", 196 | column: "UserChatId", 197 | unique: true); 198 | 199 | migrationBuilder.CreateIndex( 200 | name: "IX_Users_UserName", 201 | schema: "FeedbackBot", 202 | table: "Users", 203 | column: "UserName", 204 | unique: true); 205 | } 206 | 207 | /// 208 | protected override void Down(MigrationBuilder migrationBuilder) 209 | { 210 | migrationBuilder.DropTable( 211 | name: "Bans", 212 | schema: "FeedbackBot"); 213 | 214 | migrationBuilder.DropTable( 215 | name: "Replies", 216 | schema: "FeedbackBot"); 217 | 218 | migrationBuilder.DropTable( 219 | name: "RoleMembers", 220 | schema: "FeedbackBot"); 221 | 222 | migrationBuilder.DropTable( 223 | name: "Topics", 224 | schema: "FeedbackBot"); 225 | 226 | migrationBuilder.DropTable( 227 | name: "Roles", 228 | schema: "FeedbackBot"); 229 | 230 | migrationBuilder.DropTable( 231 | name: "Users", 232 | schema: "FeedbackBot"); 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*.{appxmanifest,axml,build,config,csproj,dbml,discomap,dtd,jsproj,lsproj,njsproj,nuspec,proj,props,proto,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}] 3 | indent_style = tab 4 | indent_size = tab 5 | tab_width = 4 6 | 7 | [*.{asax,ascx,aspx,axaml,cs,cshtml,css,htm,html,js,jsx,master,paml,razor,skin,ts,tsx,vb,xaml,xamlx,xoml}] 8 | indent_style = space 9 | indent_size = 4 10 | tab_width = 4 11 | 12 | [*.{json,resjson}] 13 | indent_style = space 14 | indent_size = 2 15 | tab_width = 2 16 | 17 | [*] 18 | 19 | # Microsoft .NET properties 20 | csharp_new_line_before_members_in_object_initializers = false 21 | csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion 22 | csharp_style_prefer_utf8_string_literals = true:suggestion 23 | csharp_style_var_elsewhere = false:suggestion 24 | csharp_style_var_for_built_in_types = false:suggestion 25 | csharp_style_var_when_type_is_apparent = false:suggestion 26 | dotnet_naming_rule.constants_rule.import_to_resharper = True 27 | dotnet_naming_rule.constants_rule.resharper_description = Constant fields (not private) 28 | dotnet_naming_rule.constants_rule.resharper_guid = 669e5282-fb4b-4e90-91e7-07d269d04b60 29 | dotnet_naming_rule.constants_rule.severity = warning 30 | dotnet_naming_rule.constants_rule.style = all_upper_style 31 | dotnet_naming_rule.constants_rule.symbols = constants_symbols 32 | dotnet_naming_rule.private_constants_rule.import_to_resharper = True 33 | dotnet_naming_rule.private_constants_rule.resharper_description = Constant fields (private) 34 | dotnet_naming_rule.private_constants_rule.resharper_guid = 236f7aa5-7b06-43ca-bf2a-9b31bfcff09a 35 | dotnet_naming_rule.private_constants_rule.severity = warning 36 | dotnet_naming_rule.private_constants_rule.style = all_upper_style 37 | dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols 38 | dotnet_naming_rule.private_static_readonly_rule.import_to_resharper = True 39 | dotnet_naming_rule.private_static_readonly_rule.resharper_description = Static readonly fields (private) 40 | dotnet_naming_rule.private_static_readonly_rule.resharper_guid = 15b5b1f1-457c-4ca6-b278-5615aedc07d3 41 | dotnet_naming_rule.private_static_readonly_rule.severity = warning 42 | dotnet_naming_rule.private_static_readonly_rule.style = lower_camel_case_style 43 | dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols 44 | dotnet_naming_style.all_upper_style.capitalization = all_upper 45 | dotnet_naming_style.all_upper_style.word_separator = _ 46 | dotnet_naming_style.all_upper_style_1.capitalization = all_upper 47 | dotnet_naming_style.all_upper_style_1.required_prefix = _ 48 | dotnet_naming_style.all_upper_style_1.word_separator = _ 49 | dotnet_naming_style.lower_camel_case_style.capitalization = camel_case 50 | dotnet_naming_style.lower_camel_case_style.required_prefix = _ 51 | dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected 52 | dotnet_naming_symbols.constants_symbols.applicable_kinds = field 53 | dotnet_naming_symbols.constants_symbols.required_modifiers = const 54 | dotnet_naming_symbols.constants_symbols.resharper_applicable_kinds = constant_field 55 | dotnet_naming_symbols.constants_symbols.resharper_required_modifiers = any 56 | dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private 57 | dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field 58 | dotnet_naming_symbols.private_constants_symbols.required_modifiers = const 59 | dotnet_naming_symbols.private_constants_symbols.resharper_applicable_kinds = constant_field 60 | dotnet_naming_symbols.private_constants_symbols.resharper_required_modifiers = any 61 | dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private 62 | dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field 63 | dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = readonly,static 64 | dotnet_naming_symbols.private_static_readonly_symbols.resharper_applicable_kinds = readonly_field 65 | dotnet_naming_symbols.private_static_readonly_symbols.resharper_required_modifiers = static 66 | dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none 67 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none 68 | dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none 69 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 70 | dotnet_style_predefined_type_for_member_access = true:suggestion 71 | dotnet_style_qualification_for_event = false:suggestion 72 | dotnet_style_qualification_for_field = false:suggestion 73 | dotnet_style_qualification_for_method = false:suggestion 74 | dotnet_style_qualification_for_property = false:suggestion 75 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion 76 | dotnet_naming_rule.private_members_with_underscore.symbols = private_fields 77 | dotnet_naming_rule.private_members_with_underscore.style = lower_camel_case_style 78 | dotnet_naming_rule.private_members_with_underscore.severity = suggestion 79 | 80 | dotnet_naming_symbols.private_fields.applicable_kinds = field 81 | dotnet_naming_symbols.private_fields.applicable_accessibilities = private 82 | 83 | dotnet_naming_style.prefix_underscore.capitalization = camel_case 84 | dotnet_naming_style.prefix_underscore.required_prefix = _ 85 | 86 | # ReSharper properties 87 | resharper_csharp_wrap_before_binary_opsign = true 88 | resharper_instance_members_qualify_declared_in = base_class 89 | resharper_method_or_operator_body = expression_body 90 | resharper_object_creation_when_type_not_evident = target_typed 91 | resharper_place_accessorholder_attribute_on_same_line = false 92 | resharper_trailing_comma_in_multiline_lists = true 93 | 94 | # ReSharper inspection severities 95 | resharper_arrange_redundant_parentheses_highlighting = hint 96 | resharper_arrange_this_qualifier_highlighting = hint 97 | resharper_arrange_type_member_modifiers_highlighting = hint 98 | resharper_arrange_type_modifiers_highlighting = hint 99 | resharper_built_in_type_reference_style_for_member_access_highlighting = hint 100 | resharper_built_in_type_reference_style_highlighting = hint 101 | resharper_redundant_base_qualifier_highlighting = warning 102 | resharper_suggest_var_or_type_built_in_types_highlighting = hint 103 | resharper_suggest_var_or_type_elsewhere_highlighting = hint 104 | resharper_suggest_var_or_type_simple_types_highlighting = hint 105 | resharper_xaml_constructor_warning_highlighting = none 106 | csharp_style_expression_bodied_lambdas = true:silent 107 | csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent 108 | csharp_style_expression_bodied_local_functions = true:silent 109 | csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent 110 | csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent 111 | dotnet_style_allow_statement_immediately_after_block_experimental = true:silent 112 | dotnet_style_allow_multiple_blank_lines_experimental = true:silent 113 | csharp_using_directive_placement = outside_namespace:silent 114 | csharp_style_expression_bodied_constructors = true:silent 115 | csharp_style_prefer_primary_constructors = true:suggestion 116 | csharp_style_prefer_top_level_statements = true:silent 117 | csharp_style_prefer_method_group_conversion = true:silent 118 | csharp_style_namespace_declarations = file_scoped:silent 119 | csharp_prefer_braces = true:silent 120 | csharp_prefer_simple_using_statement = true:suggestion 121 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 122 | csharp_prefer_static_local_function = true:suggestion 123 | csharp_prefer_static_anonymous_function = true:suggestion 124 | csharp_style_prefer_readonly_struct = true:suggestion 125 | csharp_style_prefer_readonly_struct_member = true:suggestion 126 | csharp_style_expression_bodied_methods = true:silent 127 | csharp_style_expression_bodied_operators = true:silent 128 | csharp_style_expression_bodied_properties = true:silent 129 | csharp_style_expression_bodied_indexers = true:silent 130 | csharp_style_expression_bodied_accessors = true:silent 131 | dotnet_code_quality_unused_parameters = all:suggestion 132 | csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent 133 | csharp_style_prefer_extended_property_pattern = true:suggestion 134 | csharp_style_prefer_not_pattern = true:suggestion 135 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 136 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 137 | csharp_style_prefer_switch_expression = true:suggestion 138 | csharp_style_prefer_pattern_matching = true:silent 139 | csharp_style_conditional_delegate_call = true:suggestion 140 | dotnet_style_namespace_match_folder = true:suggestion 141 | dotnet_style_prefer_simplified_interpolation = true:suggestion 142 | dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion 143 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 144 | dotnet_style_prefer_compound_assignment = true:suggestion 145 | dotnet_style_explicit_tuple_names = true:suggestion 146 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 147 | dotnet_style_prefer_conditional_expression_over_return = true:silent 148 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 149 | csharp_style_unused_value_expression_statement_preference = discard_variable:silent 150 | csharp_style_deconstructed_variable_declaration = true:suggestion 151 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 152 | csharp_style_inlined_variable_declaration = true:suggestion 153 | csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion 154 | csharp_style_prefer_tuple_swap = true:suggestion 155 | csharp_style_prefer_index_operator = true:suggestion 156 | csharp_style_prefer_range_operator = true:suggestion 157 | csharp_prefer_simple_default_expression = true:suggestion 158 | csharp_style_prefer_local_over_anonymous_function = true:suggestion 159 | csharp_style_prefer_null_check_over_type_check = true:suggestion 160 | csharp_style_throw_expression = true:suggestion 161 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 162 | dotnet_style_collection_initializer = true:suggestion 163 | dotnet_style_object_initializer = true:suggestion 164 | dotnet_style_prefer_auto_properties = true:silent 165 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 166 | dotnet_style_coalesce_expression = true:suggestion 167 | dotnet_style_null_propagation = true:suggestion 168 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 169 | csharp_space_around_binary_operators = before_and_after 170 | dotnet_style_readonly_field = true:suggestion 171 | csharp_prefer_system_threading_lock = true:suggestion 172 | csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent 173 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Migrations/20241117151116_Init.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using RuItUnion.FeedbackBot.Data; 9 | 10 | #nullable disable 11 | 12 | namespace RuItUnion.FeedbackBot.Data.Migrations 13 | { 14 | [DbContext(typeof(FeedbackBotContext))] 15 | [Migration("20241117151116_Init")] 16 | partial class Init 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasDefaultSchema("FeedbackBot") 24 | .HasAnnotation("ProductVersion", "9.0.0") 25 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 26 | 27 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 28 | 29 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbReply", b => 30 | { 31 | b.Property("ChatMessageId") 32 | .HasColumnType("integer"); 33 | 34 | b.Property("ChatThreadId") 35 | .HasColumnType("integer"); 36 | 37 | b.Property("UserMessageId") 38 | .HasColumnType("integer"); 39 | 40 | b.Property("Version") 41 | .IsConcurrencyToken() 42 | .ValueGeneratedOnAddOrUpdate() 43 | .HasColumnType("xid") 44 | .HasColumnName("xmin"); 45 | 46 | b.HasKey("ChatMessageId"); 47 | 48 | b.HasIndex("ChatThreadId"); 49 | 50 | b.ToTable("Replies", "FeedbackBot"); 51 | }); 52 | 53 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 54 | { 55 | b.Property("Id") 56 | .ValueGeneratedOnAdd() 57 | .HasColumnType("integer"); 58 | 59 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 60 | 61 | b.Property("IsOpen") 62 | .HasColumnType("boolean"); 63 | 64 | b.Property("ThreadId") 65 | .HasColumnType("integer"); 66 | 67 | b.Property("UserChatId") 68 | .HasColumnType("bigint"); 69 | 70 | b.Property("Version") 71 | .IsConcurrencyToken() 72 | .ValueGeneratedOnAddOrUpdate() 73 | .HasColumnType("xid") 74 | .HasColumnName("xmin"); 75 | 76 | b.HasKey("Id"); 77 | 78 | b.HasIndex("ThreadId") 79 | .IsUnique(); 80 | 81 | b.HasIndex("UserChatId") 82 | .IsUnique(); 83 | 84 | b.ToTable("Topics", "FeedbackBot"); 85 | }); 86 | 87 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbBan", b => 88 | { 89 | b.Property("Id") 90 | .ValueGeneratedOnAdd() 91 | .HasColumnType("uuid"); 92 | 93 | b.Property("Description") 94 | .IsRequired() 95 | .HasMaxLength(2048) 96 | .IsUnicode(true) 97 | .HasColumnType("character varying(2048)"); 98 | 99 | b.Property("Until") 100 | .HasColumnType("timestamp with time zone"); 101 | 102 | b.Property("UserId") 103 | .HasColumnType("bigint"); 104 | 105 | b.Property("Version") 106 | .IsConcurrencyToken() 107 | .ValueGeneratedOnAddOrUpdate() 108 | .HasColumnType("xid") 109 | .HasColumnName("xmin"); 110 | 111 | b.HasKey("Id"); 112 | 113 | b.HasIndex("UserId", "Until"); 114 | 115 | b.ToTable("Bans", "FeedbackBot"); 116 | }); 117 | 118 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRole", b => 119 | { 120 | b.Property("Id") 121 | .ValueGeneratedOnAdd() 122 | .HasColumnType("integer"); 123 | 124 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 125 | 126 | b.Property("MentionEnabled") 127 | .HasColumnType("boolean"); 128 | 129 | b.Property("Name") 130 | .IsRequired() 131 | .HasMaxLength(32) 132 | .IsUnicode(false) 133 | .HasColumnType("character varying(32)"); 134 | 135 | b.Property("Version") 136 | .IsConcurrencyToken() 137 | .ValueGeneratedOnAddOrUpdate() 138 | .HasColumnType("xid") 139 | .HasColumnName("xmin"); 140 | 141 | b.HasKey("Id"); 142 | 143 | b.HasAlternateKey("Name"); 144 | 145 | b.ToTable("Roles", "FeedbackBot"); 146 | 147 | b.HasData( 148 | new 149 | { 150 | Id = -1, 151 | MentionEnabled = false, 152 | Name = "admin" 153 | }, 154 | new 155 | { 156 | Id = -2, 157 | MentionEnabled = false, 158 | Name = "ban_list" 159 | }); 160 | }); 161 | 162 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRoleMember", b => 163 | { 164 | b.Property("RoleId") 165 | .HasColumnType("integer"); 166 | 167 | b.Property("UserId") 168 | .HasColumnType("bigint"); 169 | 170 | b.Property("Version") 171 | .IsConcurrencyToken() 172 | .ValueGeneratedOnAddOrUpdate() 173 | .HasColumnType("xid") 174 | .HasColumnName("xmin"); 175 | 176 | b.HasKey("RoleId", "UserId"); 177 | 178 | b.HasIndex("RoleId"); 179 | 180 | b.HasIndex("UserId"); 181 | 182 | b.ToTable("RoleMembers", "FeedbackBot"); 183 | }); 184 | 185 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbUser", b => 186 | { 187 | b.Property("Id") 188 | .HasColumnType("bigint"); 189 | 190 | b.Property("FirstName") 191 | .IsRequired() 192 | .HasMaxLength(64) 193 | .IsUnicode(true) 194 | .HasColumnType("character varying(64)"); 195 | 196 | b.Property("LastName") 197 | .HasMaxLength(64) 198 | .IsUnicode(true) 199 | .HasColumnType("character varying(64)"); 200 | 201 | b.Property("UserName") 202 | .HasMaxLength(32) 203 | .IsUnicode(true) 204 | .HasColumnType("character varying(32)"); 205 | 206 | b.Property("Version") 207 | .IsConcurrencyToken() 208 | .ValueGeneratedOnAddOrUpdate() 209 | .HasColumnType("xid") 210 | .HasColumnName("xmin"); 211 | 212 | b.HasKey("Id"); 213 | 214 | b.HasIndex("UserName") 215 | .IsUnique(); 216 | 217 | b.ToTable("Users", "FeedbackBot"); 218 | }); 219 | 220 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbReply", b => 221 | { 222 | b.HasOne("RuItUnion.FeedbackBot.Data.Models.DbTopic", "Topic") 223 | .WithMany("Replies") 224 | .HasForeignKey("ChatThreadId") 225 | .HasPrincipalKey("ThreadId") 226 | .OnDelete(DeleteBehavior.Cascade) 227 | .IsRequired(); 228 | 229 | b.Navigation("Topic"); 230 | }); 231 | 232 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 233 | { 234 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 235 | .WithMany() 236 | .HasForeignKey("UserChatId") 237 | .OnDelete(DeleteBehavior.Cascade) 238 | .IsRequired(); 239 | 240 | b.Navigation("User"); 241 | }); 242 | 243 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbBan", b => 244 | { 245 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 246 | .WithMany("Bans") 247 | .HasForeignKey("UserId") 248 | .OnDelete(DeleteBehavior.Cascade) 249 | .IsRequired(); 250 | 251 | b.Navigation("User"); 252 | }); 253 | 254 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRoleMember", b => 255 | { 256 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbRole", "Role") 257 | .WithMany() 258 | .HasForeignKey("RoleId") 259 | .OnDelete(DeleteBehavior.Cascade) 260 | .IsRequired(); 261 | 262 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 263 | .WithMany() 264 | .HasForeignKey("UserId") 265 | .OnDelete(DeleteBehavior.Cascade) 266 | .IsRequired(); 267 | 268 | b.Navigation("Role"); 269 | 270 | b.Navigation("User"); 271 | }); 272 | 273 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 274 | { 275 | b.Navigation("Replies"); 276 | }); 277 | 278 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbUser", b => 279 | { 280 | b.Navigation("Bans"); 281 | }); 282 | #pragma warning restore 612, 618 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Migrations/FeedbackBotContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using RuItUnion.FeedbackBot.Data; 8 | 9 | #nullable disable 10 | 11 | namespace RuItUnion.FeedbackBot.Data.Migrations 12 | { 13 | [DbContext(typeof(FeedbackBotContext))] 14 | partial class FeedbackBotContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasDefaultSchema("FeedbackBot") 21 | .HasAnnotation("ProductVersion", "9.0.8") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 25 | 26 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbReply", b => 27 | { 28 | b.Property("ChatMessageId") 29 | .HasColumnType("integer"); 30 | 31 | b.Property("ChatThreadId") 32 | .HasColumnType("integer"); 33 | 34 | b.Property("UserMessageId") 35 | .HasColumnType("integer"); 36 | 37 | b.Property("Version") 38 | .IsConcurrencyToken() 39 | .ValueGeneratedOnAddOrUpdate() 40 | .HasColumnType("xid") 41 | .HasColumnName("xmin"); 42 | 43 | b.HasKey("ChatMessageId"); 44 | 45 | b.HasIndex("ChatThreadId"); 46 | 47 | b.ToTable("Replies", "FeedbackBot"); 48 | }); 49 | 50 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 51 | { 52 | b.Property("Id") 53 | .ValueGeneratedOnAdd() 54 | .HasColumnType("integer"); 55 | 56 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 57 | 58 | b.Property("IsOpen") 59 | .HasColumnType("boolean"); 60 | 61 | b.Property("ThreadId") 62 | .HasColumnType("integer"); 63 | 64 | b.Property("UserChatId") 65 | .HasColumnType("bigint"); 66 | 67 | b.Property("Version") 68 | .IsConcurrencyToken() 69 | .ValueGeneratedOnAddOrUpdate() 70 | .HasColumnType("xid") 71 | .HasColumnName("xmin"); 72 | 73 | b.HasKey("Id"); 74 | 75 | b.HasIndex("ThreadId") 76 | .IsUnique(); 77 | 78 | b.HasIndex("UserChatId") 79 | .IsUnique(); 80 | 81 | b.ToTable("Topics", "FeedbackBot"); 82 | }); 83 | 84 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbBan", b => 85 | { 86 | b.Property("Id") 87 | .ValueGeneratedOnAdd() 88 | .HasColumnType("uuid"); 89 | 90 | b.Property("Description") 91 | .IsRequired() 92 | .HasMaxLength(2048) 93 | .IsUnicode(true) 94 | .HasColumnType("character varying(2048)"); 95 | 96 | b.Property("Until") 97 | .HasColumnType("timestamp with time zone"); 98 | 99 | b.Property("UserId") 100 | .HasColumnType("bigint"); 101 | 102 | b.Property("Version") 103 | .IsConcurrencyToken() 104 | .ValueGeneratedOnAddOrUpdate() 105 | .HasColumnType("xid") 106 | .HasColumnName("xmin"); 107 | 108 | b.HasKey("Id"); 109 | 110 | b.HasIndex("UserId", "Until"); 111 | 112 | b.ToTable("Bans", "FeedbackBot"); 113 | }); 114 | 115 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRole", b => 116 | { 117 | b.Property("Id") 118 | .ValueGeneratedOnAdd() 119 | .HasColumnType("integer"); 120 | 121 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 122 | 123 | b.Property("MentionEnabled") 124 | .HasColumnType("boolean"); 125 | 126 | b.Property("Name") 127 | .IsRequired() 128 | .HasMaxLength(32) 129 | .IsUnicode(false) 130 | .HasColumnType("character varying(32)"); 131 | 132 | b.Property("Version") 133 | .IsConcurrencyToken() 134 | .ValueGeneratedOnAddOrUpdate() 135 | .HasColumnType("xid") 136 | .HasColumnName("xmin"); 137 | 138 | b.HasKey("Id"); 139 | 140 | b.HasAlternateKey("Name"); 141 | 142 | b.ToTable("Roles", "FeedbackBot"); 143 | 144 | b.HasData( 145 | new 146 | { 147 | Id = -50, 148 | MentionEnabled = false, 149 | Name = "service_admin" 150 | }, 151 | new 152 | { 153 | Id = -1, 154 | MentionEnabled = false, 155 | Name = "admin" 156 | }, 157 | new 158 | { 159 | Id = -2, 160 | MentionEnabled = false, 161 | Name = "ban_list" 162 | }); 163 | }); 164 | 165 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRoleMember", b => 166 | { 167 | b.Property("RoleId") 168 | .HasColumnType("integer"); 169 | 170 | b.Property("UserId") 171 | .HasColumnType("bigint"); 172 | 173 | b.Property("Version") 174 | .IsConcurrencyToken() 175 | .ValueGeneratedOnAddOrUpdate() 176 | .HasColumnType("xid") 177 | .HasColumnName("xmin"); 178 | 179 | b.HasKey("RoleId", "UserId"); 180 | 181 | b.HasIndex("RoleId"); 182 | 183 | b.HasIndex("UserId"); 184 | 185 | b.ToTable("RoleMembers", "FeedbackBot"); 186 | }); 187 | 188 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbUser", b => 189 | { 190 | b.Property("Id") 191 | .HasColumnType("bigint"); 192 | 193 | b.Property("FirstName") 194 | .IsRequired() 195 | .HasMaxLength(64) 196 | .IsUnicode(true) 197 | .HasColumnType("character varying(64)"); 198 | 199 | b.Property("LastName") 200 | .HasMaxLength(64) 201 | .IsUnicode(true) 202 | .HasColumnType("character varying(64)"); 203 | 204 | b.Property("UserName") 205 | .HasMaxLength(32) 206 | .IsUnicode(true) 207 | .HasColumnType("character varying(32)"); 208 | 209 | b.Property("Version") 210 | .IsConcurrencyToken() 211 | .ValueGeneratedOnAddOrUpdate() 212 | .HasColumnType("xid") 213 | .HasColumnName("xmin"); 214 | 215 | b.HasKey("Id"); 216 | 217 | b.HasIndex("UserName") 218 | .IsUnique(); 219 | 220 | b.ToTable("Users", "FeedbackBot"); 221 | }); 222 | 223 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbReply", b => 224 | { 225 | b.HasOne("RuItUnion.FeedbackBot.Data.Models.DbTopic", "Topic") 226 | .WithMany("Replies") 227 | .HasForeignKey("ChatThreadId") 228 | .HasPrincipalKey("ThreadId") 229 | .OnDelete(DeleteBehavior.Cascade) 230 | .IsRequired(); 231 | 232 | b.Navigation("Topic"); 233 | }); 234 | 235 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 236 | { 237 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 238 | .WithMany() 239 | .HasForeignKey("UserChatId") 240 | .OnDelete(DeleteBehavior.Cascade) 241 | .IsRequired(); 242 | 243 | b.Navigation("User"); 244 | }); 245 | 246 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbBan", b => 247 | { 248 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 249 | .WithMany("Bans") 250 | .HasForeignKey("UserId") 251 | .OnDelete(DeleteBehavior.Cascade) 252 | .IsRequired(); 253 | 254 | b.Navigation("User"); 255 | }); 256 | 257 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRoleMember", b => 258 | { 259 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbRole", "Role") 260 | .WithMany() 261 | .HasForeignKey("RoleId") 262 | .OnDelete(DeleteBehavior.Cascade) 263 | .IsRequired(); 264 | 265 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 266 | .WithMany() 267 | .HasForeignKey("UserId") 268 | .OnDelete(DeleteBehavior.Cascade) 269 | .IsRequired(); 270 | 271 | b.Navigation("Role"); 272 | 273 | b.Navigation("User"); 274 | }); 275 | 276 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 277 | { 278 | b.Navigation("Replies"); 279 | }); 280 | 281 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbUser", b => 282 | { 283 | b.Navigation("Bans"); 284 | }); 285 | #pragma warning restore 612, 618 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /RuItUnion.FeedbackBot.Data/Migrations/20250828231422_ServiceAdminRole.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using RuItUnion.FeedbackBot.Data; 9 | 10 | #nullable disable 11 | 12 | namespace RuItUnion.FeedbackBot.Data.Migrations 13 | { 14 | [DbContext(typeof(FeedbackBotContext))] 15 | [Migration("20250828231422_ServiceAdminRole")] 16 | partial class ServiceAdminRole 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasDefaultSchema("FeedbackBot") 24 | .HasAnnotation("ProductVersion", "9.0.8") 25 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 26 | 27 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 28 | 29 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbReply", b => 30 | { 31 | b.Property("ChatMessageId") 32 | .HasColumnType("integer"); 33 | 34 | b.Property("ChatThreadId") 35 | .HasColumnType("integer"); 36 | 37 | b.Property("UserMessageId") 38 | .HasColumnType("integer"); 39 | 40 | b.Property("Version") 41 | .IsConcurrencyToken() 42 | .ValueGeneratedOnAddOrUpdate() 43 | .HasColumnType("xid") 44 | .HasColumnName("xmin"); 45 | 46 | b.HasKey("ChatMessageId"); 47 | 48 | b.HasIndex("ChatThreadId"); 49 | 50 | b.ToTable("Replies", "FeedbackBot"); 51 | }); 52 | 53 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 54 | { 55 | b.Property("Id") 56 | .ValueGeneratedOnAdd() 57 | .HasColumnType("integer"); 58 | 59 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 60 | 61 | b.Property("IsOpen") 62 | .HasColumnType("boolean"); 63 | 64 | b.Property("ThreadId") 65 | .HasColumnType("integer"); 66 | 67 | b.Property("UserChatId") 68 | .HasColumnType("bigint"); 69 | 70 | b.Property("Version") 71 | .IsConcurrencyToken() 72 | .ValueGeneratedOnAddOrUpdate() 73 | .HasColumnType("xid") 74 | .HasColumnName("xmin"); 75 | 76 | b.HasKey("Id"); 77 | 78 | b.HasIndex("ThreadId") 79 | .IsUnique(); 80 | 81 | b.HasIndex("UserChatId") 82 | .IsUnique(); 83 | 84 | b.ToTable("Topics", "FeedbackBot"); 85 | }); 86 | 87 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbBan", b => 88 | { 89 | b.Property("Id") 90 | .ValueGeneratedOnAdd() 91 | .HasColumnType("uuid"); 92 | 93 | b.Property("Description") 94 | .IsRequired() 95 | .HasMaxLength(2048) 96 | .IsUnicode(true) 97 | .HasColumnType("character varying(2048)"); 98 | 99 | b.Property("Until") 100 | .HasColumnType("timestamp with time zone"); 101 | 102 | b.Property("UserId") 103 | .HasColumnType("bigint"); 104 | 105 | b.Property("Version") 106 | .IsConcurrencyToken() 107 | .ValueGeneratedOnAddOrUpdate() 108 | .HasColumnType("xid") 109 | .HasColumnName("xmin"); 110 | 111 | b.HasKey("Id"); 112 | 113 | b.HasIndex("UserId", "Until"); 114 | 115 | b.ToTable("Bans", "FeedbackBot"); 116 | }); 117 | 118 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRole", b => 119 | { 120 | b.Property("Id") 121 | .ValueGeneratedOnAdd() 122 | .HasColumnType("integer"); 123 | 124 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 125 | 126 | b.Property("MentionEnabled") 127 | .HasColumnType("boolean"); 128 | 129 | b.Property("Name") 130 | .IsRequired() 131 | .HasMaxLength(32) 132 | .IsUnicode(false) 133 | .HasColumnType("character varying(32)"); 134 | 135 | b.Property("Version") 136 | .IsConcurrencyToken() 137 | .ValueGeneratedOnAddOrUpdate() 138 | .HasColumnType("xid") 139 | .HasColumnName("xmin"); 140 | 141 | b.HasKey("Id"); 142 | 143 | b.HasAlternateKey("Name"); 144 | 145 | b.ToTable("Roles", "FeedbackBot"); 146 | 147 | b.HasData( 148 | new 149 | { 150 | Id = -50, 151 | MentionEnabled = false, 152 | Name = "service_admin" 153 | }, 154 | new 155 | { 156 | Id = -1, 157 | MentionEnabled = false, 158 | Name = "admin" 159 | }, 160 | new 161 | { 162 | Id = -2, 163 | MentionEnabled = false, 164 | Name = "ban_list" 165 | }); 166 | }); 167 | 168 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRoleMember", b => 169 | { 170 | b.Property("RoleId") 171 | .HasColumnType("integer"); 172 | 173 | b.Property("UserId") 174 | .HasColumnType("bigint"); 175 | 176 | b.Property("Version") 177 | .IsConcurrencyToken() 178 | .ValueGeneratedOnAddOrUpdate() 179 | .HasColumnType("xid") 180 | .HasColumnName("xmin"); 181 | 182 | b.HasKey("RoleId", "UserId"); 183 | 184 | b.HasIndex("RoleId"); 185 | 186 | b.HasIndex("UserId"); 187 | 188 | b.ToTable("RoleMembers", "FeedbackBot"); 189 | }); 190 | 191 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbUser", b => 192 | { 193 | b.Property("Id") 194 | .HasColumnType("bigint"); 195 | 196 | b.Property("FirstName") 197 | .IsRequired() 198 | .HasMaxLength(64) 199 | .IsUnicode(true) 200 | .HasColumnType("character varying(64)"); 201 | 202 | b.Property("LastName") 203 | .HasMaxLength(64) 204 | .IsUnicode(true) 205 | .HasColumnType("character varying(64)"); 206 | 207 | b.Property("UserName") 208 | .HasMaxLength(32) 209 | .IsUnicode(true) 210 | .HasColumnType("character varying(32)"); 211 | 212 | b.Property("Version") 213 | .IsConcurrencyToken() 214 | .ValueGeneratedOnAddOrUpdate() 215 | .HasColumnType("xid") 216 | .HasColumnName("xmin"); 217 | 218 | b.HasKey("Id"); 219 | 220 | b.HasIndex("UserName") 221 | .IsUnique(); 222 | 223 | b.ToTable("Users", "FeedbackBot"); 224 | }); 225 | 226 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbReply", b => 227 | { 228 | b.HasOne("RuItUnion.FeedbackBot.Data.Models.DbTopic", "Topic") 229 | .WithMany("Replies") 230 | .HasForeignKey("ChatThreadId") 231 | .HasPrincipalKey("ThreadId") 232 | .OnDelete(DeleteBehavior.Cascade) 233 | .IsRequired(); 234 | 235 | b.Navigation("Topic"); 236 | }); 237 | 238 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 239 | { 240 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 241 | .WithMany() 242 | .HasForeignKey("UserChatId") 243 | .OnDelete(DeleteBehavior.Cascade) 244 | .IsRequired(); 245 | 246 | b.Navigation("User"); 247 | }); 248 | 249 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbBan", b => 250 | { 251 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 252 | .WithMany("Bans") 253 | .HasForeignKey("UserId") 254 | .OnDelete(DeleteBehavior.Cascade) 255 | .IsRequired(); 256 | 257 | b.Navigation("User"); 258 | }); 259 | 260 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbRoleMember", b => 261 | { 262 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbRole", "Role") 263 | .WithMany() 264 | .HasForeignKey("RoleId") 265 | .OnDelete(DeleteBehavior.Cascade) 266 | .IsRequired(); 267 | 268 | b.HasOne("TgBotFrame.Commands.Authorization.Models.DbUser", "User") 269 | .WithMany() 270 | .HasForeignKey("UserId") 271 | .OnDelete(DeleteBehavior.Cascade) 272 | .IsRequired(); 273 | 274 | b.Navigation("Role"); 275 | 276 | b.Navigation("User"); 277 | }); 278 | 279 | modelBuilder.Entity("RuItUnion.FeedbackBot.Data.Models.DbTopic", b => 280 | { 281 | b.Navigation("Replies"); 282 | }); 283 | 284 | modelBuilder.Entity("TgBotFrame.Commands.Authorization.Models.DbUser", b => 285 | { 286 | b.Navigation("Bans"); 287 | }); 288 | #pragma warning restore 612, 618 289 | } 290 | } 291 | } 292 | --------------------------------------------------------------------------------