Compare commits

...
Sign in to create a new pull request.

1 commit
main ... chat

Author SHA1 Message Date
Pat Hartl
408c9cb09b Basic chat service and SignalR hub
Some checks failed
LANCommander / build (push) Has been cancelled
2024-02-08 00:47:03 -06:00
15 changed files with 2718 additions and 2 deletions

View file

@ -36,6 +36,8 @@ namespace LANCommander.Data
builder.ConfigureBaseRelationships<ServerConsole>(); builder.ConfigureBaseRelationships<ServerConsole>();
builder.ConfigureBaseRelationships<ServerHttpPath>(); builder.ConfigureBaseRelationships<ServerHttpPath>();
builder.ConfigureBaseRelationships<Tag>(); builder.ConfigureBaseRelationships<Tag>();
builder.ConfigureBaseRelationships<Message>();
builder.ConfigureBaseRelationships<Channel>();
builder.Entity<Genre>() builder.Entity<Genre>()
.HasMany(g => g.Games) .HasMany(g => g.Games)
@ -229,6 +231,23 @@ namespace LANCommander.Data
rc => rc.HasOne<Role>().WithMany().HasForeignKey("RoleId") rc => rc.HasOne<Role>().WithMany().HasForeignKey("RoleId")
); );
#endregion #endregion
#region Chat Relationships
builder.Entity<Channel>()
.HasMany(c => c.Messages)
.WithOne(m => m.Channel)
.IsRequired(true)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<Channel>()
.HasMany(c => c.Users)
.WithMany(u => u.Channels)
.UsingEntity<Dictionary<string, object>>(
"ChannelUsers",
cu => cu.HasOne<User>().WithMany().HasForeignKey("UserId"),
cu => cu.HasOne<Channel>().WithMany().HasForeignKey("ChannelId")
);
#endregion
} }
public DbSet<Game>? Games { get; set; } public DbSet<Game>? Games { get; set; }
@ -254,5 +273,8 @@ namespace LANCommander.Data
public DbSet<Redistributable>? Redistributables { get; set; } public DbSet<Redistributable>? Redistributables { get; set; }
public DbSet<Media>? Media { get; set; } public DbSet<Media>? Media { get; set; }
public DbSet<Message>? Messages { get; set; }
public DbSet<Channel>? Channels { get; set; }
} }
} }

View file

@ -0,0 +1,9 @@
namespace LANCommander.Data.Enums
{
public enum ChannelType
{
Standard,
DirectMessage,
Game,
}
}

View file

@ -0,0 +1,18 @@
using LANCommander.Data.Enums;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace LANCommander.Data.Models
{
[Table("Channels")]
public class Channel : BaseModel
{
[MaxLength(128)]
public string Name { get; set; }
public ChannelType Type { get; set; } = ChannelType.Standard;
public virtual ICollection<Message>? Messages { get; set; }
public virtual ICollection<User>? Users { get; set; }
}
}

View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace LANCommander.Data.Models
{
[Table("Messages")]
public class Message : BaseModel
{
[Key]
public Guid Id { get; set; }
[MaxLength(1024)]
public string Contents { get; set; }
public virtual User? Sender { get; set; }
public virtual Channel? Channel { get; set; }
}
}

View file

@ -51,6 +51,10 @@ namespace LANCommander.Data.Models
[JsonIgnore] [JsonIgnore]
public virtual ICollection<Media>? Media { get; set; } public virtual ICollection<Media>? Media { get; set; }
[JsonIgnore]
public virtual ICollection<Message>? Messages { get; set; }
[JsonIgnore]
public virtual ICollection<Channel>? Channels { get; set; }
[JsonIgnore] [JsonIgnore]
public bool Approved { get; set; } public bool Approved { get; set; }

View file

