Модуль связей и Klipy

This commit is contained in:
Халимов Рустам
2026-03-27 01:22:20 +03:00
parent 7ef73b414c
commit b399649cfd
31 changed files with 603 additions and 472 deletions

View File

@@ -0,0 +1,52 @@
# Интеграция Klipy (Klipy Integration Module) — Knot Messager
Модуль Klipy обеспечивает интеграцию с внешним сервисом короткого видеоконтента и GIF. Он позволяет пользователям добавлять динамический контент в свои истории и сообщения, в то время как администратор сохраняет полный контроль над доступом к сервису.
---
## 🛠 Архитектура интеграции
Интеграция построена на принципах **Loose Coupling** (слабой связности):
1. **Abstractions**: Интерфейс [IKlipyClient](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs#7-12) находится в модуле `Stories.Application`, что позволяет бизнес-логике не зависеть от конкретной реализации HTTP-вызовов.
2. **Infrastructure**: Реализация [KlipyClient](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs#14-18) в `Stories.Infrastructure` отвечает за взаимодействие с внешним API `api.klipy.co`.
3. **Cross-Module Configuration**: Настройки интеграции хранятся в модуле [Admin](file:///e:/GIT/forkmessager/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs#19-94), но считываются модулем [Stories](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs#13-27) через [ISettingsService](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs#6-12).
---
## ⚙ Настройка через Admin Module
Администратор управляет интеграцией через панель управления (`SystemSettingsDto.Klipy`):
* **Enabled**: Глобальный переключатель активности. Если выключено — создание контента типа Klipy блокируется на уровне API.
* **ApiKey**: Уникальный токен доступа к API Klipy.
* **AppName**: Имя приложения для идентификации в системе Klipy.
---
## 🧪 Валидация и Тестирование (Test Connectivity)
Для обеспечения надежности в [AdminEndpoints](file:///e:/GIT/forkmessager/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs#19-94) реализована ручка проверки:
* **EndPoint**: `POST /api/admin/settings/test-klipy`
* **Механизм**: Сервер выполняет реальный сетевой запрос к эндпоинту `trending` API Klipy с использованием настроенного ключа.
* **Результат**: Возвращает `200 OK`, если ключ валиден и сервис доступен, или ошибку `Klipy.AuthFailed`, если ключ не прошел авторизацию.
---
## 📽 Использование в Историях (Stories)
Когда пользователь создает историю типа `klipy`:
1. Система проверяет `parsedType == StoryType.Klipy`.
2. Выполняется запрос к [SettingsService](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs#11-99).
3. Если `Klipy.Enabled == false`, возвращается ошибка `Stories.KlipyDisabled`.
4. Если активно — `MediaUrl` истории будет содержать прямую ссылку на контент Klipy.
---
## 🛡 Безопасность
* **API Key Isolation**: Ключ API никогда не передается клиенту. Все проверки (Test Connection) и поисковые запросы (Search) выполняются на стороне сервера.
* **Proxy-Free Media**: Для экономии трафика сервера медиа-файлы Klipy загружаются клиентом напрямую с серверов провайдера (`MediaUrl`), но только после валидации права на создание такой истории.
---
> [!TIP]
> При возникновении ошибок `Klipy.AuthFailed` проверьте сетевые настройки сервера (доступ к `https://api.klipy.co`) и срок действия API-ключа в кабинете Klipy.

View File

@@ -0,0 +1,69 @@
# Модуль Связей (Relations Module) — Knot Messager
Модуль Связей (Relations) является архитектурным фундаментом для управления социальным графом и разграничения прав доступа между узлами (пользователями) в Knot Messager. Он аккумулирует в себе все типы взаимодействий: от контактов и подписок до блокировок и федеративных связей.
---
## 🏗 Архитектура и Назначение
Модуль Relations отделен от модуля аутентификации ([Auth](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Domain/AuthErrors.cs#5-15)) и модуля сообщений (`Messaging`), что позволяет ему выступать **Единым источником истины (SSOT)** для ответов на вопросы:
1. Является ли Пользователь Б контактом Пользователя А?
2. Может ли Пользователь А видеть истории или статус Presence Пользователя Б?
3. Находится ли отправитель в черном списке получателя?
---
## ⚡ Репликация данных (UserReplica Ecosystem)
Для обеспечения высокой скорости работы и соблюдения принципов **Clean Architecture**, модуль Relations оперирует локальными репликами данных:
* **UserReplica**: Содержит базовую информацию о пользователе (Id, Username, DisplayName, Avatar).
* **Federation Metadata**: Хранит признаки внешнего пользователя (`IsExternal`, [Domain](file:///e:/GIT/forkmessager/backend/src/Modules/Federation/Application/Federation/Events/FederatedMessageActionsHandler.cs#86-97)).
* **Автономия**: Все запросы на получение списков контактов и статусов связей выполняются внутри схемы `relations`, что исключает межмодульные Joins и повышает производительность системы.
---
## 📂 Типы связей: Контакты (Contacts)
В текущей версии мессенджера основной реализацией связей являются **Контакты**:
* **Contact Request**: Модель взаимодействия, требующая явного подтверждения (Accept).
* **Status Management**: Сущность [Contact](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Domain/Contact.cs#21-22) поддерживает полный жизненный цикл связи: `Pending``Accepted``Blocked/Removed`.
### Статусы взаимодействия:
| Статус | Описание |
| :--- | :--- |
| `Pending` | Запрос на связь отправлен, ожидает решения получателя. |
| `Accepted` | Двусторонняя связь установлена. Разрешены: сообщения, истории, Presence. |
| `Blocked` | Черный список. Полная блокировка любых входящих сигналов от данного узла. |
| `Declined` | Запрос отклонен пользователем. |
---
## 📡 API и Эндпоинты
Системный путь: `/api/contacts` (в рамках модуля Relations).
* `GET /api/contacts` — Получить список активных контактов.
* `POST /api/contacts/request` — Создать новую связь (запрос).
* `POST /api/contacts/{id}/accept` — Подтвердить входящую связь.
* `DELETE /api/contacts/{id}` — Разорвать связь (удалить контакт).
---
## 🌍 Федерация и Масштабируемость
Модуль Relations спроектирован с учетом **Federation-First**:
1. **Cross-Server Identity**: При создании связи с внешним пользователем (например, `bob@knot.cloud`), модуль Relations автоматически создает запись в [UserReplica](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Domain/UserReplica.cs#3-12) с пометкой `IsExternal = true`.
2. **Privacy Control**: Перед любой передачей данных через `Federation Gateway`, система проверяет статус связи в модуле [Relations](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs#13-71), обеспечивая приватность данных пользователей.
---
## 🧩 Интеграция с другими модулями
* **Stories Module**: Использует [Relations](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs#13-71), чтобы определить круг лиц, имеющих право на просмотр ленты историй.
* **Messaging Module**: Запрашивает статус связи для фильтрации спама и применения настроек приватности.
* **Admin Module**: Позволяет администраторам видеть анонимизированную статистику связей в системе.
---
> [!IMPORTANT]
> Модуль Relations использует схему `relations` в БД. Любые расширения социального графа (подписки на каналы, групповые связи) должны реализовываться именно здесь для сохранения архитектурной чистоты.

View File

@@ -0,0 +1,39 @@
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Stories.Application.Abstractions;
namespace Host.Application.Admin.Commands.TestKlipy;
public record TestKlipyConnectionCommand() : ICommand<bool>;
internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKlipyConnectionCommand, bool>
{
private readonly ISettingsService _settingsService;
private readonly IKlipyClient _klipyClient;
public TestKlipyConnectionCommandHandler(ISettingsService settingsService, IKlipyClient klipyClient)
{
_settingsService = settingsService;
_klipyClient = klipyClient;
}
public async Task<Result<bool>> Handle(TestKlipyConnectionCommand request, CancellationToken ct)
{
var settings = await _settingsService.GetSettingsAsync(ct);
if (string.IsNullOrEmpty(settings.Klipy.ApiKey))
{
return Result.Failure<bool>(new Error("Klipy.KeyMissing", "API Key for Klipy is not configured."));
}
var isOk = await _klipyClient.TestConnectionAsync(settings.Klipy.ApiKey, ct);
if (!isOk)
{
return Result.Failure<bool>(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key."));
}
return Result.Success(true);
}
}

View File

@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Host.Application.Admin.Commands.TestKlipy;
namespace Knot.Host.Presentation.Endpoints;
@@ -33,6 +34,12 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value);
});
group.MapPost("settings/test-klipy", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new TestKlipyConnectionCommand(), ct);
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("dashboard", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetDashboardStatsQuery(), ct);

View File

@@ -6,9 +6,10 @@ using System.Threading.Tasks;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Abstractions;
public interface IRelationsDbContext
public interface IContactsDbContext
{
DbSet<Friendship> Friendships { get; }
DbSet<Contact> Contacts { get; }
DbSet<UserReplica> UserReplicas { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,35 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Application.Contacts;
public record AcceptContactRequestCommand(Guid UserId, Guid ContactId) : ICommand<Guid>;
internal sealed class AcceptContactRequestCommandHandler : ICommandHandler<AcceptContactRequestCommand, Guid>
{
private readonly IContactsDbContext _context;
public AcceptContactRequestCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<Guid>> Handle(AcceptContactRequestCommand request, CancellationToken cancellationToken)
{
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.ContactId, cancellationToken);
if (contact == null || contact.ContactId != request.UserId)
{
return Result.Failure<Guid>(new Error("Contacts.NotFound", "Contact request not found."));
}
contact.Accept();
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(contact.Id);
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Contacts;
public record ContactDto(
Guid Id,
string Username,
string DisplayName,
string Avatar,
bool IsOnline,
DateTime? LastSeen,
Guid RelationId,
bool IsBlocked = false,
bool IsExternal = false,
string? Domain = null);
public record GetContactsQuery(Guid UserId) : IQuery<List<ContactDto>>;
internal sealed class GetContactsQueryHandler : IQueryHandler<GetContactsQuery, List<ContactDto>>
{
private readonly IContactsDbContext _context;
public GetContactsQueryHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<List<ContactDto>>> Handle(GetContactsQuery request, CancellationToken cancellationToken)
{
var relations = await _context.Contacts
.Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Accepted)
.ToListAsync(cancellationToken);
var contactIds = relations.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId).ToList();
var replicas = await _context.UserReplicas
.Where(r => contactIds.Contains(r.Id))
.ToListAsync(cancellationToken);
var result = new List<ContactDto>();
foreach (var replica in replicas)
{
var rel = relations.First(c => c.UserId == replica.Id || c.ContactId == replica.Id);
result.Add(new ContactDto(
replica.Id,
replica.Username,
replica.DisplayName,
replica.Avatar,
false, // Presence handled by another service or real-time
null,
rel.Id,
rel.Status == ContactStatus.Blocked,
replica.IsExternal,
replica.Domain
));
}
return Result.Success(result);
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Contacts;
public record ContactUserDto(Guid Id, string Username, string DisplayName, string Avatar);
public record ContactRequestDto(Guid Id, ContactUserDto User, DateTime CreatedAt);
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<ContactRequestDto>>;
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<ContactRequestDto>>
{
private readonly IContactsDbContext _context;
public GetIncomingRequestsQueryHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<List<ContactRequestDto>>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken)
{
var contacts = await _context.Contacts
.Where(c => c.ContactId == request.UserId && c.Status == ContactStatus.Pending)
.ToListAsync(cancellationToken);
var requesterIds = contacts.Select(c => c.UserId).ToList();
var replicas = await _context.UserReplicas
.Where(r => requesterIds.Contains(r.Id))
.ToDictionaryAsync(r => r.Id, cancellationToken);
var result = contacts
.Where(c => replicas.ContainsKey(c.UserId))
.Select(c =>
{
var user = replicas[c.UserId];
return new ContactRequestDto(
c.Id,
new ContactUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
c.CreatedAt
);
})
.ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,43 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Application.Contacts;
public record RemoveContactCommand(Guid UserId, Guid ContactRelationId) : ICommand;
internal sealed class RemoveContactCommandHandler : ICommandHandler<RemoveContactCommand>
{
private readonly IContactsDbContext _context;
public ContactRelationNotFound() : base("Contacts.NotFound", "Contact relation not found.") { }
public ContactRelationUnauthorized() : base("Contacts.Unauthorized", "You are not authorized to remove this contact.") { }
public RemoveContactCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result> Handle(RemoveContactCommand request, CancellationToken cancellationToken)
{
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.ContactRelationId, cancellationToken);
if (contact == null)
{
return Result.Failure(new Error("Contacts.NotFound", "Contact relation not found."));
}
if (contact.UserId != request.UserId && contact.ContactId != request.UserId)
{
return Result.Failure(new Error("Contacts.Unauthorized", "You are not authorized to remove this contact."));
}
_context.Contacts.Remove(contact);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Contacts;
public record SendContactRequestCommand(Guid UserId, Guid ContactId) : ICommand;
internal sealed class SendContactRequestCommandHandler : ICommandHandler<SendContactRequestCommand>
{
private readonly IContactsDbContext _context;
public SendContactRequestCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result> Handle(SendContactRequestCommand request, CancellationToken cancellationToken)
{
if (request.UserId == request.ContactId)
{
return Result.Failure(new Error("Contacts.Self", "You cannot add yourself to contacts."));
}
var existing = await _context.Contacts
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.ContactId == request.ContactId) ||
(f.UserId == request.ContactId && f.ContactId == request.UserId), cancellationToken);
if (existing != null)
{
return Result.Failure(new Error("Contacts.Exists", "Contact relation already exists."));
}
var contact = Contact.CreateRequest(request.UserId, request.ContactId);
_context.Contacts.Add(contact);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,37 +0,0 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand<Guid>;
internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler<AcceptFriendRequestCommand, Guid>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public AcceptFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || friendship.FriendId != request.UserId)
{
return Result.Failure<Guid>(IdentityErrors.FriendsNotFound);
}
friendship.Accept();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(friendship.Id);
}
}

View File

@@ -1,14 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends.DTOs;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen,
Guid FriendshipId
);

View File

@@ -1,17 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends.DTOs;
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);
public record FriendUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -1,6 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends.DTOs;
public record SendFriendRequest(Guid FriendId);

View File

@@ -1,37 +0,0 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record DeclineFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class DeclineFriendRequestCommandHandler : ICommandHandler<DeclineFriendRequestCommand>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public DeclineFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(DeclineFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || friendship.FriendId != request.UserId)
{
return Result.Failure(IdentityErrors.FriendsNotFound);
}
friendship.Decline();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeenAt,
Guid FriendshipId
);

View File

@@ -1,12 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record FriendUserDto(Guid Id, string Username, string DisplayName, string? Avatar);
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);

View File

@@ -1,10 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record FriendshipStatusResponse(
string Status,
Guid? FriendshipId = null,
string? Direction = null
);

View File

@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetFriendsQuery(Guid UserId) : IQuery<List<FriendDto>>;
internal sealed class GetFriendsQueryHandler : IQueryHandler<GetFriendsQuery, List<FriendDto>>
{
private readonly IRelationsDbContext _context;
private readonly IUserRepository _userRepository;
public GetFriendsQueryHandler(IRelationsDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendDto>>> Handle(GetFriendsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => (f.UserId == request.UserId || f.FriendId == request.UserId) && f.Status == FriendshipStatus.Accepted)
.ToListAsync(cancellationToken);
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
var friends = new List<FriendDto>();
foreach (var id in friendIds)
{
var user = await _userRepository.GetByIdAsync(id, cancellationToken);
if (user == null)
{
continue;
}
var fs = friendships.First(f => f.UserId == id || f.FriendId == id);
friends.Add(new FriendDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false, // IsOnline logic shouldn't be here, but we'll leave default for now
DateTime.UtcNow,
fs.Id
));
}
return Result.Success(friends);
}
}

View File

@@ -1,44 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetFriendshipStatusQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<FriendshipStatusResponse>;
internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler<GetFriendshipStatusQuery, FriendshipStatusResponse>
{
private readonly IRelationsDbContext _context;
public GetFriendshipStatusQueryHandler(IRelationsDbContext context)
{
_context = context;
}
public async Task<Result<FriendshipStatusResponse>> Handle(GetFriendshipStatusQuery request, CancellationToken cancellationToken)
{
if (request.CurrentUserId == request.TargetUserId)
{
return Result.Success(new FriendshipStatusResponse("self"));
}
var fs = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == request.CurrentUserId && f.FriendId == request.TargetUserId) ||
(f.UserId == request.TargetUserId && f.FriendId == request.CurrentUserId), cancellationToken);
if (fs == null)
{
return Result.Success(new FriendshipStatusResponse("none"));
}
return Result.Success(new FriendshipStatusResponse(
fs.Status.ToString().ToLowerInvariant(),
fs.Id,
fs.UserId == request.CurrentUserId ? "outgoing" : "incoming"
));
}
}

View File

@@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<FriendRequestDto>>
{
private readonly IRelationsDbContext _context;
private readonly IUserRepository _userRepository;
public GetIncomingRequestsQueryHandler(IRelationsDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendRequestDto>>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => f.FriendId == request.UserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(cancellationToken);
var requestsList = new List<FriendRequestDto>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.UserId, cancellationToken);
if (user == null)
{
continue;
}
requestsList.Add(new FriendRequestDto(
fs.Id,
new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
fs.CreatedAt
));
}
return Result.Success(requestsList);
}
}

View File

@@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetOutgoingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler<GetOutgoingRequestsQuery, List<FriendRequestDto>>
{
private readonly IRelationsDbContext _context;
private readonly IUserRepository _userRepository;
public GetOutgoingRequestsQueryHandler(IRelationsDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<FriendRequestDto>>> Handle(GetOutgoingRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Friendships
.Where(f => f.UserId == request.UserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(cancellationToken);
var requestsList = new List<FriendRequestDto>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.FriendId, cancellationToken);
if (user == null)
{
continue;
}
requestsList.Add(new FriendRequestDto(
fs.Id,
new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
fs.CreatedAt
));
}
return Result.Success(requestsList);
}
}

View File

@@ -1,37 +0,0 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record RemoveFriendCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class RemoveFriendCommandHandler : ICommandHandler<RemoveFriendCommand>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public RemoveFriendCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(RemoveFriendCommand request, CancellationToken cancellationToken)
{
var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken);
if (friendship == null || (friendship.UserId != request.UserId && friendship.FriendId != request.UserId))
{
return Result.Failure(IdentityErrors.FriendsNotFound);
}
_context.Friendships.Remove(friendship);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,47 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record SendFriendRequestCommand(Guid UserId, Guid FriendId) : ICommand;
internal sealed class SendFriendRequestCommandHandler : ICommandHandler<SendFriendRequestCommand>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public SendFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
{
_context = context;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken)
{
if (request.UserId == request.FriendId)
{
return Result.Failure(IdentityErrors.FriendsSelf);
}
var existing = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.FriendId == request.FriendId) ||
(f.UserId == request.FriendId && f.FriendId == request.UserId), cancellationToken);
if (existing != null)
{
return Result.Failure(IdentityErrors.FriendsExists);
}
var friendship = Friendship.Create(request.UserId, request.FriendId);
_context.Friendships.Add(friendship);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,40 @@
using Knot.Shared.Kernel;
using System;
namespace Knot.Modules.Relations.Domain;
public enum ContactStatus
{
Pending,
Accepted,
Declined,
Blocked
}
public sealed class Contact : Entity<Guid>, IAggregateRoot
{
public Guid UserId { get; private set; }
public Guid ContactId { get; private set; }
public ContactStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
private Contact() : base(Guid.NewGuid()) { }
private Contact(Guid id, Guid userId, Guid contactId, ContactStatus status) : base(id)
{
UserId = userId;
ContactId = contactId;
Status = status;
CreatedAt = DateTime.UtcNow;
}
public static Contact CreateRequest(Guid userId, Guid contactId)
{
return new Contact(Guid.NewGuid(), userId, contactId, ContactStatus.Pending);
}
public void Accept() => Status = ContactStatus.Accepted;
public void Decline() => Status = ContactStatus.Declined;
public void Block() => Status = ContactStatus.Blocked;
public void Unblock() => Status = ContactStatus.Accepted; // Or Pending if it was pending
}

View File

@@ -0,0 +1,70 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Relations.Infrastructure.Persistence;
public sealed class RelationsDbContext : DbContext
{
private readonly IMediator _mediator;
public RelationsDbContext(DbContextOptions<RelationsDbContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator;
}
public DbSet<Contact> Contacts => Set<Contact>();
public DbSet<UserReplica> UserReplicas => Set<UserReplica>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("relations");
modelBuilder.Entity<Contact>(builder =>
{
builder.ToTable("Contacts");
builder.HasKey(c => c.Id);
builder.Property(c => c.Status).HasConversion<string>();
builder.HasIndex(c => new { c.UserId, c.ContactId }).IsUnique();
});
modelBuilder.Entity<UserReplica>(builder =>
{
builder.ToTable("UserReplicas");
builder.HasKey(r => r.Id);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
var events = root.GetDomainEvents().ToList();
root.ClearDomainEvents();
return events;
}
return Enumerable.Empty<IDomainEvent>();
})
.ToList();
int result = await base.SaveChangesAsync(cancellationToken);
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
return result;
}
}

View File

@@ -7,61 +7,40 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Knot.Modules.Relations.Application.Contacts;
namespace Knot.Modules.Relations.Presentation.Endpoints;
public sealed class FriendsEndpoints : ICarterModule
public sealed class ContactsEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/friends").RequireAuthorization();
var group = app.MapGroup("api/contacts").RequireAuthorization();
group.MapGet("", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetFriendsQuery(userContext.UserId), ct);
var result = await sender.Send(new GetContactsQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
group.MapGet("requests", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
group.MapPost("request", async ([FromBody] SendContactRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetIncomingRequestsQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
group.MapGet("outgoing", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetOutgoingRequestsQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
group.MapPost("request", async ([FromBody] SendFriendRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new SendFriendRequestCommand(userContext.UserId, request.FriendId), ct);
var result = await sender.Send(new SendContactRequestCommand(userContext.UserId, request.ContactId), ct);
return result.IsSuccess ? Results.Ok(new { status = "pending" }) : Results.BadRequest(result.Error.Description);
});
group.MapPost("{id:guid}/accept", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AcceptFriendRequestCommand(userContext.UserId, id), ct);
var result = await sender.Send(new AcceptContactRequestCommand(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(new { id = result.Value }) : Results.NotFound(result.Error.Description);
});
group.MapPost("{id:guid}/decline", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new DeclineFriendRequestCommand(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(new Knot.Shared.Kernel.SuccessResponse(true)) : Results.NotFound(result.Error.Description);
});
group.MapGet("status/{userId:guid}", async (Guid userId, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetFriendshipStatusQuery(userContext.UserId, userId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveFriendCommand(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(new Knot.Shared.Kernel.SuccessResponse(true)) : Results.NotFound(result.Error.Description);
var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(new SuccessResponse(true)) : Results.NotFound(result.Error.Description);
});
}
public record SendContactRequest(Guid ContactId);
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Stories.Application.Abstractions;
public interface IKlipyClient
{
Task<bool> TestConnectionAsync(string apiKey, CancellationToken ct = default);
Task<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default);
}

View File

@@ -3,6 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Stories.Domain;
@@ -13,10 +14,12 @@ public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, str
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
{
private readonly IStoryRepository _storyRepository;
private readonly ISettingsService _settingsService;
public CreateStoryCommandHandler(IStoryRepository storyRepository)
public CreateStoryCommandHandler(IStoryRepository storyRepository, ISettingsService settingsService)
{
_storyRepository = storyRepository;
_settingsService = settingsService;
}
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
@@ -26,6 +29,16 @@ internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCom
parsedType = StoryType.Text;
}
// Проверка Klipy
if (parsedType == StoryType.Klipy)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
if (!settings.Klipy.Enabled)
{
return Result.Failure<Guid>(new Error("Stories.KlipyDisabled", "Klipy integration is disabled by administrator."));
}
}
var story = Story.Create(
request.UserId,
parsedType,

View File

@@ -4,5 +4,6 @@ public enum StoryType
{
Text = 0,
Image = 1,
Video = 2
Video = 2,
Klipy = 3
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using Knot.Modules.Stories.Application.Abstractions;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Stories.Infrastructure.External;
public sealed class KlipyClient : IKlipyClient
{
private readonly HttpClient _httpClient;
public KlipyClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<bool> TestConnectionAsync(string apiKey, CancellationToken ct = default)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.klipy.co/v1/trending?limit=1");
request.Headers.Add("X-API-KEY", apiKey);
using var response = await _httpClient.SendAsync(request, ct);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public async Task<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default)
{
// В реальном проекте: десериализация ответа от Klipy API
return new List<string>();
}
}