14 Commits

23 changed files with 1505 additions and 475 deletions

View File

@@ -80,7 +80,7 @@ public class AccountCurrentController(
[MaxLength(1024)] public string? TimeZone { get; set; } [MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; } [MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; } [MaxLength(4096)] public string? Bio { get; set; }
public UsernameColor? UsernameColor { get; set; } public Shared.Models.UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; } public Instant? Birthday { get; set; }
public List<ProfileLink>? Links { get; set; } public List<ProfileLink>? Links { get; set; }
@@ -933,4 +933,4 @@ public class AccountCurrentController(
.ToListAsync(); .ToListAsync();
return Ok(records); return Ok(records);
} }
} }

View File

@@ -160,6 +160,26 @@ public class AccountServiceGrpc(
return response; return response;
} }
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
{
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context) ServerCallContext context)
{ {

View File

@@ -197,7 +197,8 @@ public class SubscriptionGiftController(
if (currentUser.Profile.Level < MinimumAccountLevel) if (currentUser.Profile.Level < MinimumAccountLevel)
{ {
return StatusCode(403, "Account level must be at least 60 to purchase a gift."); if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
} }
Duration? giftDuration = null; Duration? giftDuration = null;

View File

@@ -250,6 +250,14 @@ public class SubscriptionService(
: null; : null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found."); if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync( return await payment.CreateOrderAsync(
null, null,
subscriptionInfo.Currency, subscriptionInfo.Currency,
@@ -684,6 +692,9 @@ public class SubscriptionService(
if (now > gift.ExpiresAt) if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired."); throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions // Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id) if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you."); throw new InvalidOperationException("This gift is not intended for you.");

View File

@@ -148,6 +148,32 @@ public class UsernameColor
public string? Value { get; set; } // e.g. "red" or "#ff6600" public string? Value { get; set; } // e.g. "red" or "#ff6600"
public string? Direction { get; set; } // e.g. "to right" public string? Direction { get; set; } // e.g. "to right"
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"] public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
public Proto.UsernameColor ToProtoValue()
{
var proto = new Proto.UsernameColor
{
Type = Type,
Value = Value,
Direction = Direction,
};
if (Colors is not null)
{
proto.Colors.AddRange(Colors);
}
return proto;
}
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
{
return new UsernameColor
{
Type = proto.Type,
Value = proto.Value,
Direction = proto.Direction,
Colors = proto.Colors?.ToList()
};
}
} }
public class SnAccountProfile : ModelBase, IIdentifiedResource public class SnAccountProfile : ModelBase, IIdentifiedResource
@@ -218,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(), Verification = Verification?.ToProtoValue(),
ActiveBadge = ActiveBadge?.ToProtoValue(), ActiveBadge = ActiveBadge?.ToProtoValue(),
UsernameColor = UsernameColor?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp() UpdatedAt = UpdatedAt.ToTimestamp()
}; };
@@ -247,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture), Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background), Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId), AccountId = Guid.Parse(proto.AccountId),
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
CreatedAt = proto.CreatedAt.ToInstant(), CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant() UpdatedAt = proto.UpdatedAt.ToInstant()
}; };

View File

@@ -123,7 +123,7 @@ public class SnPostCategorySubscription : ModelBase
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Guid? CategoryId { get; set; } public Guid? CategoryId { get; set; }
public SnPostCategory? Category { get; set; } public SnPostCategory? Category { get; set; }
public Guid? TagId { get; set; } public Guid? TagId { get; set; }
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
public Guid PostId { get; set; } public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!; [JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] public SnAccount? Account { get; set; }
} }
public class SnPostAward : ModelBase public class SnPostAward : ModelBase
@@ -176,7 +177,7 @@ public class SnPostAward : ModelBase
public decimal Amount { get; set; } public decimal Amount { get; set; }
public PostReactionAttitude Attitude { get; set; } public PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; } [MaxLength(4096)] public string? Message { get; set; }
public Guid PostId { get; set; } public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!; [JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }

View File

@@ -74,4 +74,4 @@ public class WebSocketPacket
ErrorMessage = packet.ErrorMessage ErrorMessage = packet.ErrorMessage
}; };
} }
} }

View File

