├── 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 | [](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 | 
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 | 
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