@ -0,0 +1,50 @@
using LANCommander.Data.Models;
using LANCommander.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.SignalR;
namespace LANCommander.Hubs
{
[Authorize]
public class ChatHub : Hub
{
public static ChatService ChatService;
public ChatHub(ChatService chatService)
{
ChatService = chatService;
}
public async Task Connect()
{
try
{
await ChatService.Connect(Context.ConnectionId, Context.User.Identity.Name);
}
catch (Exception ex)
{
}
}
public async Task SendMessage(Guid recipient, string message)
{
try
{
var result = await ChatService.SendMessageToUserAsync(ChatService.GetUser(Context.ConnectionId).Id, recipient, message);
await Clients.Users(recipient.ToString()).SendAsync("ReceiveMessage", result);
}
catch (Exception ex)
{
}
}
public async Task<IEnumerable<Message>> GetMessages(Guid channelId, int skip = 0, int take = 25)
{
return await ChatService.GetMessages(channelId, skip, take);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,157 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class AddChat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
CreatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedById = table.Column<Guid>(type: "TEXT", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedById = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
table.ForeignKey(
name: "FK_Channels_AspNetUsers_CreatedById",
column: x => x.CreatedById,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Channels_AspNetUsers_UpdatedById",
column: x => x.UpdatedById,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "ChannelUsers",
columns: table => new
{
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelUsers", x => new { x.ChannelId, x.UserId });
table.ForeignKey(
name: "FK_ChannelUsers_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChannelUsers_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Contents = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: false),
SenderId = table.Column<Guid>(type: "TEXT", nullable: true),
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedById = table.Column<Guid>(type: "TEXT", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedById = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
table.ForeignKey(
name: "FK_Messages_AspNetUsers_CreatedById",
column: x => x.CreatedById,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Messages_AspNetUsers_SenderId",
column: x => x.SenderId,
principalTable: "AspNetUsers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Messages_AspNetUsers_UpdatedById",
column: x => x.UpdatedById,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Messages_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Channels_CreatedById",
table: "Channels",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_Channels_UpdatedById",
table: "Channels",
column: "UpdatedById");
migrationBuilder.CreateIndex(
name: "IX_ChannelUsers_UserId",
table: "ChannelUsers",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Messages_ChannelId",
table: "Messages",
column: "ChannelId");
migrationBuilder.CreateIndex(
name: "IX_Messages_CreatedById",
table: "Messages",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_Messages_SenderId",
table: "Messages",
column: "SenderId");
migrationBuilder.CreateIndex(
name: "IX_Messages_UpdatedById",
table: "Messages",
column: "UpdatedById");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelUsers");
migrationBuilder.DropTable(
name: "Messages");
migrationBuilder.DropTable(
name: "Channels");
}
}
}

View file

@ -16,7 +16,7 @@ namespace LANCommander.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("ProductVersion", "8.0.1")
.HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true); .HasAnnotation("Proxies:LazyLoading", true);
@ -36,6 +36,21 @@ namespace LANCommander.Migrations
b.ToTable("CategoryGame"); b.ToTable("CategoryGame");
}); });
modelBuilder.Entity("ChannelUsers", b =>
{
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("ChannelId", "UserId");
b.HasIndex("UserId");
b.ToTable("ChannelUsers");
});
modelBuilder.Entity("CollectionGame", b => modelBuilder.Entity("CollectionGame", b =>
{ {
b.Property<Guid>("CollectionId") b.Property<Guid>("CollectionId")
@ -277,6 +292,41 @@ namespace LANCommander.Migrations
b.ToTable("Categories"); b.ToTable("Categories");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CreatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<Guid?>("UpdatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("UpdatedById");
b.ToTable("Channels");
});
modelBuilder.Entity("LANCommander.Data.Models.Collection", b => modelBuilder.Entity("LANCommander.Data.Models.Collection", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -583,6 +633,48 @@ namespace LANCommander.Migrations
b.ToTable("Media"); b.ToTable("Media");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Contents")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<Guid?>("CreatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<Guid?>("SenderId")
.HasColumnType("TEXT");
b.Property<Guid?>("UpdatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("CreatedById");
b.HasIndex("SenderId");
b.HasIndex("UpdatedById");
b.ToTable("Messages");
});
modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b => modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1270,6 +1362,21 @@ namespace LANCommander.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ChannelUsers", b =>
{
b.HasOne("LANCommander.Data.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Data.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CollectionGame", b => modelBuilder.Entity("CollectionGame", b =>
{ {
b.HasOne("LANCommander.Data.Models.Collection", null) b.HasOne("LANCommander.Data.Models.Collection", null)
@ -1451,6 +1558,23 @@ namespace LANCommander.Migrations
b.Navigation("UpdatedBy"); b.Navigation("UpdatedBy");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Channel", b =>
{
b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatedBy");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Data.Models.Collection", b => modelBuilder.Entity("LANCommander.Data.Models.Collection", b =>
{ {
b.HasOne("LANCommander.Data.Models.User", "CreatedBy") b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
@ -1620,6 +1744,37 @@ namespace LANCommander.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Message", b =>
{
b.HasOne("LANCommander.Data.Models.Channel", "Channel")
.WithMany("Messages")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Data.Models.User", "Sender")
.WithMany("Messages")
.HasForeignKey("SenderId");
b.HasOne("LANCommander.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Channel");
b.Navigation("CreatedBy");
b.Navigation("Sender");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b => modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b =>
{ {
b.HasOne("LANCommander.Data.Models.User", "CreatedBy") b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
@ -1926,6 +2081,11 @@ namespace LANCommander.Migrations
b.Navigation("Children"); b.Navigation("Children");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Channel", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("LANCommander.Data.Models.Game", b => modelBuilder.Entity("LANCommander.Data.Models.Game", b =>
{ {
b.Navigation("Actions"); b.Navigation("Actions");
@ -1975,6 +2135,8 @@ namespace LANCommander.Migrations
b.Navigation("Media"); b.Navigation("Media");
b.Navigation("Messages");
b.Navigation("PlaySessions"); b.Navigation("PlaySessions");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618

View file

@ -183,6 +183,10 @@ namespace LANCommander
builder.Services.AddScoped<IMediaGrabberService, SteamGridDBMediaGrabber>(); builder.Services.AddScoped<IMediaGrabberService, SteamGridDBMediaGrabber>();
builder.Services.AddScoped<WikiService>(); builder.Services.AddScoped<WikiService>();
builder.Services.AddScoped<UpdateService>(); builder.Services.AddScoped<UpdateService>();
builder.Services.AddScoped<MessageService>();
builder.Services.AddScoped<ChannelService>();
builder.Services.AddSingleton<ChatService>();
builder.Services.AddSingleton<ServerProcessService>(); builder.Services.AddSingleton<ServerProcessService>();
builder.Services.AddSingleton<IPXRelayService>(); builder.Services.AddSingleton<IPXRelayService>();
@ -241,6 +245,7 @@ namespace LANCommander
app.MapHub<LoggingHub>("/hubs/logging"); app.MapHub<LoggingHub>("/hubs/logging");
app.MapHub<GameServerHub>("/hubs/gameserver"); app.MapHub<GameServerHub>("/hubs/gameserver");
app.MapHub<ChatHub>("/hubs/chat");
Logger.Debug("Registering Endpoints"); Logger.Debug("Registering Endpoints");
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>

View file

@ -0,0 +1,12 @@
using LANCommander.Data;
using LANCommander.Data.Models;
namespace LANCommander.Services
{
public class ChannelService : BaseDatabaseService<Channel>
{
public ChannelService(DatabaseContext dbContext, IHttpContextAccessor httpContextAccessor) : base(dbContext, httpContextAccessor)
{
}
}
}

View file

@ -0,0 +1,91 @@
using LANCommander.Data;
using LANCommander.Data.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace LANCommander.Services
{
public class ChatService
{
private readonly IServiceScopeFactory ScopeFactory;
private Dictionary<string, User> Connections { get; set; }
public ChatService(IServiceScopeFactory scopeFactory)
{
ScopeFactory = scopeFactory;
Connections = new Dictionary<string, User>();
}
public async Task Connect(string connectionId, string username)
{
using (var scope = ScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var user = await context.Users.FirstOrDefaultAsync(u => u.UserName == username);
if (user == null)
throw new UnauthorizedAccessException();
Connections[connectionId] = user;
}
}
public User GetUser(string connectionId)
{
return Connections[connectionId];
}
public async Task<Message> SendMessageToUserAsync(Guid senderId, Guid recipientId, string contents)
{
using (var scope = ScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var sender = await context.Users.FirstOrDefaultAsync(u => u.Id == senderId);
var recipient = await context.Users.FirstOrDefaultAsync(u => u.Id == recipientId);
var channel = sender.Channels.FirstOrDefault(c => c.Users.Count() == 2 && c.Users.Any(u => u.Id == recipientId));
// Create DM channel if it doesn't already exist
if (channel == null)
{
channel = new Channel
{
Type = Data.Enums.ChannelType.DirectMessage,
CreatedBy = sender,
Users = new List<User> { sender, recipient },
Name = "Direct Message",
CreatedOn = DateTime.Now
};
await context.AddAsync(channel);
}
var message = new Message
{
Channel = channel,
Contents = contents,
CreatedBy = sender,
Sender = sender,
CreatedOn = DateTime.Now
};
await context.AddAsync(message);
await context.SaveChangesAsync();
return message;
}
}
public async Task<IEnumerable<Message>> GetMessages(Guid channelId, int skip = 0, int take = 25)
{
using (var scope = ScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
return await context.Messages.Where(m => m.Channel.Id == channelId).OrderByDescending(m => m.CreatedOn).Skip(skip).Take(take).ToListAsync();
}
}
}
}

View file

@ -0,0 +1,12 @@
using LANCommander.Data;
using LANCommander.Data.Models;
namespace LANCommander.Services
{
public class MessageService : BaseDatabaseService<Message>
{
public MessageService(DatabaseContext dbContext, IHttpContextAccessor httpContextAccessor) : base(dbContext, httpContextAccessor)
{
}
}
}

View file

@ -23,7 +23,6 @@
} }
<link href="_content/XtermBlazor/XtermBlazor.css" rel="stylesheet" /> <link href="_content/XtermBlazor/XtermBlazor.css" rel="stylesheet" />
<link href="~/css/site.css" rel="stylesheet" /> <link href="~/css/site.css" rel="stylesheet" />
</head> </head>
<body data-theme="@SettingService.GetSettings().Theme"> <body data-theme="@SettingService.GetSettings().Theme">
@ -37,6 +36,7 @@
<script src="~/_content/AntDesign/js/ant-design-blazor.js"></script> <script src="~/_content/AntDesign/js/ant-design-blazor.js"></script>
<script src="~/_content/AntDesign.Charts/ant-design-charts-blazor.js"></script> <script src="~/_content/AntDesign.Charts/ant-design-charts-blazor.js"></script>
<script src="~/_framework/blazor.server.js"></script> <script src="~/_framework/blazor.server.js"></script>
<script src="~/lib/signalr/dist/browser/signalr.min.js"></script>
<script src="~/_content/XtermBlazor/XtermBlazor.min.js"></script> <script src="~/_content/XtermBlazor/XtermBlazor.min.js"></script>
<script src="~/lib/xterm-addon-fit/lib/xterm-addon-fit.min.js"></script> <script src="~/lib/xterm-addon-fit/lib/xterm-addon-fit.min.js"></script>

View file

@ -39,6 +39,14 @@
"files": [ "files": [
"axios.min.js" "axios.min.js"
] ]
},
{
"provider": "unpkg",
"library": "@microsoft/signalr@latest",
"destination": "wwwroot/lib/signalr/",
"files": [
"dist/browser/signalr.min.js"
]
} }
] ]
} }