@@ -14,232 +14,240 @@ import 'wallet.proto';
// Account represents a user account in the system // Account represents a user account in the system
message Account { message Account {
string id = 1; string id = 1;
string name = 2; string name = 2;
string nick = 3; string nick = 3;
string language = 4; string language = 4;
string region = 18; string region = 18;
google.protobuf.Timestamp activated_at = 5; google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6; bool is_superuser = 6;
AccountProfile profile = 7; AccountProfile profile = 7;
optional SubscriptionReferenceObject perk_subscription = 16; optional SubscriptionReferenceObject perk_subscription = 16;
repeated AccountContact contacts = 8; repeated AccountContact contacts = 8;
repeated AccountBadge badges = 9; repeated AccountBadge badges = 9;
repeated AccountAuthFactor auth_factors = 10; repeated AccountAuthFactor auth_factors = 10;
repeated AccountConnection connections = 11; repeated AccountConnection connections = 11;
repeated Relationship outgoing_relationships = 12; repeated Relationship outgoing_relationships = 12;
repeated Relationship incoming_relationships = 13; repeated Relationship incoming_relationships = 13;
google.protobuf.Timestamp created_at = 14; google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15; google.protobuf.Timestamp updated_at = 15;
google.protobuf.StringValue automated_id = 17; google.protobuf.StringValue automated_id = 17;
} }
// Enum for status attitude // Enum for status attitude
enum StatusAttitude { enum StatusAttitude {
STATUS_ATTITUDE_UNSPECIFIED = 0; STATUS_ATTITUDE_UNSPECIFIED = 0;
POSITIVE = 1; POSITIVE = 1;
NEGATIVE = 2; NEGATIVE = 2;
NEUTRAL = 3; NEUTRAL = 3;
} }
// AccountStatus represents the status of an account // AccountStatus represents the status of an account
message AccountStatus { message AccountStatus {
string id = 1; string id = 1;
StatusAttitude attitude = 2; StatusAttitude attitude = 2;
bool is_online = 3; bool is_online = 3;
bool is_customized = 4; bool is_customized = 4;
bool is_invisible = 5; bool is_invisible = 5;
bool is_not_disturb = 6; bool is_not_disturb = 6;
google.protobuf.StringValue label = 7; google.protobuf.StringValue label = 7;
google.protobuf.Timestamp cleared_at = 8; google.protobuf.Timestamp cleared_at = 8;
string account_id = 9; string account_id = 9;
bytes meta = 10; bytes meta = 10;
}
message UsernameColor {
string type = 1;
google.protobuf.StringValue value = 2;
google.protobuf.StringValue direction = 3;
repeated string colors = 4;
} }
// Profile contains detailed information about a user // Profile contains detailed information about a user
message AccountProfile { message AccountProfile {
string id = 1; string id = 1;
google.protobuf.StringValue first_name = 2; google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue middle_name = 3; google.protobuf.StringValue middle_name = 3;
google.protobuf.StringValue last_name = 4; google.protobuf.StringValue last_name = 4;
google.protobuf.StringValue bio = 5; google.protobuf.StringValue bio = 5;
google.protobuf.StringValue gender = 6; google.protobuf.StringValue gender = 6;
google.protobuf.StringValue pronouns = 7; google.protobuf.StringValue pronouns = 7;
google.protobuf.StringValue time_zone = 8; google.protobuf.StringValue time_zone = 8;
google.protobuf.StringValue location = 9; google.protobuf.StringValue location = 9;
google.protobuf.Timestamp birthday = 10; google.protobuf.Timestamp birthday = 10;
google.protobuf.Timestamp last_seen_at = 11; google.protobuf.Timestamp last_seen_at = 11;
VerificationMark verification = 12; VerificationMark verification = 12;
BadgeReferenceObject active_badge = 13; BadgeReferenceObject active_badge = 13;
int32 experience = 14; int32 experience = 14;
int32 level = 15; int32 level = 15;
double leveling_progress = 16; double leveling_progress = 16;
double social_credits = 17; double social_credits = 17;
int32 social_credits_level = 18; int32 social_credits_level = 18;
CloudFile picture = 19; CloudFile picture = 19;
CloudFile background = 20; CloudFile background = 20;
string account_id = 21; string account_id = 21;
google.protobuf.Timestamp created_at = 22; google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23; google.protobuf.Timestamp updated_at = 23;
optional UsernameColor username_color = 24;
} }
// AccountContact represents a contact method for an account // AccountContact represents a contact method for an account
message AccountContact { message AccountContact {
string id = 1; string id = 1;
AccountContactType type = 2; AccountContactType type = 2;
google.protobuf.Timestamp verified_at = 3; google.protobuf.Timestamp verified_at = 3;
bool is_primary = 4; bool is_primary = 4;
string content = 5; string content = 5;
string account_id = 6; string account_id = 6;
google.protobuf.Timestamp created_at = 7; google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8; google.protobuf.Timestamp updated_at = 8;
} }
// Enum for contact types // Enum for contact types
enum AccountContactType { enum AccountContactType {
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0; ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
EMAIL = 1; EMAIL = 1;
PHONE_NUMBER = 2; PHONE_NUMBER = 2;
ADDRESS = 3; ADDRESS = 3;
} }
// AccountAuthFactor represents an authentication factor for an account // AccountAuthFactor represents an authentication factor for an account
message AccountAuthFactor { message AccountAuthFactor {
string id = 1; string id = 1;
AccountAuthFactorType type = 2; AccountAuthFactorType type = 2;
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
int32 trustworthy = 5; int32 trustworthy = 5;
google.protobuf.Timestamp enabled_at = 6; google.protobuf.Timestamp enabled_at = 6;
google.protobuf.Timestamp expired_at = 7; google.protobuf.Timestamp expired_at = 7;
string account_id = 8; string account_id = 8;
map<string, google.protobuf.Value> created_response = 9; // For initial setup map<string, google.protobuf.Value> created_response = 9; // For initial setup
google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11; google.protobuf.Timestamp updated_at = 11;
} }
// Enum for authentication factor types // Enum for authentication factor types
enum AccountAuthFactorType { enum AccountAuthFactorType {
AUTH_FACTOR_TYPE_UNSPECIFIED = 0; AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
PASSWORD = 1; PASSWORD = 1;
EMAIL_CODE = 2; EMAIL_CODE = 2;
IN_APP_CODE = 3; IN_APP_CODE = 3;
TIMED_CODE = 4; TIMED_CODE = 4;
PIN_CODE = 5; PIN_CODE = 5;
} }
// AccountBadge represents a badge associated with an account // AccountBadge represents a badge associated with an account
message AccountBadge { message AccountBadge {
string id = 1; // Unique identifier for the badge string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to string account_id = 8; // ID of the account this badge belongs to
google.protobuf.Timestamp created_at = 9; google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10; google.protobuf.Timestamp updated_at = 10;
} }
// AccountConnection represents a third-party connection for an account // AccountConnection represents a third-party connection for an account
message AccountConnection { message AccountConnection {
string id = 1; string id = 1;
string provider = 2; string provider = 2;
string provided_identifier = 3; string provided_identifier = 3;
map<string, google.protobuf.Value> meta = 4; map<string, google.protobuf.Value> meta = 4;
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
google.protobuf.Timestamp last_used_at = 7; google.protobuf.Timestamp last_used_at = 7;
string account_id = 8; string account_id = 8;
google.protobuf.Timestamp created_at = 9; google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10; google.protobuf.Timestamp updated_at = 10;
} }
// VerificationMark represents verification status // VerificationMark represents verification status
message VerificationMark { message VerificationMark {
VerificationMarkType type = 1; VerificationMarkType type = 1;
string title = 2; string title = 2;
string description = 3; string description = 3;
string verified_by = 4; string verified_by = 4;
google.protobuf.Timestamp created_at = 5; google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6; google.protobuf.Timestamp updated_at = 6;
} }
enum VerificationMarkType { enum VerificationMarkType {
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0; VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
OFFICIAL = 1; OFFICIAL = 1;
INDIVIDUAL = 2; INDIVIDUAL = 2;
ORGANIZATION = 3; ORGANIZATION = 3;
GOVERNMENT = 4; GOVERNMENT = 4;
CREATOR = 5; CREATOR = 5;
DEVELOPER = 6; DEVELOPER = 6;
PARODY = 7; PARODY = 7;
} }
// BadgeReferenceObject represents a reference to a badge with minimal information // BadgeReferenceObject represents a reference to a badge with minimal information
message BadgeReferenceObject { message BadgeReferenceObject {
string id = 1; // Unique identifier for the badge string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to string account_id = 8; // ID of the account this badge belongs to
} }
// Relationship represents a connection between two accounts // Relationship represents a connection between two accounts
message Relationship { message Relationship {
string account_id = 1; string account_id = 1;
string related_id = 2; string related_id = 2;
optional Account account = 3; optional Account account = 3;
optional Account related = 4; optional Account related = 4;
int32 status = 5; int32 status = 5;
google.protobuf.Timestamp created_at = 6; google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7; google.protobuf.Timestamp updated_at = 7;
} }
// Leveling information // Leveling information
message LevelingInfo { message LevelingInfo {
int32 current_level = 1; int32 current_level = 1;
int32 current_experience = 2; int32 current_experience = 2;
int32 next_level_experience = 3; int32 next_level_experience = 3;
int32 previous_level_experience = 4; int32 previous_level_experience = 4;
double level_progress = 5; double level_progress = 5;
repeated int32 experience_per_level = 6; repeated int32 experience_per_level = 6;
} }
// ActionLog represents a record of an action taken by a user // ActionLog represents a record of an action taken by a user
message ActionLog { message ActionLog {
string id = 1; // Unique identifier for the log entry string id = 1; // Unique identifier for the log entry
string action = 2; // The action that was performed, e.g., "user.login" string action = 2; // The action that was performed, e.g., "user.login"
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
google.protobuf.StringValue user_agent = 4; // User agent of the client google.protobuf.StringValue user_agent = 4; // User agent of the client
google.protobuf.StringValue ip_address = 5; // IP address of the client google.protobuf.StringValue ip_address = 5; // IP address of the client
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
string account_id = 7; // The account that performed the action string account_id = 7; // The account that performed the action
google.protobuf.StringValue session_id = 8; // The session in which the action was performed google.protobuf.StringValue session_id = 8; // The session in which the action was performed
google.protobuf.Timestamp created_at = 9; // When the action log was created google.protobuf.Timestamp created_at = 9; // When the action log was created
} }
message GetAccountStatusBatchResponse { message GetAccountStatusBatchResponse {
repeated AccountStatus statuses = 1; repeated AccountStatus statuses = 1;
} }
// ==================================== // ====================================
@@ -248,45 +256,46 @@ message GetAccountStatusBatchResponse {
// AccountService provides CRUD operations for user accounts and related entities // AccountService provides CRUD operations for user accounts and related entities
service AccountService { service AccountService {
// Account Operations // Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {} rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {} rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {} rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {} rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {} rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {} rpc SearchAccount(SearchAccountRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {} rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {} rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
// Profile Operations // Profile Operations
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {} rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
// Contact Operations // Contact Operations
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {} rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
// Badge Operations // Badge Operations
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {} rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
// Authentication Factor Operations // Authentication Factor Operations
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {} rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
// Connection Operations // Connection Operations
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {} rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
// Relationship Operations // Relationship Operations
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {} rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {} rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {} rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {} rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {} rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
} }
// ActionLogService provides operations for action logs // ActionLogService provides operations for action logs
service ActionLogService { service ActionLogService {
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {} rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {} rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
} }
// ==================================== // ====================================
@@ -295,184 +304,188 @@ service ActionLogService {
// ActionLog Requests/Responses // ActionLog Requests/Responses
message CreateActionLogRequest { message CreateActionLogRequest {
string action = 1; string action = 1;
map<string, google.protobuf.Value> meta = 2; map<string, google.protobuf.Value> meta = 2;
google.protobuf.StringValue user_agent = 3; google.protobuf.StringValue user_agent = 3;
google.protobuf.StringValue ip_address = 4; google.protobuf.StringValue ip_address = 4;
google.protobuf.StringValue location = 5; google.protobuf.StringValue location = 5;
string account_id = 6; string account_id = 6;
google.protobuf.StringValue session_id = 7; google.protobuf.StringValue session_id = 7;
} }
message CreateActionLogResponse { message CreateActionLogResponse {
ActionLog action_log = 1; ActionLog action_log = 1;
} }
message ListActionLogsRequest { message ListActionLogsRequest {
string account_id = 1; string account_id = 1;
string action = 2; string action = 2;
int32 page_size = 3; int32 page_size = 3;
string page_token = 4; string page_token = 4;
string order_by = 5; string order_by = 5;
} }
message ListActionLogsResponse { message ListActionLogsResponse {
repeated ActionLog action_logs = 1; repeated ActionLog action_logs = 1;
string next_page_token = 2; string next_page_token = 2;
int32 total_size = 3; int32 total_size = 3;
} }
// Account Requests/Responses // Account Requests/Responses
message GetAccountRequest { message GetAccountRequest {
string id = 1; // Account ID to retrieve string id = 1; // Account ID to retrieve
} }
message GetBotAccountRequest { message GetBotAccountRequest {
string automated_id = 1; string automated_id = 1;
} }
message GetAccountBatchRequest { message GetAccountBatchRequest {
repeated string id = 1; // Account ID to retrieve repeated string id = 1; // Account ID to retrieve
} }
message GetBotAccountBatchRequest { message GetBotAccountBatchRequest {
repeated string automated_id = 1; repeated string automated_id = 1;
} }
message LookupAccountBatchRequest { message LookupAccountBatchRequest {
repeated string names = 1; repeated string names = 1;
}
message SearchAccountRequest {
string query = 1;
} }
message GetAccountBatchResponse { message GetAccountBatchResponse {
repeated Account accounts = 1; // List of accounts repeated Account accounts = 1; // List of accounts
} }
message CreateAccountRequest { message CreateAccountRequest {
string name = 1; // Required: Unique username string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name string nick = 2; // Optional: Display name
string language = 3; // Default language string language = 3; // Default language
bool is_superuser = 4; // Admin flag bool is_superuser = 4; // Admin flag
AccountProfile profile = 5; // Initial profile data AccountProfile profile = 5; // Initial profile data
} }
message UpdateAccountRequest { message UpdateAccountRequest {
string id = 1; // Account ID to update string id = 1; // Account ID to update
google.protobuf.StringValue name = 2; // New username if changing google.protobuf.StringValue name = 2; // New username if changing
google.protobuf.StringValue nick = 3; // New display name google.protobuf.StringValue nick = 3; // New display name
google.protobuf.StringValue language = 4; // New language google.protobuf.StringValue language = 4; // New language
google.protobuf.BoolValue is_superuser = 5; // Admin status google.protobuf.BoolValue is_superuser = 5; // Admin status
} }
message DeleteAccountRequest { message DeleteAccountRequest {
string id = 1; // Account ID to delete string id = 1; // Account ID to delete
bool purge = 2; // If true, permanently delete instead of soft delete bool purge = 2; // If true, permanently delete instead of soft delete
} }
message ListAccountsRequest { message ListAccountsRequest {
int32 page_size = 1; // Number of results per page int32 page_size = 1; // Number of results per page
string page_token = 2; // Token for pagination string page_token = 2; // Token for pagination
string filter = 3; // Filter expression string filter = 3; // Filter expression
string order_by = 4; // Sort order string order_by = 4; // Sort order
} }
message ListAccountsResponse { message ListAccountsResponse {
repeated Account accounts = 1; // List of accounts repeated Account accounts = 1; // List of accounts
string next_page_token = 2; // Token for next page string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of accounts int32 total_size = 3; // Total number of accounts
} }
// Profile Requests/Responses // Profile Requests/Responses
message GetProfileRequest { message GetProfileRequest {
string account_id = 1; // Account ID to get profile for string account_id = 1; // Account ID to get profile for
} }
message UpdateProfileRequest { message UpdateProfileRequest {
string account_id = 1; // Account ID to update profile for string account_id = 1; // Account ID to update profile for
AccountProfile profile = 2; // Profile data to update AccountProfile profile = 2; // Profile data to update
google.protobuf.FieldMask update_mask = 3; // Fields to update google.protobuf.FieldMask update_mask = 3; // Fields to update
} }
// Contact Requests/Responses // Contact Requests/Responses
message AddContactRequest { message AddContactRequest {
string account_id = 1; // Account to add contact to string account_id = 1; // Account to add contact to
AccountContactType type = 2; // Type of contact AccountContactType type = 2; // Type of contact
string content = 3; // Contact content (email, phone, etc.) string content = 3; // Contact content (email, phone, etc.)
bool is_primary = 4; // If this should be the primary contact bool is_primary = 4; // If this should be the primary contact
} }
message ListContactsRequest { message ListContactsRequest {
string account_id = 1; // Account ID to list contacts for string account_id = 1; // Account ID to list contacts for
AccountContactType type = 2; // Optional: filter by type AccountContactType type = 2; // Optional: filter by type
bool verified_only = 3; // Only return verified contacts bool verified_only = 3; // Only return verified contacts
} }
message ListContactsResponse { message ListContactsResponse {
repeated AccountContact contacts = 1; // List of contacts repeated AccountContact contacts = 1; // List of contacts
} }
message VerifyContactRequest { message VerifyContactRequest {
string id = 1; // Contact ID to verify string id = 1; // Contact ID to verify
string account_id = 2; // Account ID (for validation) string account_id = 2; // Account ID (for validation)
string code = 3; // Verification code string code = 3; // Verification code
} }
// Badge Requests/Responses // Badge Requests/Responses
message ListBadgesRequest { message ListBadgesRequest {
string account_id = 1; // Account to list badges for string account_id = 1; // Account to list badges for
string type = 2; // Optional: filter by type string type = 2; // Optional: filter by type
bool active_only = 3; // Only return active (non-expired) badges bool active_only = 3; // Only return active (non-expired) badges
} }
message ListBadgesResponse { message ListBadgesResponse {
repeated AccountBadge badges = 1; // List of badges repeated AccountBadge badges = 1; // List of badges
} }
message ListAuthFactorsRequest { message ListAuthFactorsRequest {
string account_id = 1; // Account to list factors for string account_id = 1; // Account to list factors for
bool active_only = 2; // Only return active (non-expired) factors bool active_only = 2; // Only return active (non-expired) factors
} }
message ListAuthFactorsResponse { message ListAuthFactorsResponse {
repeated AccountAuthFactor factors = 1; // List of auth factors repeated AccountAuthFactor factors = 1; // List of auth factors
} }
message ListConnectionsRequest { message ListConnectionsRequest {
string account_id = 1; // Account to list connections for string account_id = 1; // Account to list connections for
string provider = 2; // Optional: filter by provider string provider = 2; // Optional: filter by provider
} }
message ListConnectionsResponse { message ListConnectionsResponse {
repeated AccountConnection connections = 1; // List of connections repeated AccountConnection connections = 1; // List of connections
} }
// Relationship Requests/Responses // Relationship Requests/Responses
message ListRelationshipsRequest { message ListRelationshipsRequest {
string account_id = 1; // Account to list relationships for string account_id = 1; // Account to list relationships for
optional int32 status = 2; // Filter by status optional int32 status = 2; // Filter by status
int32 page_size = 5; // Number of results per page int32 page_size = 5; // Number of results per page
string page_token = 6; // Token for pagination string page_token = 6; // Token for pagination
} }
message ListRelationshipsResponse { message ListRelationshipsResponse {
repeated Relationship relationships = 1; // List of relationships repeated Relationship relationships = 1; // List of relationships
string next_page_token = 2; // Token for next page string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of relationships int32 total_size = 3; // Total number of relationships
} }
message GetRelationshipRequest { message GetRelationshipRequest {
string account_id = 1; string account_id = 1;
string related_id = 2; string related_id = 2;
optional int32 status = 3; optional int32 status = 3;
} }
message GetRelationshipResponse { message GetRelationshipResponse {
optional Relationship relationship = 1; optional Relationship relationship = 1;
} }
message ListRelationshipSimpleRequest { message ListRelationshipSimpleRequest {
string account_id = 1; string account_id = 1;
} }
message ListRelationshipSimpleResponse { message ListRelationshipSimpleResponse {
repeated string accounts_id = 1; repeated string accounts_id = 1;
} }

View File

@@ -11,7 +11,7 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
var response = await accounts.GetAccountAsync(request); var response = await accounts.GetAccountAsync(request);
return response; return response;
} }
public async Task<Account> GetBotAccount(Guid automatedId) public async Task<Account> GetBotAccount(Guid automatedId)
{ {
var request = new GetBotAccountRequest { AutomatedId = automatedId.ToString() }; var request = new GetBotAccountRequest { AutomatedId = automatedId.ToString() };
@@ -26,7 +26,14 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
var response = await accounts.GetAccountBatchAsync(request); var response = await accounts.GetAccountBatchAsync(request);
return response.Accounts.ToList(); return response.Accounts.ToList();
} }
public async Task<List<Account>> SearchAccounts(string query)
{
var request = new SearchAccountRequest { Query = query };
var response = await accounts.SearchAccountAsync(request);
return response.Accounts.ToList();
}
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds) public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
{ {
var request = new GetBotAccountBatchRequest(); var request = new GetBotAccountBatchRequest();

View File

@@ -0,0 +1,20 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Autocompletion;
[ApiController]
[Route("/api/autocomplete")]
public class AutocompletionController(AutocompletionService aus) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> TextAutocomplete([FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}

View File

@@ -1,10 +1,12 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Autocompletion; namespace DysonNetwork.Sphere.Autocompletion;
public class AutocompletionService(AppDatabase db) public class AutocompletionService(AppDatabase db, AccountClientHelper accountsHelper)
{ {
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, int limit = 10) public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10)
{ {
if (string.IsNullOrWhiteSpace(content)) if (string.IsNullOrWhiteSpace(content))
return []; return [];
@@ -14,7 +16,8 @@ public class AutocompletionService(AppDatabase db)
var afterAt = content[1..]; var afterAt = content[1..];
string type; string type;
string query; string query;
if (afterAt.Contains('/')) var hadSlash = afterAt.Contains('/');
if (hadSlash)
{ {
var parts = afterAt.Split('/', 2); var parts = afterAt.Split('/', 2);
type = parts[0]; type = parts[0];
@@ -25,7 +28,8 @@ public class AutocompletionService(AppDatabase db)
type = "u"; type = "u";
query = afterAt; query = afterAt;
} }
return await AutocompleteAt(type, query, limit);
return await AutocompleteAt(type, query, chatId, realmId, hadSlash, limit);
} }
if (!content.StartsWith(':')) return []; if (!content.StartsWith(':')) return [];
@@ -33,15 +37,49 @@ public class AutocompletionService(AppDatabase db)
var query = content[1..]; var query = content[1..];
return await AutocompleteSticker(query, limit); return await AutocompleteSticker(query, limit);
} }
} }
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, int limit) private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, Guid? chatId, Guid? realmId, bool hadSlash,
int limit)
{ {
var results = new List<DysonNetwork.Shared.Models.Autocompletion>(); var results = new List<DysonNetwork.Shared.Models.Autocompletion>();
switch (type) switch (type)
{ {
case "u":
var allAccounts = await accountsHelper.SearchAccounts(query);
var filteredAccounts = allAccounts;
if (chatId.HasValue)
{
var chatMemberIds = await db.ChatMembers
.Where(m => m.ChatRoomId == chatId.Value && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var chatMemberIdStrings = chatMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => chatMemberIdStrings.Contains(a.Id)).ToList();
}
else if (realmId.HasValue)
{
var realmMemberIds = await db.RealmMembers
.Where(m => m.RealmId == realmId.Value && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList();
}
var users = filteredAccounts
.Take(limit)
.Select(a => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "user",
Keyword = "@" + (hadSlash ? "u/" : "") + a.Name,
Data = SnAccount.FromProtoValue(a)
})
.ToList();
results.AddRange(users);
break;
case "p": case "p":
var publishers = await db.Publishers var publishers = await db.Publishers
.Where(p => EF.Functions.Like(p.Name, $"{query}%") || EF.Functions.Like(p.Nick, $"{query}%")) .Where(p => EF.Functions.Like(p.Name, $"{query}%") || EF.Functions.Like(p.Nick, $"{query}%"))
@@ -49,7 +87,7 @@ public class AutocompletionService(AppDatabase db)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion .Select(p => new DysonNetwork.Shared.Models.Autocompletion
{ {
Type = "publisher", Type = "publisher",
Keyword = p.Name, Keyword = "@p/" + p.Name,
Data = p Data = p
}) })
.ToListAsync(); .ToListAsync();
@@ -63,7 +101,7 @@ public class AutocompletionService(AppDatabase db)
.Select(r => new DysonNetwork.Shared.Models.Autocompletion .Select(r => new DysonNetwork.Shared.Models.Autocompletion
{ {
Type = "realm", Type = "realm",
Keyword = r.Slug, Keyword = "@r/" + r.Slug,
Data = r Data = r
}) })
.ToListAsync(); .ToListAsync();
@@ -77,7 +115,7 @@ public class AutocompletionService(AppDatabase db)
.Select(c => new DysonNetwork.Shared.Models.Autocompletion .Select(c => new DysonNetwork.Shared.Models.Autocompletion
{ {
Type = "chat", Type = "chat",
Keyword = c.Name!, Keyword = "@c/" + c.Name,
Data = c Data = c
}) })
.ToListAsync(); .ToListAsync();
@@ -92,30 +130,17 @@ public class AutocompletionService(AppDatabase db)
{ {
var stickers = await db.Stickers var stickers = await db.Stickers
.Include(s => s.Pack) .Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Slug, $"{query}%")) .Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.Take(limit) .Take(limit)
.Select(s => new DysonNetwork.Shared.Models.Autocompletion .Select(s => new DysonNetwork.Shared.Models.Autocompletion
{ {
Type = "sticker", Type = "sticker",
Keyword = s.Slug, Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
Data = s Data = s
}) })
.ToListAsync(); .ToListAsync();
// Also possibly search by pack prefix? But user said slug after : var results = stickers.ToList();
// Perhaps combine or search packs
var packs = await db.StickerPacks
.Where(p => EF.Functions.Like(p.Prefix, $"{query}%"))
.Take(limit)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "sticker_pack",
Keyword = p.Prefix,
Data = p
})
.ToListAsync();
var results = stickers.Concat(packs).Take(limit).ToList();
return results; return results;
} }
} }

View File

@@ -87,7 +87,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -103,10 +104,10 @@ public partial class ChatController(
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList(); var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList();
members = await crs.LoadMemberAccounts(members); members = await crs.LoadMemberAccounts(members);
foreach (var message in messages) foreach (var message in messages)
message.Sender = members.First(x => x.Id == message.SenderId); message.Sender = members.First(x => x.Id == message.SenderId);
@@ -129,7 +130,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -141,16 +143,81 @@ public partial class ChatController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (message is null) return NotFound(); if (message is null) return NotFound();
message.Sender = await crs.LoadMemberAccount(message.Sender); message.Sender = await crs.LoadMemberAccount(message.Sender);
return Ok(message); return Ok(message);
} }
[GeneratedRegex("@([A-Za-z0-9_-]+)")] [GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex(); private static partial Regex MentionRegex();
/// <summary>
/// Extracts mentioned users from message content, replies, and forwards
/// </summary>
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
{
var mentionedUsers = new List<Guid>();
// Add sender of a replied message
if (repliedMessageId.HasValue)
{
var replyingTo = await db.ChatMessages
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
.Include(m => m.Sender)
.Select(m => m.Sender)
.FirstOrDefaultAsync();
if (replyingTo != null)
mentionedUsers.Add(replyingTo.AccountId);
}
// Add sender of a forwarded message
if (forwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.Where(m => m.Id == forwardedMessageId.Value)
.Select(m => new { m.SenderId })
.FirstOrDefaultAsync();
if (forwardedMessage != null)
{
mentionedUsers.Add(forwardedMessage.SenderId);
}
}
// Extract mentions from content using regex
if (!string.IsNullOrWhiteSpace(content))
{
var mentionedNames = MentionRegex()
.Matches(content)
.Select(m => m.Groups[1].Value)
.Distinct()
.ToList();
if (mentionedNames.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentionedNames);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
if (mentionedIds.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
.Select(m => m.AccountId)
.ToListAsync();
mentionedUsers.AddRange(mentionedMembers);
}
}
}
return mentionedUsers.Distinct().ToList();
}
[HttpPost("{roomId:guid}/messages")] [HttpPost("{roomId:guid}/messages")]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.messages.create")] [RequiredPermission("global", "chat.messages.create")]
@@ -188,6 +255,7 @@ public partial class ChatController(
.ToList(); .ToList();
} }
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
@@ -208,28 +276,9 @@ public partial class ChatController(
message.ForwardedMessageId = forwardedMessage.Id; message.ForwardedMessageId = forwardedMessage.Id;
} }
if (request.Content is not null) // Extract mentioned users
{ message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
var mentioned = MentionRegex() request.ForwardedMessageId, roomId);
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentioned);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedId = queryResponse
.Select(a => Guid.Parse(a.Id))
.ToList();
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.Id)
.ToListAsync();
message.MembersMentioned = mentionedMembers;
}
}
var result = await cs.SendMessageAsync(message, member, member.ChatRoom); var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
@@ -259,6 +308,7 @@ public partial class ChatController(
(request.AttachmentsId == null || request.AttachmentsId.Count == 0)) (request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message."); return BadRequest("You cannot send an empty message.");
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
@@ -275,6 +325,11 @@ public partial class ChatController(
return BadRequest("The message you're forwarding does not exist."); return BadRequest("The message you're forwarding does not exist.");
} }
// Update mentions based on new content and references
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId, accountId);
message.MembersMentioned = updatedMentions;
// Call service method to update the message // Call service method to update the message
await cs.UpdateMessageAsync( await cs.UpdateMessageAsync(
message, message,
@@ -324,7 +379,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null); .AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember) if (!isMember)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -332,19 +388,21 @@ public partial class ChatController(
return Ok(response); return Ok(response);
} }
[HttpGet("{roomId:guid}/autocomplete")] [HttpPost("{roomId:guid}/autocomplete")]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete([FromBody] AutocompletionRequest request, Guid roomId) public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest request, Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null); .AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember) if (!isMember)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
var result = await aus.GetAutocompletion(request.Content, limit: 10); var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result); return Ok(result);
} }
} }

View File

@@ -15,7 +15,6 @@ public partial class ChatService(
FileService.FileServiceClient filesClient, FileService.FileServiceClient filesClient,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IRealtimeService realtime,
ILogger<ChatService> logger ILogger<ChatService> logger
) )
{ {
@@ -198,8 +197,6 @@ public partial class ChatService(
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room) public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
{ {
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString(); if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
message.UpdatedAt = message.CreatedAt;
// First complete the save operation // First complete the save operation
db.ChatMessages.Add(message); db.ChatMessages.Add(message);
@@ -209,20 +206,25 @@ public partial class ChatService(
await CreateFileReferencesForMessageAsync(message); await CreateFileReferencesForMessageAsync(message);
// Then start the delivery process // Then start the delivery process
var localMessage = message;
var localSender = sender;
var localRoom = room;
var localLogger = logger;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
await DeliverMessageAsync(message, sender, room); await DeliverMessageAsync(localMessage, localSender, localRoom);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}"); localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
} }
}); });
// Process link preview in the background to avoid delaying message sending // Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message)); var localMessageForPreview = message;
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
message.Sender = sender; message.Sender = sender;
message.ChatRoom = room; message.ChatRoom = room;
@@ -532,27 +534,10 @@ public partial class ChatService(
{ {
RoomId = room.Id, RoomId = room.Id,
SenderId = sender.Id, SenderId = sender.Id,
ProviderName = realtime.ProviderName ProviderName = "Built-in WebRTC Signaling",
SessionId = Guid.NewGuid().ToString() // Simple session ID for built-in signaling
}; };
try
{
var sessionConfig = await realtime.CreateSessionAsync(room.Id, new Dictionary<string, object>
{
{ "room_id", room.Id },
{ "user_id", sender.AccountId },
});
// Store session details
call.SessionId = sessionConfig.SessionId;
call.UpstreamConfig = sessionConfig.Parameters;
}
catch (Exception ex)
{
// Log the exception but continue with call creation
throw new InvalidOperationException($"Failed to create {realtime.ProviderName} session: {ex.Message}");
}
db.ChatRealtimeCall.Add(call); db.ChatRealtimeCall.Add(call);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -577,26 +562,7 @@ public partial class ChatService(
if (sender.Role < ChatMemberRole.Moderator && call.SenderId != sender.Id) if (sender.Role < ChatMemberRole.Moderator && call.SenderId != sender.Id)
throw new InvalidOperationException("You are not the call initiator either the chat room moderator."); throw new InvalidOperationException("You are not the call initiator either the chat room moderator.");
// End the realtime session if it exists // For built-in WebRTC signaling, just set the end time
if (!string.IsNullOrEmpty(call.SessionId) && !string.IsNullOrEmpty(call.ProviderName))
{
try
{
var config = new RealtimeSessionConfig
{
SessionId = call.SessionId,
Parameters = call.UpstreamConfig
};
await realtime.EndSessionAsync(call.SessionId, config);
}
catch (Exception ex)
{
// Log the exception but continue with call ending
throw new InvalidOperationException($"Failed to end {call.ProviderName} session: {ex.Message}");
}
}
call.EndedAt = SystemClock.Instance.GetCurrentInstant(); call.EndedAt = SystemClock.Instance.GetCurrentInstant();
db.ChatRealtimeCall.Update(call); db.ChatRealtimeCall.Update(call);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -0,0 +1,639 @@
# WebRTC Signaling Server - Client Implementation Guide
This document explains how clients should implement WebRTC signaling to work with the DysonNetwork WebRTC server.
## Overview
The WebRTC signaling server provides a WebSocket-based signaling channel for WebRTC peer-to-peer communication within chat rooms. It handles authentication, room membership verification, and message broadcasting between clients in the same chat room.
When using with the Gateway, the `/api` should be replaced with `<gateway>/sphere`
## Architecture
- **Signaling Endpoint**: `GET /api/chat/realtime/{chatId}`
- **Authentication**: JWT-based (handled by existing middleware)
- **Message Format**: WebSocketPacket (structured JSON packets)
- **Protocol**: Room-based broadcasting with client management and enforced sender validation
## Client Implementation
### 1. Prerequisites
Before implementing WebRTC signaling, ensure your client:
1. **Has Valid Authentication**: Must provide a valid JWT token for the authenticated user
2. **Is a Chat Room Member**: User must be an active member of the specified chat room
3. **Supports WebSockets**: Must be capable of establishing WebSocket connections
### 2. Connection Establishment
#### 2.1 WebSocket Connection URL
```
ws://your-server.com/api/chat/realtime/{chatId}
```
- **Protocol**: `ws://` (or `wss://` for secure connections)
- **Path**: `/api/chat/realtime/{chatId}` where `{chatId}` is the chat room GUID
- **Authentication**: Handled via existing JWT middleware (no additional query parameters needed)
#### 2.2 Authentication
The authentication is handled automatically by the server's middleware that:
1. Checks for valid JWT token in the request
2. Extracts the authenticated user (`Account`) from `HttpContext.Items["CurrentUser"]`
3. Validates that the user is a member of the specified chat room
4. Returns `401 Unauthorized` if not authenticated or `403 Forbidden` if not a room member
#### 2.3 Connection Example (JavaScript)
```javascript
class SignalingClient {
constructor(chatId, serverUrl = 'ws://localhost:5000', userId, userName) {
this.chatId = chatId;
this.ws = null;
this.serverUrl = serverUrl;
this.isConnected = false;
this.userId = userId; // Current user ID
this.userName = userName; // Current user name
this.onMessageHandlers = [];
}
// Connect to the signaling server
async connect() {
const url = `${this.serverUrl}/api/chat/realtime/${this.chatId}`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = (event) => {
this.isConnected = true;
console.log('Connected to signaling server for chat:', this.chatId);
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = (event) => {
this.isConnected = false;
console.log('Disconnected from signaling server');
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Failed to connect to signaling server:', error);
throw error;
}
}
// Disconnect from the signaling server
disconnect() {
if (this.ws && this.isConnected) {
this.ws.close();
this.isConnected = false;
}
}
}
```
### 3. Message Handling
#### 3.1 Enforced Message Format
The signaling server broadcasts messages using the WebSocketPacket format. All messages are automatically wrapped by the server with validated sender information. Clients should send only the signaling type and data, and receive complete packets with sender details.
**WebSocketPacket Format:**
For signaling messages (see SignalingMessage model):
```json
{
"type": "webrtc.signal",
"data": {
"type": "signaling-message-type",
"data": {
"offer": "...SDP string here...",
"answer": "...SDP string here...",
"candidate": {...ICE candidate data...}
},
"to": "optional-target-user-id-for-directed-messaging",
"senderAccountId": "server-validated-user-guid",
"senderInfo": {
"id": "user-guid",
"name": "username",
"nick": "display nickname",
"profile": {},
"updatedAt": "2022-01-01T00:00:00Z"
}
}
}
```
For connection established:
```json
{
"type": "webrtc",
"data": {
"userId": "user-guid",
"roomId": "room-guid",
"message": "Connected to call...",
"timestamp": "2022-01-01T00:00:00Z",
"participants": [...]
}
}
```
#### 3.2 Incoming Messages
Implement a message handler to process signaling data with user identity:
```javascript
class SignalingClient {
constructor(chatId, serverUrl = 'ws://localhost:5000', userId, userName) {
this.chatId = chatId;
this.ws = null;
this.serverUrl = serverUrl;
this.isConnected = false;
this.userId = userId; // Current user ID
this.userName = userName; // Current user name
this.onMessageHandlers = [];
}
// ... WebSocket connection methods ...
handleMessage(message) {
try {
// Parse WebSocketPacket
const packet = JSON.parse(message);
if (packet.type === 'signaling') {
// Extract signaling message with server-validated sender info
const signalingMessage = packet.data;
const senderId = signalingMessage.SenderAccountId;
const senderInfo = signalingMessage.SenderInfo;
// Ignore messages from yourself (server broadcasts to all clients)
if (senderId === this.userId) {
return;
}
// Use sender's nick or name for display
const senderDisplay = senderInfo?.nick || senderInfo?.name || senderId;
console.log(`Received ${signalingMessage.type} from ${senderDisplay} (${senderId})`);
// Call handlers with signal type and data and sender info
this.onMessageHandlers.forEach(handler => {
try {
handler(signalingMessage, senderId, senderInfo);
} catch (error) {
console.error('Error in message handler:', error);
}
});
} else if (packet.type === 'webrtc') {
// Handle connection established or other server messages
console.log('Received server message:', packet.data.message);
} else {
console.warn('Unknown packet type:', packet.type);
}
} catch (error) {
console.error('Failed to parse WebSocketPacket:', message, error);
}
}
// Register message handlers
onMessage(handler) {
this.onMessageHandlers.push(handler);
return () => {
// Return unsubscribe function
const index = this.onMessageHandlers.indexOf(handler);
if (index > -1) {
this.onMessageHandlers.splice(index, 1);
}
};
}
sendMessage(messageData) {
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('Cannot send message: WebSocket not connected');
return false;
}
try {
// Server will automatically add sender info - just send the signaling data
const messageStr = JSON.stringify(messageData);
this.ws.send(messageStr);
return true;
} catch (error) {
console.error('Failed to send message:', error);
return false;
}
}
}
```
#### 3.3 User Identity Tracking
Track connected peers with full account information:
```javascript
class SignalingClient {
constructor(chatId, serverUrl, userId, userName) {
this.chatId = chatId;
this.userId = userId;
this.userName = userName;
this.serverUrl = serverUrl;
this.ws = null;
this.isConnected = false;
this.connectedPeers = new Map(); // userId -> senderInfo
this.onPeerHandlers = [];
this.onMessageHandlers = [];
}
handleMessage(message) {
try {
const packet = JSON.parse(message);
if (packet.type === 'signaling') {
const signalingMessage = packet.data;
const senderId = signalingMessage.SenderAccountId;
const senderInfo = signalingMessage.SenderInfo;
// Track peer information with full account data
if (!this.connectedPeers.has(senderId)) {
this.connectedPeers.set(senderId, senderInfo);
this.onPeerHandlers.forEach(handler => {
try {
handler(senderId, senderInfo, 'connected');
} catch (error) {
console.error('Error in peer handler:', error);
}
});
console.log(`New peer connected: ${senderInfo?.name || senderId} (${senderId})`);
}
// Ignore messages from yourself
if (senderId === this.userId) {
return;
}
// Call handlers with signaling message and sender info
this.onMessageHandlers.forEach(handler => {
try {
handler(signalingMessage, senderId, senderInfo);
} catch (error) {
console.error('Error in message handler:', error);
}
});
} else if (packet.type === 'webrtc') {
// Handle connection established or other server messages
console.log('Received server message:', packet.data.message);
} else {
console.warn('Unknown packet type:', packet.type);
}
} catch (error) {
console.error('Failed to parse WebSocketPacket:', message, error);
}
}
// Register peer connection/disconnection handlers
onPeer(handler) {
this.onPeerHandlers.push(handler);
return () => {
const index = this.onPeerHandlers.indexOf(handler);
if (index > -1) {
this.onPeerHandlers.splice(index, 1);
}
};
}
// Get list of connected peers with full account info
getConnectedPeers() {
return Array.from(this.connectedPeers.entries()).map(([userId, senderInfo]) => ({
userId,
userInfo: senderInfo
}));
}
// Find user info by user ID
getUserInfo(userId) {
return this.connectedPeers.get(userId);
}
}
```
### 4. WebRTC Integration
#### 4.1 Complete Implementation Example
```javascript
class WebRTCCPUB extends SignalingClient {
constructor(chatId, serverUrl) {
super(chatId, serverUrl);
this.peerConnection = null;
this.localStream = null;
this.remoteStream = null;
// Initialize WebRTCPeerConnection with configuration
this.initPeerConnection();
}
initPeerConnection() {
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
this.peerConnection = new RTCPeerConnection(configuration);
// Handle ICE candidates
this.peerConnection.onicecandidate = (event) => {
if (event.candidate) {
// Send ICE candidate via signaling server
this.sendMessage({
type: 'ice-candidate',
candidate: event.candidate
});
}
};
// Handle remote stream
this.peerConnection.ontrack = (event) => {
this.remoteStream = event.streams[0];
// Attach remote stream to video element
if (this.onRemoteStream) {
this.onRemoteStream(this.remoteStream);
}
};
}
// Register for signaling messages
onMessage(signalingMessage, senderId, senderInfo) {
super.onMessage(signalingMessage, senderId, senderInfo).then(() => {
this.handleSignalingMessage(signalingMessage);
});
}
handleSignalingMessage(signalingMessage) {
switch (signalingMessage.type) {
case 'offer':
this.handleOffer(signalingMessage.data.offer);
break;
case 'answer':
this.handleAnswer(signalingMessage.data.answer);
break;
case 'ice-candidate':
this.handleIceCandidate(signalingMessage.data.candidate);
break;
default:
console.warn('Unknown message type:', signalingMessage.type);
}
}
async createOffer() {
try {
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
// Send offer via signaling server
this.sendMessage({
type: 'offer',
offer: offer
});
} catch (error) {
console.error('Error creating offer:', error);
}
}
async handleOffer(offer) {
try {
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
// Send answer via signaling server
this.sendMessage({
type: 'answer',
answer: answer
});
} catch (error) {
console.error('Error handling offer:', error);
}
}
async handleAnswer(answer) {
try {
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
async handleIceCandidate(candidate) {
try {
await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
}
// Get user media and add to peer connection
async startLocalStream(constraints = { audio: true, video: true }) {
try {
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
this.localStream.getTracks().forEach(track => {
this.peerConnection.addTrack(track, this.localStream);
});
return this.localStream;
} catch (error) {
console.error('Error accessing media devices:', error);
throw error;
}
}
}
```
### 5. Usage Flow
#### 5.1 Basic Usage Pattern
```javascript
// 1. Create signaling client
const signaling = new WebRTCCPUB(chatId, serverUrl);
// 2. Set up event handlers
signaling.onRemoteStream = (stream) => {
// Attach remote stream to video element
remoteVideoElement.srcObject = stream;
};
// 3. Connect to signaling server
await signaling.connect();
// 4. Get local media stream
await signaling.startLocalStream();
// 5. Create offer (for the caller)
await signaling.createOffer();
// The signaling server will automatically broadcast messages to other clients in the room
```
#### 5.2 Complete Call Flow Example
```javascript
async function initiateCall(chatId, serverUrl) {
const caller = new WebRTCCPUB(chatId, serverUrl);
// Connect to signaling server
await caller.connect();
// Get local stream
const localStream = await caller.startLocalStream();
localVideoElement.srcObject = localStream;
// Create and send offer
await caller.createOffer();
// Wait for remote stream
caller.onRemoteStream = (remoteStream) => {
remoteVideoElement.srcObject = remoteStream;
console.log('Call connected!');
};
}
async function answerCall(chatId, serverUrl) {
const answerer = new WebRTCCPUB(chatId, serverUrl);
// Connect to signaling server
await answerer.connect();
// Get local stream
const localStream = await answerer.startLocalStream();
localVideoElement.srcObject = localStream;
// WebRTC signaling is handled automatically by the message handlers
answerer.onRemoteStream = (remoteStream) => {
remoteVideoElement.srcObject = remoteStream;
console.log('Call connected!');
};
}
```
### 6. Error Handling
#### 6.1 Connection Errors
```javascript
// Handle connection errors
signaling.ws.addEventListener('error', (event) => {
console.error('WebSocket connection error:', event);
// Attempt reconnection or show error to user
});
// Handle server close
signaling.ws.addEventListener('close', (event) => {
console.log('WebSocket closed:', event.code, event.reason);
// Reconnect if clean closure
if (event.wasClean) {
// Re-establish connection if needed
} else {
// Report error
}
});
```
#### 6.2 WebRTC Errors
```javascript
// Handle getUserMedia errors
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (error) {
switch (error.name) {
case 'NotAllowedError':
console.error('User denied media access');
break;
case 'NotFoundError':
console.error('No media devices found');
break;
default:
console.error('Error accessing media:', error);
}
}
```
### 7. Best Practices
#### 7.1 Connection Management
- **Reconnection Logic**: Implement exponential backoff for reconnection attempts
- **Connection Pooling**: Re-use connections when possible
- **Cleanup**: Always close connections and clean up resources
#### 7.2 Message Handling
- **Message Validation**: Validate incoming signaling messages
- **Error Resilience**: Gracefully handle malformed messages
- **Message Types**: Define clear message type conventions
#### 7.3 WebRTC Configuration
- **ICE Servers**: Configure multiple STUN/TURN servers for reliability
- **Codec Preferences**: Set preferred codecs for optimal performance
- **Bandwidth Management**: Implement appropriate bitrate controls
#### 7.4 Security Considerations
- **Input Validation**: Validate all signaling data
- **Rate Limiting**: Implement appropriate rate limiting for signaling messages
- **Authentication**: Ensure proper authentication before establishing connections
### 8. Room Isolation
The signaling server guarantees that:
- **Messages stay within rooms**: Clients only receive messages from other clients in the same chat room
- **Authentication per connection**: Each WebSocket connection is individually authenticated
- **Member validation**: Only active chat room members can connect and send messages
### 9. Troubleshooting
#### 9.1 Common Issues
- **Connection refused**: Check if JWT token is valid and user is room member
- **Messages not received**: Verify room membership and connection status
- **WebRTC failures**: Check ICE server configuration and network connectivity
#### 9.2 Debug Tips
- Enable console logging for signaling events
- Monitor WebSocket connection state
- Validate signaling message formats
- Check browser developer tools for network activity
## API Reference
### WebSocket Endpoint
- **URL Pattern**: `/api/chat/realtime/{chatId}`
- **Method**: `GET`
- **Authentication**: JWT (middleware-handled)
- **Protocol**: WebSocket (ws/wss)
### Response Codes
- **401**: Unauthorized - Invalid or missing JWT
- **403**: Forbidden - User not member of chat room
- **400**: Bad Request - Not a WebSocket request
### Message Format
- **Encoding**: UTF-8 text
- **Format**: WebSocketPacket JSON (server-enforced structure)
- **Broadcasting**: Automatic to all room members except sender with validated sender information
## Additional Resources
- [WebRTC API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
- [WebSocket API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
- [WebRTC Signaling Fundamentals](https://webrtc.org/getting-started/signaling-channels)

View File

@@ -1,10 +1,12 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat.Realtime;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using System.Collections.Concurrent;
using System.Net.WebSockets;
using DysonNetwork.Shared.Proto;
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
@@ -13,6 +15,15 @@ public class RealtimeChatConfiguration
public string Endpoint { get; set; } = null!; public string Endpoint { get; set; } = null!;
} }
public class SignalingMessage
{
public string Type { get; set; } = null!;
public object? Data { get; set; }
public string? To { get; set; }
public string? AccountId { get; set; }
public SnAccount? Account { get; set; }
}
[ApiController] [ApiController]
[Route("/api/chat/realtime")] [Route("/api/chat/realtime")]
public class RealtimeCallController( public class RealtimeCallController(
@@ -20,31 +31,36 @@ public class RealtimeCallController(
AppDatabase db, AppDatabase db,
ChatService cs, ChatService cs,
ChatRoomService crs, ChatRoomService crs,
IRealtimeService realtime ILogger<RealtimeCallController> logger
) : ControllerBase ) : ControllerBase
{ {
private readonly RealtimeChatConfiguration _config = private readonly RealtimeChatConfiguration _config =
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!; configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
// A thread-safe collection to hold connected WebSocket clients per chat room.
private static readonly
ConcurrentDictionary<string, ConcurrentDictionary<Guid, (WebSocket Socket, string
AccountId, int Role)>> RoomClients = new();
// A thread-safe collection to hold participants in each room.
private static readonly
ConcurrentDictionary<string, ConcurrentDictionary<string, (Account Account, DateTime JoinedAt)>>
RoomParticipants = new();
/// <summary> /// <summary>
/// This endpoint is especially designed for livekit webhooks, /// This endpoint is for WebRTC signaling webhooks if needed in the future.
/// for update the call participates and more. /// Currently built-in WebRTC signaling doesn't require external webhooks.
/// Learn more at: https://docs.livekit.io/home/server/webhooks/
/// </summary> /// </summary>
[HttpPost("webhook")] [HttpPost("webhook")]
[SwaggerIgnore] [SwaggerIgnore]
public async Task<IActionResult> WebhookReceiver() public Task<IActionResult> WebhookReceiver()
{ {
using var reader = new StreamReader(Request.Body); // Built-in WebRTC signaling doesn't require webhooks
var postData = await reader.ReadToEndAsync(); // Return success to indicate endpoint exists for potential future use
var authHeader = Request.Headers.Authorization.ToString(); return Task.FromResult<IActionResult>(Ok("Webhook received - built-in WebRTC signaling active"));
await realtime.ReceiveWebhook(postData, authHeader);
return Ok();
} }
[HttpGet("{roomId:guid}")] [HttpGet("{roomId:guid}/status")]
[Authorize] [Authorize]
public async Task<ActionResult<SnRealtimeCall>> GetOngoingCall(Guid roomId) public async Task<ActionResult<SnRealtimeCall>> GetOngoingCall(Guid roomId)
{ {
@@ -94,46 +110,32 @@ public class RealtimeCallController(
return BadRequest("Call session is not properly configured."); return BadRequest("Call session is not properly configured.");
var isAdmin = member.Role >= ChatMemberRole.Moderator; var isAdmin = member.Role >= ChatMemberRole.Moderator;
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
// Get LiveKit endpoint from configuration // Get WebRTC signaling server endpoint from configuration
var endpoint = _config.Endpoint ?? var endpoint = _config.Endpoint ??
throw new InvalidOperationException("LiveKit endpoint configuration is missing"); throw new InvalidOperationException("WebRTC signaling endpoint configuration is missing");
// Inject the ChatRoomService // Get current participants from the participant list
var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>();
// Get current participants from the LiveKit service
var participants = new List<CallParticipant>(); var participants = new List<CallParticipant>();
if (realtime is LiveKitRealtimeService livekitService) var roomKey = ongoingCall.RoomId.ToString();
if (RoomParticipants.TryGetValue(roomKey, out var partsDict))
{ {
var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId); participants.AddRange(from part in partsDict.Values
participants = []; select new CallParticipant
foreach (var p in roomParticipants)
{
var participant = new CallParticipant
{ {
Identity = p.Identity, Identity = part.Account.Id,
Name = p.Name, Name = part.Account.Name,
AccountId = p.AccountId, AccountId = Guid.Parse(part.Account.Id),
JoinedAt = p.JoinedAt JoinedAt = part.JoinedAt
}; });
// Fetch the ChatMember profile if we have an account ID
if (p.AccountId.HasValue)
participant.Profile = await chatRoomService.GetRoomMember(p.AccountId.Value, roomId);
participants.Add(participant);
}
} }
// Create the response model // Create the response model for built-in WebRTC signaling
var response = new JoinCallResponse var response = new JoinCallResponse
{ {
Provider = realtime.ProviderName, Provider = "Built-in WebRTC Signaling",
Endpoint = endpoint, Endpoint = endpoint,
Token = userToken, Token = "", // No external token needed for built-in signaling
CallId = ongoingCall.Id, CallId = ongoingCall.Id,
RoomName = ongoingCall.SessionId, RoomName = ongoingCall.SessionId,
IsAdmin = isAdmin, IsAdmin = isAdmin,
@@ -186,6 +188,212 @@ public class RealtimeCallController(
return BadRequest(exception.Message); return BadRequest(exception.Message);
} }
} }
/// <summary>
/// WebSocket signaling endpoint for WebRTC calls in a specific chat room.
/// Path: /api/chat/realtime/{chatId}
/// Requires JWT authentication (handled by middleware).
/// </summary>
[HttpGet("{chatId:guid}")]
public async Task SignalingWebSocket(Guid chatId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
{
HttpContext.Response.StatusCode = 401;
await HttpContext.Response.WriteAsync("Unauthorized");
return;
}
// Verify the user is a member of the chat room
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
{
HttpContext.Response.StatusCode = 403;
await HttpContext.Response.WriteAsync("Forbidden: Not a member of this chat room");
return;
}
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
HttpContext.Response.StatusCode = 400;
await HttpContext.Response.WriteAsync("Bad Request: WebSocket connection expected");
return;
}
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var clientId = Guid.NewGuid();
// Add a client to the room-specific clients dictionary
var roomKey = chatId.ToString();
var roomDict = RoomClients.GetOrAdd(roomKey,
_ => new ConcurrentDictionary<Guid, (WebSocket, string, int)>());
roomDict.TryAdd(clientId, (webSocket, currentUser.Id, member.Role));
// Add to the participant list
var participantsDict = RoomParticipants.GetOrAdd(roomKey,
_ => new ConcurrentDictionary<string, (Account Account, DateTime JoinedAt)>());
var wasAdded = participantsDict.TryAdd(currentUser.Id, (currentUser, DateTime.UtcNow));
logger.LogInformation(
"WebRTC signaling client connected: {ClientId} ({UserId}) in room {RoomId}. Total clients in room: {Count}",
clientId, currentUser.Id, chatId, roomDict.Count);
// Get other participants as CallParticipant objects
var otherParticipants = participantsDict.Values
.Where(p => p.Account.Id != currentUser.Id)
.Select(p => new CallParticipant
{
Identity = p.Account.Id,
Name = p.Account.Name,
AccountId = Guid.Parse(p.Account.Id),
Account = SnAccount.FromProtoValue(p.Account),
JoinedAt = p.JoinedAt
})
.ToList();
var welcomePacket = new WebSocketPacket
{
Type = "webrtc",
Data = new
{
userId = currentUser.Id,
roomId = chatId,
message = $"Connected to call of #{chatId}.",
timestamp = DateTime.UtcNow.ToString("o"),
participants = otherParticipants
}
};
var responseBytes = welcomePacket.ToBytes();
await webSocket.SendAsync(new ArraySegment<byte>(responseBytes), WebSocketMessageType.Text, true,
CancellationToken.None);
// Broadcast user-joined to existing clients if this is the first connection for this user in the room
if (wasAdded)
{
var joinPacket = new WebSocketPacket
{
Type = "webrtc.signal",
Data = new SignalingMessage
{
Type = "user-joined",
AccountId = currentUser.Id,
Account = SnAccount.FromProtoValue(currentUser),
Data = new { }
}
};
await BroadcastMessageToRoom(chatId, clientId, joinPacket);
}
try
{
// Use a MemoryStream to build the full message from potentially multiple chunks.
using var ms = new MemoryStream();
// A larger buffer can be more efficient, but the loop is what handles correctness.
var buffer = new byte[1024 * 8];
while (webSocket.State == WebSocketState.Open)
{
ms.SetLength(0); // Clear the stream for the new message.
WebSocketReceiveResult result;
do
{
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Close)
break;
var packet = WebSocketPacket.FromBytes(ms.ToArray());
var signalingMessage = packet.GetData<SignalingMessage>();
if (signalingMessage is null)
{
logger.LogWarning("Signaling message could not be parsed, dismissed...");
continue;
}
signalingMessage.AccountId = currentUser.Id;
signalingMessage.Account = SnAccount.FromProtoValue(currentUser);
var broadcastPacket = new WebSocketPacket
{
Type = "webrtc.signal",
Data = signalingMessage
};
logger.LogDebug("Message received from {ClientId} ({UserId}): Type={MessageType}", clientId, currentUser.Id, signalingMessage.Type);
await BroadcastMessageToRoom(chatId, clientId, broadcastPacket);
}
}
catch (WebSocketException wsex) when (wsex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{
// This is an expected exception when a client closes the browser tab.
logger.LogDebug("WebRTC signaling client connection was closed prematurely for user {UserId}",
currentUser.Id);
}
catch (Exception ex)
{
logger.LogError(ex, "Error with WebRTC signaling client connection for user {UserId}", currentUser.Id);
}
finally
{
// Remove the client from the room
if (roomDict.TryRemove(clientId, out _))
{
logger.LogInformation(
"WebRTC signaling client disconnected: {ClientId} ({UserId}). Total clients in room: {Count}",
clientId, currentUser.Id, roomDict.Count);
// If no more connections from this account, remove from participants
if (roomDict.Values.All(v => v.AccountId != currentUser.Id))
{
var tempParticipantsDict = RoomParticipants.GetOrAdd(roomKey,
_ => new ConcurrentDictionary<string, (Account Account, DateTime JoinedAt)>());
if (tempParticipantsDict.TryRemove(currentUser.Id, out _))
{
logger.LogInformation("Participant {UserId} removed from room {RoomId}", currentUser.Id,
chatId);
}
}
}
webSocket.Dispose();
}
}
private async Task BroadcastMessageToRoom(Guid roomId, Guid senderId, WebSocketPacket packet)
{
var roomKey = roomId.ToString();
if (!RoomClients.TryGetValue(roomKey, out var roomDict))
return;
var messageBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(messageBytes);
var signalingMessage = packet.GetData<SignalingMessage>();
var targetAccountId = signalingMessage?.To;
foreach (var pair in roomDict)
{
// Skip sender unless it's broadcast message
if (!string.IsNullOrEmpty(targetAccountId) && pair.Key == senderId) continue;
// If directed message, only send to target
if (!string.IsNullOrEmpty(targetAccountId) && pair.Value.AccountId != targetAccountId) continue;
if (pair.Value.Socket.State != WebSocketState.Open) continue;
await pair.Value.Socket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
logger.LogDebug("Message broadcasted to {ClientId} in room {RoomId}", pair.Key, roomId);
}
}
} }
// Response model for joining a call // Response model for joining a call
@@ -220,7 +428,7 @@ public class JoinCallResponse
/// Whether the user is the admin of the call /// Whether the user is the admin of the call
/// </summary> /// </summary>
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
/// <summary> /// <summary>
/// Current participants in the call /// Current participants in the call
/// </summary> /// </summary>
@@ -236,22 +444,22 @@ public class CallParticipant
/// The participant's identity (username) /// The participant's identity (username)
/// </summary> /// </summary>
public string Identity { get; set; } = null!; public string Identity { get; set; } = null!;
/// <summary> /// <summary>
/// The participant's display name /// The participant's display name
/// </summary> /// </summary>
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
/// <summary> /// <summary>
/// The participant's account ID if available /// The participant's account ID if available
/// </summary> /// </summary>
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
/// <summary> /// <summary>
/// The participant's profile in the chat /// The participant's profile in the chat
/// </summary> /// </summary>
public SnChatMember? Profile { get; set; } public SnAccount? Account { get; set; }
/// <summary> /// <summary>
/// When the participant joined the call /// When the participant joined the call
/// </summary> /// </summary>

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Poll; using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
@@ -23,6 +24,7 @@ public class PostController(
AppDatabase db, AppDatabase db,
PostService ps, PostService ps,
PublisherService pub, PublisherService pub,
AccountClientHelper accountsHelper,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als, ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments, PaymentService.PaymentServiceClient payments,
@@ -97,7 +99,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -197,7 +199,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -228,7 +230,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -271,6 +273,14 @@ public class PostController(
.Take(take) .Take(take)
.Skip(offset) .Skip(offset)
.ToListAsync(); .ToListAsync();
var accountsProto = await accountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
foreach (var reaction in reactions)
if (accounts.TryGetValue(reaction.AccountId, out var account))
reaction.Account = account;
return Ok(reactions); return Ok(reactions);
} }
@@ -283,7 +293,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -314,7 +324,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -342,7 +352,7 @@ public class PostController(
if (currentUser != null) if (currentUser != null)
{ {
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id }); { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
} }
@@ -448,7 +458,10 @@ public class PostController(
if (request.RepliedPostId is not null) if (request.RepliedPostId is not null)
{ {
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value); var repliedPost = await db.Posts
.Where(p => p.Id == request.RepliedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (repliedPost is null) return BadRequest("Post replying to was not found."); if (repliedPost is null) return BadRequest("Post replying to was not found.");
post.RepliedPost = repliedPost; post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id; post.RepliedPostId = repliedPost.Id;
@@ -456,7 +469,10 @@ public class PostController(
if (request.ForwardedPostId is not null) if (request.ForwardedPostId is not null)
{ {
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value); var forwardedPost = await db.Posts
.Where(p => p.Id == request.ForwardedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (forwardedPost is null) return BadRequest("Forwarded post was not found."); if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
post.ForwardedPost = forwardedPost; post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id; post.ForwardedPostId = forwardedPost.Id;
@@ -514,7 +530,7 @@ public class PostController(
}); });
post.Publisher = publisher; post.Publisher = publisher;
return post; return post;
} }
@@ -536,7 +552,7 @@ public class PostController(
var friendsResponse = var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() }); { AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
@@ -632,7 +648,7 @@ public class PostController(
var friendsResponse = var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() }); { AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
@@ -883,7 +899,7 @@ public class PostController(
UserAgent = Request.Headers.UserAgent, UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
}); });
return Ok(post); return Ok(post);
} }
@@ -915,4 +931,4 @@ public class PostController(
return NoContent(); return NoContent();
} }
} }

View File

@@ -14,7 +14,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices();
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();

View File

@@ -282,9 +282,9 @@ public class PublisherService(
public int SubscribersCount { get; set; } public int SubscribersCount { get; set; }
} }
private const string PublisherStatsCacheKey = "PublisherStats_{0}"; private const string PublisherStatsCacheKey = "publisher:{0}:stats";
private const string PublisherHeatmapCacheKey = "PublisherHeatmap_{0}"; private const string PublisherHeatmapCacheKey = "publisher:{0}:heatmap";
private const string PublisherFeatureCacheKey = "PublisherFeature_{0}_{1}"; private const string PublisherFeatureCacheKey = "publisher:{0}:feature:{1}";
public async Task<PublisherStats?> GetPublisherStats(string name) public async Task<PublisherStats?> GetPublisherStats(string name)
{ {
@@ -329,7 +329,7 @@ public class PublisherService(
public async Task<ActivityHeatmap?> GetPublisherHeatmap(string name) public async Task<ActivityHeatmap?> GetPublisherHeatmap(string name)
{ {
var cacheKey = string.Format(PublisherHeatmapCacheKey, name); var cacheKey = string.Format(PublisherHeatmapCacheKey, name);
var heatmap = await cache.GetAsync<ActivityHeatmap>(cacheKey); var heatmap = await cache.GetAsync<ActivityHeatmap?>(cacheKey);
if (heatmap is not null) if (heatmap is not null)
return heatmap; return heatmap;

View File

@@ -15,6 +15,7 @@ using System.Text.Json.Serialization;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Autocompletion; using DysonNetwork.Sphere.Autocompletion;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Discovery;
@@ -25,7 +26,7 @@ namespace DysonNetwork.Sphere.Startup;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services)
{ {
services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddLocalization(options => options.ResourcesPath = "Resources");
@@ -40,7 +41,6 @@ public static class ServiceCollectionExtensions
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options => }).AddDataAnnotationsLocalization(options =>
@@ -119,6 +119,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<WebFeedService>(); services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>(); services.AddScoped<DiscoveryService>();
services.AddScoped<PollService>(); services.AddScoped<PollService>();
services.AddScoped<AccountClientHelper>();
services.AddScoped<AutocompletionService>(); services.AddScoped<AutocompletionService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower(); var translationProvider = configuration["Translation:Provider"]?.ToLower();

View File

@@ -237,6 +237,22 @@ public class StickerController(
return Redirect($"/drive/files/{sticker.Image.Id}?original=true"); return Redirect($"/drive/files/{sticker.Image.Id}?original=true");
} }
[HttpGet("search")]
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
{
var queryable = db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.OrderByDescending(s => s.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var stickers = await queryable.Take(take).Skip(offset).ToListAsync();
return Ok(stickers);
}
[HttpGet("{packId:guid}/content/{id:guid}")] [HttpGet("{packId:guid}/content/{id:guid}")]
public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id) public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id)
{ {
@@ -420,4 +436,4 @@ public class StickerController(
return NoContent(); return NoContent();
} }
} }

View File

@@ -4,7 +4,7 @@
"SiteUrl": "https://solian.app", "SiteUrl": "https://solian.app",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },

View File

@@ -1,38 +1,38 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5071", "BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app", "SiteUrl": "https://solian.app",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"GeoIp": {
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "",
"ApiSecret": ""
},
"Translation": {
"Provider": "Tencent",
"Region": "ap-hongkong",
"ProjectId": "0",
"SecretId": "",
"SecretKey": ""
},
"KnownProxies": ["127.0.0.1", "::1"],
"Etcd": {
"Insecure": true
},
"Service": {
"Name": "DysonNetwork.Sphere",
"Url": "https://localhost:7099"
} }
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"GeoIp": {
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "",
"ApiSecret": ""
},
"Translation": {
"Provider": "Tencent",
"Region": "ap-hongkong",
"ProjectId": "0",
"SecretId": "",
"SecretKey": ""
},
"KnownProxies": ["127.0.0.1", "::1"],
"Etcd": {
"Insecure": true
},
"Service": {
"Name": "DysonNetwork.Sphere",
"Url": "https://localhost:7099"
}
} }